Skip to main content

browser_automation_cli/
cache.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! HTTP / parse cache under XDG (PRD 5AF / GAP-011 / GAP-023).
3//!
4//! Backends: in-process L1 ([`FxHashMap`] of hex digests), SQLite under XDG
5//! cache. Redis is optional via `config set cache_backend=redis` +
6//! `cache_redis_url` (never env).
7//!
8//! # Workload / hashing
9//!
10//! L1 keys are SHA-256 hex digests produced by this process (trusted).
11//! [`rustc_hash::FxHashMap`] avoids SipHash overhead on short fixed-width
12//! keys (rules_rust_eficiencia_e_performance). Do **not** use FxHash for
13//! untrusted external key spaces.
14//!
15//! **Concurrency:** single-key get/put under short `Mutex` critical sections
16//! that never cross `.await` (rules). Not a multi-item fan-out surface.
17
18use std::path::{Path, PathBuf};
19use std::sync::Mutex;
20use std::time::{Duration, SystemTime, UNIX_EPOCH};
21
22use rustc_hash::FxHashMap;
23use sha2::{Digest, Sha256};
24
25use crate::error::{CliError, ErrorKind};
26use crate::xdg;
27
28/// Cache key derived from method + URL + optional body hash.
29#[derive(Debug, Clone, PartialEq, Eq, Hash)]
30pub struct CacheKey(String);
31
32impl CacheKey {
33    /// Build a stable key for an HTTP GET URL.
34    pub fn http_get(url: &str) -> Self {
35        let mut h = Sha256::new();
36        h.update(b"GET\0");
37        h.update(url.as_bytes());
38        Self(hex::encode(h.finalize()))
39    }
40
41    /// Build a stable key for local file parse (path + mtime + len).
42    pub fn file_parse(path: &Path, len: u64, mtime_secs: u64) -> Self {
43        let mut h = Sha256::new();
44        h.update(b"PARSE\0");
45        h.update(path.to_string_lossy().as_bytes());
46        h.update(len.to_le_bytes());
47        h.update(mtime_secs.to_le_bytes());
48        Self(hex::encode(h.finalize()))
49    }
50
51    /// Hex digest string.
52    pub fn as_str(&self) -> &str {
53        &self.0
54    }
55}
56
57/// Cached payload.
58#[derive(Debug, Clone)]
59pub struct CacheEntry {
60    /// Raw body bytes or UTF-8 text.
61    pub body: Vec<u8>,
62    /// Optional content-type hint.
63    pub content_type: Option<String>,
64    /// Expiry as unix seconds (0 = no expiry).
65    pub expires_unix: u64,
66}
67
68impl CacheEntry {
69    /// True when entry is still valid.
70    pub fn is_fresh(&self) -> bool {
71        if self.expires_unix == 0 {
72            return true;
73        }
74        now_unix() < self.expires_unix
75    }
76}
77
78/// Trait for one-shot HTTP/parse caches.
79pub trait HttpCache: Send {
80    /// Lookup a key.
81    fn get(&self, key: &CacheKey) -> Result<Option<CacheEntry>, CliError>;
82    /// Store a key.
83    fn put(&self, key: &CacheKey, entry: CacheEntry) -> Result<(), CliError>;
84}
85
86/// In-process L1 cache (dies with the process — one-shot safe).
87///
88/// # Interior mutability
89///
90/// `Mutex` is required so [`HttpCache`] can take `&self` while remaining
91/// `Send` for optional multi-thread use. Critical sections only clone/insert
92/// map entries (no `.await`). Poison is **propagated** as [`CliError`] (not
93/// recovered) because a poisoned L1 is a software fault, not residual cleanup.
94#[derive(Debug, Default)]
95pub struct MemoryCache {
96    inner: Mutex<FxHashMap<String, CacheEntry>>,
97}
98
99impl HttpCache for MemoryCache {
100    fn get(&self, key: &CacheKey) -> Result<Option<CacheEntry>, CliError> {
101        let guard = self
102            .inner
103            .lock()
104            .map_err(|_| CliError::new(ErrorKind::Software, "cache lock poisoned"))?;
105        Ok(guard.get(key.as_str()).filter(|e| e.is_fresh()).cloned())
106    }
107
108    fn put(&self, key: &CacheKey, entry: CacheEntry) -> Result<(), CliError> {
109        let mut guard = self
110            .inner
111            .lock()
112            .map_err(|_| CliError::new(ErrorKind::Software, "cache lock poisoned"))?;
113        guard.insert(key.as_str().to_string(), entry);
114        Ok(())
115    }
116}
117
118/// SQLite-backed cache under XDG cache directory.
119pub struct SqliteCache {
120    path: PathBuf,
121}
122
123impl SqliteCache {
124    /// Open or create the product HTTP cache DB.
125    pub fn open_default() -> Result<Self, CliError> {
126        let dir = xdg::cache_dir()?.join("http_cache");
127        std::fs::create_dir_all(&dir)
128            .map_err(|e| CliError::new(ErrorKind::Io, format!("http_cache mkdir: {e}")))?;
129        let path = dir.join("cache.sqlite");
130        let db = Self { path };
131        db.init_schema()?;
132        Ok(db)
133    }
134
135    fn init_schema(&self) -> Result<(), CliError> {
136        let conn = rusqlite::Connection::open(&self.path)
137            .map_err(|e| CliError::new(ErrorKind::Io, format!("http_cache open: {e}")))?;
138        conn.execute_batch(
139            "CREATE TABLE IF NOT EXISTS entries (
140                key TEXT PRIMARY KEY,
141                body BLOB NOT NULL,
142                content_type TEXT,
143                expires_unix INTEGER NOT NULL
144            );",
145        )
146        .map_err(|e| CliError::new(ErrorKind::Io, format!("http_cache schema: {e}")))?;
147        Ok(())
148    }
149}
150
151impl HttpCache for SqliteCache {
152    fn get(&self, key: &CacheKey) -> Result<Option<CacheEntry>, CliError> {
153        let conn = rusqlite::Connection::open(&self.path)
154            .map_err(|e| CliError::new(ErrorKind::Io, format!("http_cache open: {e}")))?;
155        let mut stmt = conn
156            .prepare("SELECT body, content_type, expires_unix FROM entries WHERE key = ?1")
157            .map_err(|e| CliError::new(ErrorKind::Io, format!("http_cache prepare: {e}")))?;
158        let mut rows = stmt
159            .query(rusqlite::params![key.as_str()])
160            .map_err(|e| CliError::new(ErrorKind::Io, format!("http_cache query: {e}")))?;
161        if let Some(row) = rows
162            .next()
163            .map_err(|e| CliError::new(ErrorKind::Io, format!("http_cache row: {e}")))?
164        {
165            let body: Vec<u8> = row
166                .get(0)
167                .map_err(|e| CliError::new(ErrorKind::Data, format!("http_cache body: {e}")))?;
168            let content_type: Option<String> = row.get(1).ok();
169            let expires_unix: i64 = row.get(2).unwrap_or(0);
170            let entry = CacheEntry {
171                body,
172                content_type,
173                expires_unix: expires_unix.max(0) as u64,
174            };
175            if entry.is_fresh() {
176                return Ok(Some(entry));
177            }
178        }
179        Ok(None)
180    }
181
182    fn put(&self, key: &CacheKey, entry: CacheEntry) -> Result<(), CliError> {
183        let conn = rusqlite::Connection::open(&self.path)
184            .map_err(|e| CliError::new(ErrorKind::Io, format!("http_cache open: {e}")))?;
185        conn.execute(
186            "INSERT OR REPLACE INTO entries (key, body, content_type, expires_unix) VALUES (?1, ?2, ?3, ?4)",
187            rusqlite::params![
188                key.as_str(),
189                entry.body,
190                entry.content_type,
191                entry.expires_unix as i64
192            ],
193        )
194        .map_err(|e| CliError::new(ErrorKind::Io, format!("http_cache put: {e}")))?;
195        Ok(())
196    }
197}
198
199/// Layered L1 memory + L2 sqlite.
200pub struct LayeredCache {
201    /// In-process layer.
202    pub l1: MemoryCache,
203    /// Disk layer.
204    pub l2: SqliteCache,
205}
206
207impl HttpCache for LayeredCache {
208    fn get(&self, key: &CacheKey) -> Result<Option<CacheEntry>, CliError> {
209        if let Some(e) = self.l1.get(key)? {
210            return Ok(Some(e));
211        }
212        if let Some(e) = self.l2.get(key)? {
213            let _ = self.l1.put(key, e.clone());
214            return Ok(Some(e));
215        }
216        Ok(None)
217    }
218
219    fn put(&self, key: &CacheKey, entry: CacheEntry) -> Result<(), CliError> {
220        self.l1.put(key, entry.clone())?;
221        self.l2.put(key, entry)?;
222        Ok(())
223    }
224}
225
226/// Redis-backed cache (RESP over TCP). Enabled when
227/// `config set cache_backend redis` and `cache_redis_url` is set (XDG only).
228#[derive(Debug)]
229pub struct RedisCache {
230    url: String,
231}
232
233impl RedisCache {
234    /// Connect and PING. URL form: `redis://127.0.0.1:6379` or `redis://host:port/db`.
235    pub fn connect(url: &str) -> Result<Self, CliError> {
236        let url = url.trim();
237        if url.is_empty() {
238            return Err(CliError::with_suggestion(
239                ErrorKind::Usage,
240                "cache_backend=redis requires cache_redis_url",
241                "browser-automation-cli config set cache_redis_url redis://127.0.0.1:6379",
242            ));
243        }
244        let c = Self {
245            url: url.to_string(),
246        };
247        c.cmd_simple(&["PING"]).map_err(|e| {
248            CliError::with_suggestion(
249                ErrorKind::Unavailable,
250                format!("redis PING failed: {e}"),
251                "Start redis-server or switch: config set cache_backend sqlite",
252            )
253        })?;
254        Ok(c)
255    }
256
257    fn parse_host_port_db(url: &str) -> Result<(String, u16, i64), String> {
258        // GAP-A007: rediss:// implies TLS; this client is plain TCP only — fail closed.
259        if url.trim().to_ascii_lowercase().starts_with("rediss://") {
260            return Err(
261                "rediss:// (TLS) is not supported by the built-in Redis client; use redis://127.0.0.1:6379 (plain local) or config set cache_backend sqlite"
262                    .into(),
263            );
264        }
265        // Minimal parser: redis://host:port[/db]
266        let rest = url.strip_prefix("redis://").unwrap_or(url);
267        let rest = rest.split('@').next_back().unwrap_or(rest);
268        let (hostport, db) = match rest.split_once('/') {
269            Some((hp, d)) => (hp, d.parse::<i64>().unwrap_or(0)),
270            None => (rest, 0),
271        };
272        let (host, port) = if let Some((h, p)) = hostport.rsplit_once(':') {
273            (h.to_string(), p.parse::<u16>().unwrap_or(6379))
274        } else {
275            (hostport.to_string(), 6379)
276        };
277        if host.is_empty() {
278            return Err("empty redis host".into());
279        }
280        Ok((host, port, db))
281    }
282
283    fn with_stream<T>(
284        &self,
285        f: impl FnOnce(&mut std::net::TcpStream) -> Result<T, String>,
286    ) -> Result<T, String> {
287        use std::io::Write as _;
288        use std::net::TcpStream;
289        use std::time::Duration;
290
291        let (host, port, db) = Self::parse_host_port_db(&self.url)?;
292        let mut stream = TcpStream::connect((host.as_str(), port))
293            .map_err(|e| format!("connect {host}:{port}: {e}"))?;
294        stream.set_read_timeout(Some(Duration::from_secs(3))).ok();
295        stream.set_write_timeout(Some(Duration::from_secs(3))).ok();
296        if db != 0 {
297            let select = format!(
298                "*2\r\n$6\r\nSELECT\r\n${}\r\n{db}\r\n",
299                db.to_string().len()
300            );
301            stream
302                .write_all(select.as_bytes())
303                .map_err(|e| format!("SELECT write: {e}"))?;
304            let _ = read_resp_line(&mut stream)?;
305        }
306        f(&mut stream)
307    }
308
309    fn cmd_simple(&self, parts: &[&str]) -> Result<String, String> {
310        self.with_stream(|stream| {
311            write_resp_array(stream, parts)?;
312            read_resp_value(stream)
313        })
314    }
315
316    fn redis_key(key: &CacheKey) -> String {
317        format!("browser-automation-cli:cache:v1:{}", key.as_str())
318    }
319}
320
321impl HttpCache for RedisCache {
322    fn get(&self, key: &CacheKey) -> Result<Option<CacheEntry>, CliError> {
323        let rk = Self::redis_key(key);
324        let raw = self
325            .cmd_simple(&["GET", &rk])
326            .map_err(|e| CliError::new(ErrorKind::Unavailable, format!("redis GET: {e}")))?;
327        if raw == "$-1" || raw.is_empty() || raw == "(nil)" {
328            return Ok(None);
329        }
330        // Payload is JSON: {body_b64, content_type, expires_unix}
331        let v: serde_json::Value = crate::json_util::from_str(&raw)
332            .map_err(|e| CliError::new(ErrorKind::Data, format!("redis cache decode: {e}")))?;
333        let body_b64 = v
334            .get("body_b64")
335            .and_then(|x| x.as_str())
336            .ok_or_else(|| CliError::new(ErrorKind::Data, "redis cache missing body_b64"))?;
337        use base64::Engine;
338        let body = base64::engine::general_purpose::STANDARD
339            .decode(body_b64)
340            .map_err(|e| CliError::new(ErrorKind::Data, format!("redis body b64: {e}")))?;
341        let entry = CacheEntry {
342            body,
343            content_type: v
344                .get("content_type")
345                .and_then(|x| x.as_str())
346                .map(|s| s.to_string()),
347            expires_unix: v.get("expires_unix").and_then(|x| x.as_u64()).unwrap_or(0),
348        };
349        if entry.is_fresh() {
350            Ok(Some(entry))
351        } else {
352            let _ = self.cmd_simple(&["DEL", &rk]);
353            Ok(None)
354        }
355    }
356
357    fn put(&self, key: &CacheKey, entry: CacheEntry) -> Result<(), CliError> {
358        use base64::Engine;
359        let rk = Self::redis_key(key);
360        let body_b64 = base64::engine::general_purpose::STANDARD.encode(&entry.body);
361        let payload = serde_json::json!({
362            "body_b64": body_b64,
363            "content_type": entry.content_type,
364            "expires_unix": entry.expires_unix,
365        })
366        .to_string();
367        let ttl = if entry.expires_unix > 0 {
368            let now = now_unix();
369            entry.expires_unix.saturating_sub(now).max(1)
370        } else {
371            86_400
372        };
373        let ttl_s = ttl.to_string();
374        self.with_stream(|stream| {
375            write_resp_array(stream, &["SET", &rk, &payload, "EX", &ttl_s])?;
376            let _ = read_resp_value(stream)?;
377            Ok(())
378        })
379        .map_err(|e| CliError::new(ErrorKind::Unavailable, format!("redis SET: {e}")))
380    }
381}
382
383fn write_resp_array(stream: &mut impl std::io::Write, parts: &[&str]) -> Result<(), String> {
384    let mut buf = format!("*{}\r\n", parts.len());
385    for p in parts {
386        buf.push_str(&format!("${}\r\n{}\r\n", p.len(), p));
387    }
388    stream
389        .write_all(buf.as_bytes())
390        .map_err(|e| format!("redis write: {e}"))
391}
392
393/// Hard cap for a single RESP bulk payload (untrusted network / redis server).
394/// Prevents `vec![0; n]` OOM from a malicious length (rules: try_reserve / max before alloc).
395const MAX_RESP_BULK_BYTES: usize = 16 * 1024 * 1024;
396const MAX_RESP_LINE_BYTES: usize = 16 * 1024 * 1024;
397
398fn checked_resp_bulk_len(n: i64) -> Result<usize, String> {
399    if n < 0 {
400        return Err("negative bulk length".into());
401    }
402    let n = n as u64;
403    if n > MAX_RESP_BULK_BYTES as u64 {
404        return Err(format!(
405            "redis bulk too large: {n} > {MAX_RESP_BULK_BYTES} (allocation budget)"
406        ));
407    }
408    Ok(n as usize)
409}
410
411fn read_resp_line(stream: &mut impl std::io::Read) -> Result<String, String> {
412    let mut line = Vec::new();
413    let mut byte = [0u8; 1];
414    loop {
415        let n = stream
416            .read(&mut byte)
417            .map_err(|e| format!("redis read: {e}"))?;
418        if n == 0 {
419            break;
420        }
421        if byte[0] == b'\n' {
422            break;
423        }
424        if byte[0] != b'\r' {
425            line.push(byte[0]);
426        }
427        if line.len() > MAX_RESP_LINE_BYTES {
428            return Err("redis line too large".into());
429        }
430    }
431    String::from_utf8(line).map_err(|e| format!("redis utf8: {e}"))
432}
433
434fn read_resp_value(stream: &mut impl std::io::Read) -> Result<String, String> {
435    let line = read_resp_line(stream)?;
436    if line.is_empty() {
437        return Err("empty redis response".into());
438    }
439    match line.as_bytes()[0] {
440        b'+' | b':' | b'-' => Ok(line[1..].to_string()),
441        b'$' => {
442            let n: i64 = line[1..].parse().map_err(|e| format!("bulk len: {e}"))?;
443            if n < 0 {
444                return Ok(String::new());
445            }
446            let len = checked_resp_bulk_len(n)?;
447            // Size validated → fallible reserve then fill (OOM-safe path for untrusted n).
448            let mut buf = Vec::new();
449            buf.try_reserve_exact(len.saturating_add(2))
450                .map_err(|e| format!("redis bulk reserve failed: {e}"))?;
451            buf.resize(len + 2, 0);
452            stream
453                .read_exact(&mut buf)
454                .map_err(|e| format!("bulk read: {e}"))?;
455            // drop trailing CRLF
456            if buf.len() >= 2 {
457                buf.truncate(buf.len() - 2);
458            }
459            String::from_utf8(buf).map_err(|e| format!("bulk utf8: {e}"))
460        }
461        b'*' => {
462            // For simple commands we only need first line acknowledgement.
463            Ok(line)
464        }
465        _ => Ok(line),
466    }
467}
468
469/// Build the product cache from XDG `cache_backend` (sqlite|memory|redis).
470pub fn default_cache() -> Result<Box<dyn HttpCache>, CliError> {
471    let cfg = xdg::load_config().unwrap_or_default();
472    let backend = cfg
473        .cache_backend
474        .as_deref()
475        .unwrap_or("sqlite")
476        .to_ascii_lowercase();
477    match backend.as_str() {
478        "memory" => Ok(Box::new(MemoryCache::default())),
479        "redis" => {
480            let url = cfg.cache_redis_url.as_deref().unwrap_or("");
481            Ok(Box::new(RedisCache::connect(url)?))
482        }
483        // default sqlite layered
484        _ => Ok(Box::new(LayeredCache {
485            l1: MemoryCache::default(),
486            l2: SqliteCache::open_default()?,
487        })),
488    }
489}
490
491/// Layered only (tests / explicit sqlite path).
492pub fn sqlite_layered_cache() -> Result<LayeredCache, CliError> {
493    Ok(LayeredCache {
494        l1: MemoryCache::default(),
495        l2: SqliteCache::open_default()?,
496    })
497}
498
499/// TTL helper: now + duration.
500pub fn expires_after(ttl: Duration) -> u64 {
501    now_unix().saturating_add(ttl.as_secs())
502}
503
504fn now_unix() -> u64 {
505    SystemTime::now()
506        .duration_since(UNIX_EPOCH)
507        .map(|d| d.as_secs())
508        .unwrap_or(0)
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use std::collections::HashMap;
515    use std::io::{Read, Write};
516    use std::net::{TcpListener, TcpStream};
517    use std::sync::atomic::{AtomicBool, Ordering};
518    use std::sync::{Arc, Mutex};
519    use std::thread;
520
521    /// Minimal RESP server speaking the subset used by [`RedisCache`] (GAP-A008).
522    struct RespMockServer {
523        port: u16,
524        /// Stop flag is a pure boolean shared across threads → `AtomicBool`
525        /// (rules: never `Mutex<bool>` when an atomic suffices).
526        stop: Arc<AtomicBool>,
527        join: Option<thread::JoinHandle<()>>,
528    }
529
530    impl RespMockServer {
531        fn spawn() -> Result<Self, String> {
532            let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?;
533            let port = listener.local_addr().map_err(|e| e.to_string())?.port();
534            // Relaxed: isolated stop flag; no dependent data publication.
535            let stop = Arc::new(AtomicBool::new(false));
536            let stop_t = Arc::clone(&stop);
537            let store: Arc<Mutex<HashMap<String, String>>> = Arc::new(Mutex::new(HashMap::new()));
538            let join = thread::spawn(move || {
539                let _ = listener.set_nonblocking(true);
540                while !stop_t.load(Ordering::Relaxed) {
541                    match listener.accept() {
542                        Ok((stream, _)) => {
543                            let store = Arc::clone(&store);
544                            thread::spawn(move || {
545                                let _ = handle_resp_client(stream, store);
546                            });
547                        }
548                        Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
549                            thread::sleep(std::time::Duration::from_millis(5));
550                        }
551                        Err(_) => break,
552                    }
553                }
554            });
555            thread::sleep(std::time::Duration::from_millis(20));
556            Ok(Self {
557                port,
558                stop,
559                join: Some(join),
560            })
561        }
562    }
563
564    impl Drop for RespMockServer {
565        fn drop(&mut self) {
566            self.stop.store(true, Ordering::Relaxed);
567            let _ = TcpStream::connect(("127.0.0.1", self.port));
568            if let Some(j) = self.join.take() {
569                let _ = j.join();
570            }
571        }
572    }
573
574    fn lock_store(
575        store: &Mutex<HashMap<String, String>>,
576    ) -> std::sync::MutexGuard<'_, HashMap<String, String>> {
577        // Test mock: recover poison so one handler panic cannot freeze the mock.
578        store
579            .lock()
580            .unwrap_or_else(|poisoned| poisoned.into_inner())
581    }
582
583    fn handle_resp_client(
584        mut stream: TcpStream,
585        store: Arc<Mutex<HashMap<String, String>>>,
586    ) -> Result<(), String> {
587        let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(2)));
588        let _ = stream.set_write_timeout(Some(std::time::Duration::from_secs(2)));
589        while let Ok(cmd) = read_resp_array(&mut stream) {
590            if cmd.is_empty() {
591                break;
592            }
593            let name = cmd[0].to_ascii_uppercase();
594            let reply = match name.as_str() {
595                "PING" => "+PONG\r\n".to_string(),
596                "SELECT" => "+OK\r\n".to_string(),
597                "SET" if cmd.len() >= 3 => {
598                    let key = cmd[1].clone();
599                    let val = cmd[2].clone();
600                    lock_store(&store).insert(key, val);
601                    "+OK\r\n".to_string()
602                }
603                "GET" if cmd.len() >= 2 => {
604                    let key = &cmd[1];
605                    let val = lock_store(&store).get(key).cloned();
606                    match val {
607                        Some(v) => format!("${}\r\n{}\r\n", v.len(), v),
608                        None => "$-1\r\n".to_string(),
609                    }
610                }
611                "DEL" if cmd.len() >= 2 => {
612                    let key = &cmd[1];
613                    let n = if lock_store(&store).remove(key).is_some() {
614                        1
615                    } else {
616                        0
617                    };
618                    format!(":{n}\r\n")
619                }
620                _ => "-ERR unknown command\r\n".to_string(),
621            };
622            stream
623                .write_all(reply.as_bytes())
624                .map_err(|e| e.to_string())?;
625        }
626        Ok(())
627    }
628
629    /// Hard cap for RESP array arity in the test mock parser.
630    const MAX_RESP_ARRAY_LEN: usize = 1024;
631
632    fn read_resp_array(stream: &mut impl Read) -> Result<Vec<String>, String> {
633        let line = read_resp_line(stream)?;
634        if line.is_empty() {
635            return Err("eof".into());
636        }
637        if !line.starts_with('*') {
638            return Err(format!("expected array, got {line}"));
639        }
640        let n: i64 = line[1..].parse().map_err(|e| format!("array len: {e}"))?;
641        if n < 0 {
642            return Ok(Vec::new());
643        }
644        if n as u64 > MAX_RESP_ARRAY_LEN as u64 {
645            return Err(format!(
646                "redis array too large: {n} > {MAX_RESP_ARRAY_LEN} (allocation budget)"
647            ));
648        }
649        let mut out = Vec::new();
650        out.try_reserve_exact(n as usize)
651            .map_err(|e| format!("redis array reserve failed: {e}"))?;
652        for _ in 0..n {
653            out.push(read_resp_bulk(stream)?);
654        }
655        Ok(out)
656    }
657
658    fn read_resp_bulk(stream: &mut impl Read) -> Result<String, String> {
659        let line = read_resp_line(stream)?;
660        if !line.starts_with('$') {
661            return Err(format!("expected bulk, got {line}"));
662        }
663        let n: i64 = line[1..].parse().map_err(|e| format!("bulk len: {e}"))?;
664        if n < 0 {
665            return Ok(String::new());
666        }
667        let len = checked_resp_bulk_len(n)?;
668        let mut buf = Vec::new();
669        buf.try_reserve_exact(len.saturating_add(2))
670            .map_err(|e| format!("redis bulk reserve failed: {e}"))?;
671        buf.resize(len + 2, 0);
672        stream
673            .read_exact(&mut buf)
674            .map_err(|e| format!("bulk body: {e}"))?;
675        if buf.len() >= 2 {
676            buf.truncate(buf.len() - 2);
677        }
678        String::from_utf8(buf).map_err(|e| format!("bulk utf8: {e}"))
679    }
680
681    #[test]
682    fn resp_bulk_rejects_oversized_length() {
683        assert!(checked_resp_bulk_len(-1).is_err());
684        assert!(checked_resp_bulk_len((MAX_RESP_BULK_BYTES as i64) + 1).is_err());
685        assert_eq!(checked_resp_bulk_len(0).unwrap(), 0);
686        assert_eq!(checked_resp_bulk_len(64).unwrap(), 64);
687    }
688
689    fn which_bin(name: &str) -> Option<String> {
690        crate::platform::which_bin(name).map(|p| p.display().to_string())
691    }
692
693    fn free_port() -> Result<u16, String> {
694        let l = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?;
695        Ok(l.local_addr().map_err(|e| e.to_string())?.port())
696    }
697
698    #[test]
699    fn memory_hit_miss() {
700        let c = MemoryCache::default();
701        let k = CacheKey::http_get("https://example.com/");
702        assert!(c.get(&k).unwrap().is_none());
703        c.put(
704            &k,
705            CacheEntry {
706                body: b"hi".to_vec(),
707                content_type: Some("text/html".into()),
708                expires_unix: 0,
709            },
710        )
711        .unwrap();
712        let e = c.get(&k).unwrap().unwrap();
713        assert_eq!(e.body, b"hi");
714    }
715
716    #[test]
717    fn key_stable() {
718        assert_eq!(
719            CacheKey::http_get("https://a").as_str(),
720            CacheKey::http_get("https://a").as_str()
721        );
722        assert_ne!(
723            CacheKey::http_get("https://a").as_str(),
724            CacheKey::http_get("https://b").as_str()
725        );
726    }
727
728    #[test]
729    fn redis_url_parse() {
730        let (h, p, d) = RedisCache::parse_host_port_db("redis://127.0.0.1:6379/2").unwrap();
731        assert_eq!(h, "127.0.0.1");
732        assert_eq!(p, 6379);
733        assert_eq!(d, 2);
734    }
735
736    #[test]
737    fn redis_rediss_tls_rejected_fail_closed() {
738        // GAP-A007: never open plain TCP for rediss://
739        let err = RedisCache::parse_host_port_db("rediss://example.com:6380/0").unwrap_err();
740        assert!(
741            err.contains("rediss://") || err.contains("TLS"),
742            "expected TLS rejection, got: {err}"
743        );
744    }
745
746    #[test]
747    fn redis_connect_empty_url_errors() {
748        let e = RedisCache::connect("").unwrap_err();
749        assert!(e.message().contains("cache_redis_url") || e.message().contains("redis"));
750    }
751
752    /// Always-on TCP roundtrip against an in-process RESP mock (GAP-A008 / R-LIVE-1).
753    /// No product env; no external redis-server required.
754    #[test]
755    fn redis_roundtrip_via_resp_mock() {
756        let mock = RespMockServer::spawn().expect("mock listen");
757        let url = format!("redis://127.0.0.1:{}/0", mock.port);
758        let c = RedisCache::connect(&url).expect("connect mock redis");
759        let k = CacheKey::http_get("https://redis-mock.example/");
760        c.put(
761            &k,
762            CacheEntry {
763                body: b"live-mock".to_vec(),
764                content_type: Some("text/plain".into()),
765                expires_unix: 0,
766            },
767        )
768        .expect("put");
769        let e = c.get(&k).expect("get").expect("hit");
770        assert_eq!(e.body, b"live-mock");
771        drop(mock);
772    }
773
774    /// When `redis-server` is on PATH, spawn ephemeral instance and roundtrip (R-LIVE-4).
775    /// Skips cleanly (pass) when the binary is absent — no product env.
776    #[test]
777    fn redis_real_server_if_present() {
778        let Some(bin) = which_bin("redis-server") else {
779            eprintln!("skip redis_real_server_if_present: redis-server not on PATH");
780            return;
781        };
782        let dir = tempfile::tempdir().expect("tmp");
783        let port = free_port().expect("port");
784        let mut child = std::process::Command::new(&bin)
785            .arg("--port")
786            .arg(port.to_string())
787            .arg("--dir")
788            .arg(dir.path())
789            .arg("--save")
790            .arg("")
791            .arg("--appendonly")
792            .arg("no")
793            .arg("--bind")
794            .arg("127.0.0.1")
795            .arg("--protected-mode")
796            .arg("no")
797            .stdout(std::process::Stdio::null())
798            .stderr(std::process::Stdio::null())
799            .spawn()
800            .expect("spawn redis-server");
801        let url = format!("redis://127.0.0.1:{port}/15");
802        let mut ok = false;
803        for _ in 0..50 {
804            std::thread::sleep(std::time::Duration::from_millis(50));
805            if RedisCache::connect(&url).is_ok() {
806                ok = true;
807                break;
808            }
809        }
810        if !ok {
811            let _ = child.kill();
812            let _ = child.wait();
813            panic!("redis-server did not accept connections on {url}");
814        }
815        let c = RedisCache::connect(&url).expect("connect real redis");
816        let k = CacheKey::http_get("https://redis-real.example/");
817        c.put(
818            &k,
819            CacheEntry {
820                body: b"live-real".to_vec(),
821                content_type: Some("text/plain".into()),
822                expires_unix: 0,
823            },
824        )
825        .expect("put");
826        let e = c.get(&k).expect("get").expect("hit");
827        assert_eq!(e.body, b"live-real");
828        let _ = child.kill();
829        let _ = child.wait();
830    }
831
832    #[test]
833    fn default_cache_sqlite_works() {
834        let c = default_cache().expect("sqlite layered");
835        let k = CacheKey::http_get("https://cache-audit.example/");
836        c.put(
837            &k,
838            CacheEntry {
839                body: b"ok".to_vec(),
840                content_type: Some("text/plain".into()),
841                expires_unix: 0,
842            },
843        )
844        .unwrap();
845        let e = c.get(&k).unwrap().unwrap();
846        assert_eq!(e.body, b"ok");
847    }
848}