1use 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
18pub struct CacheKey(String);
19
20impl CacheKey {
21 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 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 pub fn as_str(&self) -> &str {
41 &self.0
42 }
43}
44
45#[derive(Debug, Clone)]
47pub struct CacheEntry {
48 pub body: Vec<u8>,
50 pub content_type: Option<String>,
52 pub expires_unix: u64,
54}
55
56impl CacheEntry {
57 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
66pub trait HttpCache: Send {
68 fn get(&self, key: &CacheKey) -> Result<Option<CacheEntry>, CliError>;
70 fn put(&self, key: &CacheKey, entry: CacheEntry) -> Result<(), CliError>;
72}
73
74#[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
99pub struct SqliteCache {
101 path: PathBuf,
102}
103
104impl SqliteCache {
105 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
180pub struct LayeredCache {
182 pub l1: MemoryCache,
184 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#[derive(Debug)]
210pub struct RedisCache {
211 url: String,
212}
213
214impl RedisCache {
215 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 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 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 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]; stream
411 .read_exact(&mut buf)
412 .map_err(|e| format!("bulk read: {e}"))?;
413 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 Ok(line)
422 }
423 _ => Ok(line),
424 }
425}
426
427pub 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 _ => Ok(Box::new(LayeredCache {
443 l1: MemoryCache::default(),
444 l2: SqliteCache::open_default()?,
445 })),
446 }
447}
448
449pub fn sqlite_layered_cache() -> Result<LayeredCache, CliError> {
451 Ok(LayeredCache {
452 l1: MemoryCache::default(),
453 l2: SqliteCache::open_default()?,
454 })
455}
456
457pub 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 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 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 #[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 #[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}