Skip to main content

gossan_intel/
cache.rs

1//! Persistent TTL cache for intel enrichment.
2
3use std::path::Path;
4
5use rusqlite::{params, Connection, OptionalExtension};
6use std::sync::Mutex;
7
8use crate::enrichment::IntelEnrichment;
9
10/// SQLite-backed TTL cache keyed by (source, target_type, target_value).
11pub struct IntelCache {
12    conn: Mutex<Connection>,
13}
14
15impl IntelCache {
16    /// Open or create the cache database.
17    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    /// Get a cached enrichment if it exists and is not stale.
39    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    /// Store an enrichment in the cache.
75    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    /// Evict entries older than `ttl_secs`.
96    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    /// Clear all cached data.
111    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; // ancient
148        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}