1use anyhow::{anyhow, Result};
7use async_trait::async_trait;
8use redis::{cmd, Client};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::sync::Arc;
12use std::time::{Duration, SystemTime};
13use tokio::sync::RwLock;
14use tracing::{debug, info};
15
16#[derive(Clone, PartialEq, Eq)]
21pub struct EncryptionKey(Vec<u8>);
22
23impl EncryptionKey {
24 pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
28 Self(bytes.into())
29 }
30
31 pub fn as_bytes(&self) -> &[u8] {
33 &self.0
34 }
35}
36
37impl std::fmt::Debug for EncryptionKey {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 write!(f, "EncryptionKey([REDACTED; {} bytes])", self.0.len())
40 }
41}
42
43#[derive(Debug, Clone)]
45pub struct CacheConfig {
46 pub redis_urls: Vec<String>,
47 pub default_ttl: Duration,
48 pub max_cache_size: u64,
49 pub compression_enabled: bool,
50 pub encryption_enabled: bool,
51 pub encryption_key: Option<EncryptionKey>,
55 pub cluster_mode: bool,
56 pub sharding_strategy: ShardingStrategy,
57 pub eviction_policy: EvictionPolicy,
58 pub consistency_level: ConsistencyLevel,
59 pub replication_factor: usize,
60 pub local_cache_size: usize,
61 pub prefetch_enabled: bool,
62}
63
64impl Default for CacheConfig {
65 fn default() -> Self {
66 Self {
67 redis_urls: vec!["redis://localhost:6379".to_string()],
68 default_ttl: Duration::from_secs(3600),
69 max_cache_size: 1024 * 1024 * 1024, compression_enabled: true,
71 encryption_enabled: false,
72 encryption_key: None,
73 cluster_mode: false,
74 sharding_strategy: ShardingStrategy::ConsistentHashing,
75 eviction_policy: EvictionPolicy::LRU,
76 consistency_level: ConsistencyLevel::Eventual,
77 replication_factor: 2,
78 local_cache_size: 10000,
79 prefetch_enabled: true,
80 }
81 }
82}
83
84#[derive(Debug, Clone)]
86pub enum ShardingStrategy {
87 ConsistentHashing,
88 Range,
89 ModuloHash,
90 QueryType,
91 ServiceAffinity,
92}
93
94#[derive(Debug, Clone)]
96pub enum EvictionPolicy {
97 LRU,
98 LFU,
99 FIFO,
100 TTL,
101 Adaptive,
102}
103
104#[derive(Debug, Clone)]
106pub enum ConsistencyLevel {
107 Strong,
108 Eventual,
109 Session,
110 Bounded,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct CacheEntry {
116 pub key: String,
117 pub value: Vec<u8>,
118 pub created_at: SystemTime,
119 pub expires_at: SystemTime,
120 pub access_count: u64,
121 pub last_accessed: SystemTime,
122 pub size_bytes: usize,
123 pub tags: Vec<String>,
124 pub metadata: HashMap<String, String>,
125}
126
127#[derive(Debug, Clone, Default)]
129pub struct CacheStats {
130 pub hits: u64,
131 pub misses: u64,
132 pub sets: u64,
133 pub deletes: u64,
134 pub evictions: u64,
135 pub total_size_bytes: u64,
136 pub entry_count: u64,
137 pub average_response_time: Duration,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct InvalidationEvent {
143 pub keys: Vec<String>,
144 pub tags: Vec<String>,
145 pub timestamp: SystemTime,
146 pub source: String,
147 pub reason: InvalidationReason,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
152pub enum InvalidationReason {
153 SchemaChange,
154 DataUpdate,
155 Manual,
156 TTLExpired,
157 MemoryPressure,
158 ErrorRecovery,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct QueryContext {
164 pub query_hash: String,
165 pub variables_hash: String,
166 pub operation_name: Option<String>,
167 pub user_id: Option<String>,
168 pub service_ids: Vec<String>,
169 pub schema_version: String,
170 pub requested_fields: Vec<String>,
171}
172
173impl QueryContext {
174 pub fn cache_key(&self) -> String {
176 format!(
177 "gql:{}:{}:{}:{}",
178 self.query_hash,
179 self.variables_hash,
180 self.schema_version,
181 self.service_ids.join(",")
182 )
183 }
184
185 pub fn tags(&self) -> Vec<String> {
187 let mut tags = vec![
188 format!("query:{}", self.query_hash),
189 format!("schema:{}", self.schema_version),
190 ];
191
192 for service_id in &self.service_ids {
193 tags.push(format!("service:{service_id}"));
194 }
195
196 for field in &self.requested_fields {
197 tags.push(format!("field:{field}"));
198 }
199
200 if let Some(user_id) = &self.user_id {
201 tags.push(format!("user:{user_id}"));
202 }
203
204 tags
205 }
206}
207
208#[async_trait]
210pub trait DistributedCache: Send + Sync {
211 async fn get(&self, key: &str) -> Result<Option<Vec<u8>>>;
212 async fn set(&self, key: &str, value: Vec<u8>, ttl: Option<Duration>) -> Result<()>;
213 async fn delete(&self, key: &str) -> Result<()>;
214 async fn exists(&self, key: &str) -> Result<bool>;
215 async fn invalidate_by_tags(&self, tags: &[String]) -> Result<u64>;
216 async fn get_stats(&self) -> Result<CacheStats>;
217 async fn health_check(&self) -> Result<bool>;
218 async fn clear(&self) -> Result<()>;
219}
220
221pub struct RedisDistributedCache {
223 config: CacheConfig,
224 redis_pool: Arc<RwLock<Vec<Client>>>,
225 local_cache: Arc<RwLock<lru::LruCache<String, CacheEntry>>>,
226 stats: Arc<RwLock<CacheStats>>,
227 compression: Option<Arc<dyn CompressionStrategy>>,
228 encryption: Option<Arc<dyn EncryptionStrategy>>,
229}
230
231impl RedisDistributedCache {
232 pub async fn new(config: CacheConfig) -> Result<Self> {
234 let mut redis_clients = Vec::new();
235
236 for redis_url in &config.redis_urls {
237 let client = Client::open(redis_url.as_str())
238 .map_err(|e| anyhow!("Failed to create Redis client: {}", e))?;
239 redis_clients.push(client);
240 }
241
242 let local_cache = lru::LruCache::new(
243 std::num::NonZeroUsize::new(config.local_cache_size).unwrap_or(
244 std::num::NonZeroUsize::new(1000).expect("1000 is a valid NonZeroUsize"),
245 ),
246 );
247
248 let compression = if config.compression_enabled {
249 Some(Arc::new(GzipCompressionStrategy::new()) as Arc<dyn CompressionStrategy>)
250 } else {
251 None
252 };
253
254 let encryption = if config.encryption_enabled {
255 let key = config.encryption_key.as_ref().ok_or_else(|| {
258 anyhow!(
259 "CacheConfig.encryption_enabled = true but no encryption_key was provided; \
260 refusing to start a cache that would store data in plaintext"
261 )
262 })?;
263 Some(Arc::new(AesEncryptionStrategy::new(key.as_bytes())?)
264 as Arc<dyn EncryptionStrategy>)
265 } else {
266 None
267 };
268
269 Ok(Self {
270 config,
271 redis_pool: Arc::new(RwLock::new(redis_clients)),
272 local_cache: Arc::new(RwLock::new(local_cache)),
273 stats: Arc::new(RwLock::new(CacheStats::default())),
274 compression,
275 encryption,
276 })
277 }
278
279 async fn get_redis_client(&self, key: &str) -> Result<Client> {
281 let clients = self.redis_pool.read().await;
282
283 if clients.is_empty() {
284 return Err(anyhow!("No Redis clients available"));
285 }
286
287 let index = match self.config.sharding_strategy {
288 ShardingStrategy::ConsistentHashing => self.consistent_hash(key, clients.len()),
289 ShardingStrategy::ModuloHash => self.modulo_hash(key, clients.len()),
290 _ => 0, };
292
293 Ok(clients[index].clone())
294 }
295
296 fn consistent_hash(&self, key: &str, num_nodes: usize) -> usize {
298 use std::collections::hash_map::DefaultHasher;
299 use std::hash::{Hash, Hasher};
300
301 let mut hasher = DefaultHasher::new();
302 key.hash(&mut hasher);
303 (hasher.finish() as usize) % num_nodes
304 }
305
306 fn modulo_hash(&self, key: &str, num_nodes: usize) -> usize {
308 use std::collections::hash_map::DefaultHasher;
309 use std::hash::{Hash, Hasher};
310
311 let mut hasher = DefaultHasher::new();
312 key.hash(&mut hasher);
313 (hasher.finish() as usize) % num_nodes
314 }
315
316 async fn process_data(&self, data: &[u8], encode: bool) -> Result<Vec<u8>> {
318 let mut processed_data = data.to_vec();
319
320 if encode {
321 if let Some(compression) = &self.compression {
323 processed_data = compression.compress(&processed_data).await?;
324 }
325
326 if let Some(encryption) = &self.encryption {
328 processed_data = encryption.encrypt(&processed_data).await?;
329 }
330 } else {
331 if let Some(encryption) = &self.encryption {
333 processed_data = encryption.decrypt(&processed_data).await?;
334 }
335
336 if let Some(compression) = &self.compression {
338 processed_data = compression.decompress(&processed_data).await?;
339 }
340 }
341
342 Ok(processed_data)
343 }
344
345 async fn update_stats<F>(&self, update_fn: F)
347 where
348 F: FnOnce(&mut CacheStats),
349 {
350 let mut stats = self.stats.write().await;
351 update_fn(&mut stats);
352 }
353}
354
355#[async_trait]
356impl DistributedCache for RedisDistributedCache {
357 async fn get(&self, key: &str) -> Result<Option<Vec<u8>>> {
358 let start_time = std::time::Instant::now();
359
360 {
362 let mut local_cache = self.local_cache.write().await;
363 if let Some(entry) = local_cache.get(key) {
364 if entry.expires_at > SystemTime::now() {
365 self.update_stats(|stats| {
366 stats.hits += 1;
367 stats.average_response_time =
368 (stats.average_response_time + start_time.elapsed()) / 2;
369 })
370 .await;
371
372 return Ok(Some(entry.value.clone()));
373 } else {
374 local_cache.pop(key);
376 }
377 }
378 }
379
380 let client = self.get_redis_client(key).await?;
382 let mut connection = client
383 .get_multiplexed_async_connection()
384 .await
385 .map_err(|e| anyhow!("Failed to get Redis connection: {}", e))?;
386
387 let redis_result: Option<Vec<u8>> = cmd("GET")
388 .arg(key)
389 .query_async(&mut connection)
390 .await
391 .map_err(|e| anyhow!("Redis GET failed: {}", e))?;
392
393 if let Some(raw_data) = redis_result {
394 let processed_data = self.process_data(&raw_data, false).await?;
396
397 let entry = CacheEntry {
399 key: key.to_string(),
400 value: processed_data.clone(),
401 created_at: SystemTime::now(),
402 expires_at: SystemTime::now() + self.config.default_ttl,
403 access_count: 1,
404 last_accessed: SystemTime::now(),
405 size_bytes: processed_data.len(),
406 tags: Vec::new(),
407 metadata: HashMap::new(),
408 };
409
410 {
411 let mut local_cache = self.local_cache.write().await;
412 local_cache.put(key.to_string(), entry);
413 }
414
415 self.update_stats(|stats| {
416 stats.hits += 1;
417 stats.average_response_time =
418 (stats.average_response_time + start_time.elapsed()) / 2;
419 })
420 .await;
421
422 Ok(Some(processed_data))
423 } else {
424 self.update_stats(|stats| {
425 stats.misses += 1;
426 stats.average_response_time =
427 (stats.average_response_time + start_time.elapsed()) / 2;
428 })
429 .await;
430
431 Ok(None)
432 }
433 }
434
435 async fn set(&self, key: &str, value: Vec<u8>, ttl: Option<Duration>) -> Result<()> {
436 let ttl = ttl.unwrap_or(self.config.default_ttl);
437
438 let processed_data = self.process_data(&value, true).await?;
440
441 let client = self.get_redis_client(key).await?;
443 let mut connection = client
444 .get_multiplexed_async_connection()
445 .await
446 .map_err(|e| anyhow!("Failed to get Redis connection: {}", e))?;
447
448 cmd("SETEX")
449 .arg(key)
450 .arg(ttl.as_secs())
451 .arg(&processed_data)
452 .exec_async(&mut connection)
453 .await
454 .map_err(|e| anyhow!("Redis SETEX failed: {}", e))?;
455
456 let entry = CacheEntry {
458 key: key.to_string(),
459 value,
460 created_at: SystemTime::now(),
461 expires_at: SystemTime::now() + ttl,
462 access_count: 0,
463 last_accessed: SystemTime::now(),
464 size_bytes: processed_data.len(),
465 tags: Vec::new(),
466 metadata: HashMap::new(),
467 };
468
469 {
470 let mut local_cache = self.local_cache.write().await;
471 local_cache.put(key.to_string(), entry);
472 }
473
474 self.update_stats(|stats| {
475 stats.sets += 1;
476 stats.total_size_bytes += processed_data.len() as u64;
477 stats.entry_count += 1;
478 })
479 .await;
480
481 Ok(())
482 }
483
484 async fn delete(&self, key: &str) -> Result<()> {
485 {
487 let mut local_cache = self.local_cache.write().await;
488 local_cache.pop(key);
489 }
490
491 let client = self.get_redis_client(key).await?;
493 let mut connection = client
494 .get_multiplexed_async_connection()
495 .await
496 .map_err(|e| anyhow!("Failed to get Redis connection: {}", e))?;
497
498 cmd("DEL")
499 .arg(key)
500 .query_async::<()>(&mut connection)
501 .await
502 .map_err(|e| anyhow!("Redis DEL failed: {}", e))?;
503
504 self.update_stats(|stats| {
505 stats.deletes += 1;
506 })
507 .await;
508
509 Ok(())
510 }
511
512 async fn exists(&self, key: &str) -> Result<bool> {
513 {
515 let mut local_cache = self.local_cache.write().await;
516 if let Some(entry) = local_cache.get(key) {
517 if entry.expires_at > SystemTime::now() {
518 return Ok(true);
519 } else {
520 local_cache.pop(key);
521 }
522 }
523 }
524
525 let client = self.get_redis_client(key).await?;
527 let mut connection = client
528 .get_multiplexed_async_connection()
529 .await
530 .map_err(|e| anyhow!("Failed to get Redis connection: {}", e))?;
531
532 let exists: bool = cmd("EXISTS")
533 .arg(key)
534 .query_async(&mut connection)
535 .await
536 .map_err(|e| anyhow!("Redis EXISTS failed: {}", e))?;
537
538 Ok(exists)
539 }
540
541 async fn invalidate_by_tags(&self, tags: &[String]) -> Result<u64> {
542 let mut invalidated = 0;
545
546 for tag in tags {
547 let pattern = format!("*{tag}*");
549
550 let clients = self.redis_pool.read().await;
551 for client in clients.iter() {
552 let mut connection = client.get_multiplexed_async_connection().await?;
553
554 let keys: Vec<String> = cmd("KEYS")
555 .arg(&pattern)
556 .query_async(&mut connection)
557 .await?;
558
559 for key in keys {
560 self.delete(&key).await?;
561 invalidated += 1;
562 }
563 }
564 }
565
566 Ok(invalidated)
567 }
568
569 async fn get_stats(&self) -> Result<CacheStats> {
570 Ok(self.stats.read().await.clone())
571 }
572
573 async fn health_check(&self) -> Result<bool> {
574 let clients = self.redis_pool.read().await;
575
576 for client in clients.iter() {
577 match client.get_multiplexed_async_connection().await {
578 Ok(mut connection) => {
579 let result: Result<String, _> = cmd("PING").query_async(&mut connection).await;
580 if result.is_err() {
581 return Ok(false);
582 }
583 }
584 Err(_) => return Ok(false),
585 }
586 }
587
588 Ok(true)
589 }
590
591 async fn clear(&self) -> Result<()> {
592 {
594 let mut local_cache = self.local_cache.write().await;
595 local_cache.clear();
596 }
597
598 let clients = self.redis_pool.read().await;
600 for client in clients.iter() {
601 let mut connection = client.get_multiplexed_async_connection().await?;
602 cmd("FLUSHDB").query_async::<()>(&mut connection).await?;
603 }
604
605 {
607 let mut stats = self.stats.write().await;
608 *stats = CacheStats::default();
609 }
610
611 Ok(())
612 }
613}
614
615#[async_trait]
617pub trait CompressionStrategy: Send + Sync {
618 async fn compress(&self, data: &[u8]) -> Result<Vec<u8>>;
619 async fn decompress(&self, data: &[u8]) -> Result<Vec<u8>>;
620}
621
622pub struct GzipCompressionStrategy;
624
625impl Default for GzipCompressionStrategy {
626 fn default() -> Self {
627 Self::new()
628 }
629}
630
631impl GzipCompressionStrategy {
632 pub fn new() -> Self {
633 Self
634 }
635}
636
637#[async_trait]
638impl CompressionStrategy for GzipCompressionStrategy {
639 async fn compress(&self, data: &[u8]) -> Result<Vec<u8>> {
640 Ok(oxiarc_deflate::gzip_compress(data, 6)?)
643 }
644
645 async fn decompress(&self, data: &[u8]) -> Result<Vec<u8>> {
646 Ok(oxiarc_deflate::gzip_decompress(data)?)
648 }
649}
650
651#[async_trait]
653pub trait EncryptionStrategy: Send + Sync {
654 async fn encrypt(&self, data: &[u8]) -> Result<Vec<u8>>;
655 async fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>>;
656}
657
658const AES256_KEY_LEN: usize = 32;
660const AES_GCM_NONCE_LEN: usize = 12;
662
663pub struct AesEncryptionStrategy {
670 key: [u8; AES256_KEY_LEN],
671}
672
673impl AesEncryptionStrategy {
674 pub fn new(key: &[u8]) -> Result<Self> {
677 if key.len() != AES256_KEY_LEN {
678 return Err(anyhow!(
679 "AES-256-GCM requires a {}-byte key, got {} bytes",
680 AES256_KEY_LEN,
681 key.len()
682 ));
683 }
684 let mut key_arr = [0u8; AES256_KEY_LEN];
685 key_arr.copy_from_slice(key);
686 Ok(Self { key: key_arr })
687 }
688}
689
690#[async_trait]
691impl EncryptionStrategy for AesEncryptionStrategy {
692 async fn encrypt(&self, data: &[u8]) -> Result<Vec<u8>> {
693 use oxicrypto_core::Aead;
694
695 let nonce: [u8; AES_GCM_NONCE_LEN] = oxicrypto_rand::random_nonce()
697 .map_err(|e| anyhow!("Failed to generate AES-GCM nonce: {e}"))?;
698
699 let sealed = oxicrypto_aead::Aes256Gcm
701 .seal_to_vec(&self.key, &nonce, &[], data)
702 .map_err(|e| anyhow!("AES-256-GCM encryption failed: {e}"))?;
703
704 let mut out = Vec::with_capacity(AES_GCM_NONCE_LEN + sealed.len());
706 out.extend_from_slice(&nonce);
707 out.extend_from_slice(&sealed);
708 Ok(out)
709 }
710
711 async fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>> {
712 use oxicrypto_core::Aead;
713
714 if data.len() < AES_GCM_NONCE_LEN {
715 return Err(anyhow!(
716 "Ciphertext too short: {} bytes, expected at least {} (nonce)",
717 data.len(),
718 AES_GCM_NONCE_LEN
719 ));
720 }
721 let (nonce, sealed) = data.split_at(AES_GCM_NONCE_LEN);
722
723 oxicrypto_aead::Aes256Gcm
724 .open_to_vec(&self.key, nonce, &[], sealed)
725 .map_err(|e| anyhow!("AES-256-GCM decryption/authentication failed: {e}"))
726 }
727}
728
729#[allow(dead_code)]
731pub struct GraphQLQueryCache {
732 cache: Arc<dyn DistributedCache>,
733 config: CacheConfig,
734}
735
736impl GraphQLQueryCache {
737 pub async fn new(config: CacheConfig) -> Result<Self> {
739 let cache = Arc::new(RedisDistributedCache::new(config.clone()).await?);
740
741 Ok(Self { cache, config })
742 }
743
744 pub async fn cache_query_result(
746 &self,
747 context: &QueryContext,
748 result: &serde_json::Value,
749 ttl: Option<Duration>,
750 ) -> Result<()> {
751 let key = context.cache_key();
752 let value = serde_json::to_vec(result)?;
753
754 self.cache.set(&key, value, ttl).await?;
755
756 info!("Cached GraphQL query result: {}", key);
757 Ok(())
758 }
759
760 pub async fn get_cached_result(
762 &self,
763 context: &QueryContext,
764 ) -> Result<Option<serde_json::Value>> {
765 let key = context.cache_key();
766
767 if let Some(cached_data) = self.cache.get(&key).await? {
768 let result: serde_json::Value = serde_json::from_slice(&cached_data)?;
769 debug!("Cache hit for GraphQL query: {}", key);
770 return Ok(Some(result));
771 }
772
773 debug!("Cache miss for GraphQL query: {}", key);
774 Ok(None)
775 }
776
777 pub async fn invalidate_on_schema_change(&self, schema_version: &str) -> Result<u64> {
779 let tags = vec![format!("schema:{}", schema_version)];
780 self.cache.invalidate_by_tags(&tags).await
781 }
782
783 pub async fn invalidate_for_services(&self, service_ids: &[String]) -> Result<u64> {
785 let tags: Vec<String> = service_ids
786 .iter()
787 .map(|id| format!("service:{id}"))
788 .collect();
789 self.cache.invalidate_by_tags(&tags).await
790 }
791
792 pub async fn get_stats(&self) -> Result<CacheStats> {
794 self.cache.get_stats().await
795 }
796
797 pub async fn health_check(&self) -> Result<bool> {
799 self.cache.health_check().await
800 }
801
802 pub async fn raw_get(&self, key: &str) -> Result<Option<Vec<u8>>> {
804 self.cache.get(key).await
805 }
806
807 pub async fn raw_set(&self, key: &str, value: Vec<u8>, ttl: Option<Duration>) -> Result<()> {
809 self.cache.set(key, value, ttl).await
810 }
811}
812
813#[cfg(test)]
814mod tests {
815 use super::*;
816
817 #[tokio::test]
818 async fn test_query_context_cache_key() {
819 let context = QueryContext {
820 query_hash: "abc123".to_string(),
821 variables_hash: "def456".to_string(),
822 operation_name: Some("GetUser".to_string()),
823 user_id: Some("user123".to_string()),
824 service_ids: vec!["service1".to_string(), "service2".to_string()],
825 schema_version: "v1.0".to_string(),
826 requested_fields: vec!["name".to_string(), "email".to_string()],
827 };
828
829 let cache_key = context.cache_key();
830 assert!(cache_key.contains("abc123"));
831 assert!(cache_key.contains("def456"));
832 assert!(cache_key.contains("v1.0"));
833 }
834
835 #[tokio::test]
836 async fn test_gzip_compression() {
837 let compression = GzipCompressionStrategy::new();
838 let original_data = b"This is a test string for compression. ".repeat(100);
840
841 let compressed = compression
842 .compress(&original_data)
843 .await
844 .expect("should succeed");
845 let decompressed = compression
846 .decompress(&compressed)
847 .await
848 .expect("should succeed");
849
850 assert_eq!(original_data.as_slice(), decompressed.as_slice());
851 assert!(compressed.len() < original_data.len()); }
853
854 #[tokio::test]
855 async fn regression_aes_encryption_roundtrip_is_not_plaintext() {
856 let key = [7u8; AES256_KEY_LEN];
857 let strategy = AesEncryptionStrategy::new(&key).expect("32-byte key is valid");
858 let plaintext = b"sensitive GraphQL query result with PII".to_vec();
859
860 let ciphertext = strategy.encrypt(&plaintext).await.expect("encrypt");
861 assert_ne!(ciphertext, plaintext);
863 assert!(
864 ciphertext
865 .windows(plaintext.len())
866 .all(|w| w != plaintext.as_slice()),
867 "ciphertext must not embed the plaintext"
868 );
869 assert!(ciphertext.len() >= plaintext.len() + AES_GCM_NONCE_LEN + 16);
871
872 let recovered = strategy.decrypt(&ciphertext).await.expect("decrypt");
873 assert_eq!(recovered, plaintext);
874 }
875
876 #[tokio::test]
877 async fn regression_aes_encryption_fresh_nonce_per_message() {
878 let key = [3u8; AES256_KEY_LEN];
879 let strategy = AesEncryptionStrategy::new(&key).expect("valid key");
880 let plaintext = b"same input".to_vec();
881 let c1 = strategy.encrypt(&plaintext).await.expect("encrypt 1");
882 let c2 = strategy.encrypt(&plaintext).await.expect("encrypt 2");
883 assert_ne!(c1, c2);
885 }
886
887 #[tokio::test]
888 async fn regression_aes_tamper_detection_fails_loud() {
889 let key = [9u8; AES256_KEY_LEN];
890 let strategy = AesEncryptionStrategy::new(&key).expect("valid key");
891 let mut ciphertext = strategy.encrypt(b"payload").await.expect("encrypt");
892 let last = ciphertext.len() - 1;
894 ciphertext[last] ^= 0x01;
895 assert!(
896 strategy.decrypt(&ciphertext).await.is_err(),
897 "tampered ciphertext must be rejected, not silently returned"
898 );
899 }
900
901 #[test]
902 fn regression_bad_key_length_rejected() {
903 assert!(AesEncryptionStrategy::new(&[0u8; 16]).is_err());
904 assert!(AesEncryptionStrategy::new(&[0u8; 32]).is_ok());
905 }
906
907 #[tokio::test]
908 async fn regression_encryption_enabled_without_key_fails_loud() {
909 let config = CacheConfig {
910 encryption_enabled: true,
911 encryption_key: None,
912 ..Default::default()
913 };
914 let result = RedisDistributedCache::new(config).await;
916 assert!(
917 result.is_err(),
918 "encryption_enabled without a key must be a hard error"
919 );
920 }
921
922 #[test]
923 fn regression_encryption_key_debug_is_redacted() {
924 let key = EncryptionKey::new(vec![0xAB; 32]);
925 let dbg = format!("{key:?}");
926 assert!(dbg.contains("REDACTED"));
927 assert!(!dbg.contains("171")); }
929}