1use 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
30pub struct CacheKey(String);
31
32impl CacheKey {
33 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 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 pub fn as_str(&self) -> &str {
53 &self.0
54 }
55}
56
57#[derive(Debug, Clone)]
59pub struct CacheEntry {
60 pub body: Vec<u8>,
62 pub content_type: Option<String>,
64 pub expires_unix: u64,
66}
67
68impl CacheEntry {
69 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
78pub trait HttpCache: Send {
80 fn get(&self, key: &CacheKey) -> Result<Option<CacheEntry>, CliError>;
82 fn put(&self, key: &CacheKey, entry: CacheEntry) -> Result<(), CliError>;
84}
85
86#[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
118pub struct SqliteCache {
120 path: PathBuf,
121}
122
123impl SqliteCache {
124 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
199pub struct LayeredCache {
201 pub l1: MemoryCache,
203 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#[derive(Debug)]
229pub struct RedisCache {
230 url: String,
231}
232
233impl RedisCache {
234 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 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 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 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
393const 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 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 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 Ok(line)
464 }
465 _ => Ok(line),
466 }
467}
468
469pub 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 _ => Ok(Box::new(LayeredCache {
485 l1: MemoryCache::default(),
486 l2: SqliteCache::open_default()?,
487 })),
488 }
489}
490
491pub fn sqlite_layered_cache() -> Result<LayeredCache, CliError> {
493 Ok(LayeredCache {
494 l1: MemoryCache::default(),
495 l2: SqliteCache::open_default()?,
496 })
497}
498
499pub 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 struct RespMockServer {
523 port: u16,
524 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 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 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 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 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 #[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 #[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}