Skip to main content

browser_automation_cli/
cache.rs

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