1use std::path::Path;
4
5use rusqlite::{params, Connection, OptionalExtension};
6use std::sync::Mutex;
7
8use crate::enrichment::IntelEnrichment;
9
10pub struct IntelCache {
12 conn: Mutex<Connection>,
13}
14
15impl IntelCache {
16 pub fn open<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
18 let conn = Connection::open(path)?;
19 conn.execute_batch(
20 "PRAGMA journal_mode = WAL;
21 PRAGMA synchronous = NORMAL;
22 CREATE TABLE IF NOT EXISTS cache (
23 source TEXT NOT NULL,
24 target_type TEXT NOT NULL,
25 target_value TEXT NOT NULL,
26 data TEXT NOT NULL,
27 fetched_at INTEGER NOT NULL,
28 PRIMARY KEY (source, target_type, target_value)
29 );
30 CREATE INDEX IF NOT EXISTS idx_cache_lookup ON cache(source, target_type, target_value);
31 CREATE INDEX IF NOT EXISTS idx_cache_stale ON cache(fetched_at);"
32 )?;
33 Ok(Self {
34 conn: Mutex::new(conn),
35 })
36 }
37
38 pub fn get(
40 &self,
41 source: &str,
42 target_type: &str,
43 target_value: &str,
44 ttl_secs: u64,
45 ) -> anyhow::Result<Option<IntelEnrichment>> {
46 let conn = self
47 .conn
48 .lock()
49 .map_err(|e| anyhow::anyhow!("mutex poisoned: {e}"))?;
50 let row: Option<(String, i64)> = conn
51 .query_row(
52 "SELECT data, fetched_at FROM cache
53 WHERE source = ?1 AND target_type = ?2 AND target_value = ?3",
54 params![source, target_type, target_value],
55 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
56 )
57 .optional()?;
58
59 let Some((data, fetched_at)) = row else {
60 return Ok(None);
61 };
62
63 let now = std::time::SystemTime::now()
64 .duration_since(std::time::UNIX_EPOCH)
65 .unwrap_or_default()
66 .as_secs() as i64;
67 if now - fetched_at > ttl_secs as i64 {
68 return Ok(None);
69 }
70
71 Ok(serde_json::from_str(&data).ok())
72 }
73
74 pub fn put(&self, enrichment: &IntelEnrichment) -> anyhow::Result<()> {
76 let conn = self
77 .conn
78 .lock()
79 .map_err(|e| anyhow::anyhow!("mutex poisoned: {e}"))?;
80 let data = serde_json::to_string(enrichment)?;
81 conn.execute(
82 "INSERT OR REPLACE INTO cache (source, target_type, target_value, data, fetched_at)
83 VALUES (?1, ?2, ?3, ?4, ?5)",
84 params![
85 enrichment.source,
86 enrichment.target_type,
87 enrichment.target_value,
88 data,
89 enrichment.fetched_at as i64
90 ],
91 )?;
92 Ok(())
93 }
94
95 pub fn evict_stale(&self, ttl_secs: u64) -> anyhow::Result<usize> {
97 let cutoff = std::time::SystemTime::now()
98 .duration_since(std::time::UNIX_EPOCH)
99 .unwrap_or_default()
100 .as_secs() as i64
101 - ttl_secs as i64;
102 let conn = self
103 .conn
104 .lock()
105 .map_err(|e| anyhow::anyhow!("mutex poisoned: {e}"))?;
106 let n = conn.execute("DELETE FROM cache WHERE fetched_at < ?1", params![cutoff])?;
107 Ok(n)
108 }
109
110 pub fn clear(&self) -> anyhow::Result<()> {
112 let conn = self
113 .conn
114 .lock()
115 .map_err(|e| anyhow::anyhow!("mutex poisoned: {e}"))?;
116 conn.execute("DELETE FROM cache", [])?;
117 Ok(())
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use tempfile::NamedTempFile;
125
126 #[test]
127 fn cache_hit_and_miss() {
128 let file = NamedTempFile::new().unwrap();
129 let cache = IntelCache::open(file.path()).unwrap();
130
131 let enrichment = IntelEnrichment::new("shodan", "ip", "1.2.3.4");
132 cache.put(&enrichment).unwrap();
133
134 let hit = cache.get("shodan", "ip", "1.2.3.4", 3600).unwrap();
135 assert!(hit.is_some());
136
137 let miss = cache.get("shodan", "ip", "5.6.7.8", 3600).unwrap();
138 assert!(miss.is_none());
139 }
140
141 #[test]
142 fn cache_ttl_respected() {
143 let file = NamedTempFile::new().unwrap();
144 let cache = IntelCache::open(file.path()).unwrap();
145
146 let mut enrichment = IntelEnrichment::new("shodan", "ip", "1.2.3.4");
147 enrichment.fetched_at = 0; cache.put(&enrichment).unwrap();
149
150 let stale = cache.get("shodan", "ip", "1.2.3.4", 3600).unwrap();
151 assert!(stale.is_none());
152 }
153
154 #[test]
155 fn cache_persists_across_reopen() {
156 let file = NamedTempFile::new().unwrap();
157 let path = file.path().to_path_buf();
158
159 let cache = IntelCache::open(&path).unwrap();
160 let enrichment = IntelEnrichment::new("vt", "domain", "example.com");
161 cache.put(&enrichment).unwrap();
162 drop(cache);
163
164 let cache2 = IntelCache::open(&path).unwrap();
165 let hit = cache2.get("vt", "domain", "example.com", 3600).unwrap();
166 assert!(hit.is_some());
167 }
168
169 #[test]
170 fn cache_evict_stale() {
171 let file = NamedTempFile::new().unwrap();
172 let cache = IntelCache::open(file.path()).unwrap();
173
174 let mut old = IntelEnrichment::new("abuseipdb", "ip", "1.1.1.1");
175 old.fetched_at = 0;
176 cache.put(&old).unwrap();
177
178 let n = cache.evict_stale(60).unwrap();
179 assert_eq!(n, 1);
180
181 let miss = cache.get("abuseipdb", "ip", "1.1.1.1", 3600).unwrap();
182 assert!(miss.is_none());
183 }
184}