Skip to main content

reliary_core/
content_cache.rs

1//! Content cache — stores original content by hash, retrievable on demand.
2//! Enables aggressive compression of tool results and reasoning: if the LLM
3//! needs originals, it calls `reliary retrieve <hash>` to get them back.
4//! Storage: SQLite at .reliary/cache.sqlite in the project root.
5
6use rusqlite::{Connection, params};
7use std::path::Path;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10const DEFAULT_TTL_SECS: u64 = 3600;
11const DEFAULT_MAX_ENTRIES: usize = 500;
12
13#[inline(always)]
14pub fn now() -> u64 {
15    SystemTime::now()
16        .duration_since(UNIX_EPOCH)
17        .map(|d| d.as_secs())
18        .unwrap_or(0)
19}
20
21/// Hash content for storage key. Returns the full SHA-256 hex digest.
22///
23/// V80: was a 64-bit SipHash. `store` uses INSERT OR REPLACE, so a collision
24/// silently OVERWROTE a different entry's content with no verification — and a
25/// 64-bit space collides (birthday) at ~65k entries, well within the 500-entry
26/// cache only if adversarial input is crafted; more importantly the value is a
27/// user-facing retrieval key. SHA-256 makes collisions infeasible.
28pub fn hash_content(content: &str) -> String {
29    use sha2::{Digest, Sha256};
30    let mut h = Sha256::new();
31    h.update(content.as_bytes());
32    format!("{:x}", h.finalize())
33}
34
35/// Open or create the cache database at the given path.
36pub fn open(path: &Path) -> Result<Connection, String> {
37    let conn = Connection::open(path).map_err(|e| format!("open: {}", e))?;
38    conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;")
39        .map_err(|e| format!("pragma: {}", e))?;
40    conn.execute_batch(
41        "CREATE TABLE IF NOT EXISTS content_cache (
42            hash TEXT PRIMARY KEY,
43            original BLOB NOT NULL,
44            stored_at INTEGER NOT NULL,
45            accessed_at INTEGER NOT NULL
46        );
47        CREATE INDEX IF NOT EXISTS idx_accessed ON content_cache(accessed_at);",
48    )
49    .map_err(|e| format!("create: {}", e))?;
50    Ok(conn)
51}
52
53/// Store original content, return hash.
54pub fn store(conn: &Connection, content: &str) -> Result<String, String> {
55    let hash = hash_content(content);
56    let now = now();
57    conn.execute(
58        "INSERT OR REPLACE INTO content_cache (hash, original, stored_at, accessed_at) VALUES (?1, ?2, ?3, ?3)",
59        params![hash, content.as_bytes(), now],
60    )
61    .map_err(|e| format!("insert: {}", e))?;
62    Ok(hash)
63}
64
65/// Retrieve original content by hash. Updates accessed_at in one round trip.
66pub fn retrieve(conn: &Connection, hash: &str) -> Result<Option<String>, String> {
67    let now = now();
68    let mut stmt = conn
69        .prepare_cached("UPDATE content_cache SET accessed_at = ?1 WHERE hash = ?2 RETURNING original")
70        .map_err(|e| format!("prepare: {}", e))?;
71    match stmt.query_row(params![now, hash], |row| row.get::<_, Vec<u8>>(0)) {
72        Ok(blob) => String::from_utf8(blob).map(Some).map_err(|e| format!("utf8: {}", e)),
73        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
74        Err(e) => Err(format!("query: {}", e)),
75    }
76}
77
78/// Evict entries older than TTL or beyond max_entries count.
79pub fn evict(conn: &Connection, ttl_secs: u64, max_entries: usize) -> Result<usize, String> {
80    let now = now();
81    let expired = conn
82        .execute(
83            "DELETE FROM content_cache WHERE accessed_at < ?1",
84            params![now.saturating_sub(ttl_secs)],
85        )
86        .map_err(|e| format!("evict ttl: {}", e))?;
87    let count: i64 = conn
88        .query_row("SELECT COUNT(*) FROM content_cache", [], |r| r.get(0))
89        .unwrap_or(0);
90    let mut trimmed = 0;
91    if (count as usize) > max_entries {
92        let excess = count as usize - max_entries;
93        trimmed = conn
94            .execute(
95                "DELETE FROM content_cache WHERE hash IN (
96                    SELECT hash FROM content_cache ORDER BY accessed_at ASC LIMIT ?1
97                )",
98                params![excess],
99            )
100            .map_err(|e| format!("evict max: {}", e))?;
101    }
102    Ok(expired + trimmed)
103}
104
105/// Stats for the `reliary stats` command.
106/// D29: Combined into single query.
107pub fn stats(conn: &Connection) -> Result<(usize, u64), String> {
108    let (count, bytes): (i64, i64) = conn
109        .query_row(
110            "SELECT COUNT(*), COALESCE(SUM(LENGTH(original)), 0) FROM content_cache",
111            [],
112            |r| Ok((r.get(0)?, r.get(1)?)),
113        )
114        .unwrap_or((0, 0));
115    Ok((count as usize, bytes as u64))
116}
117
118#[inline(always)]
119pub fn default_ttl() -> u64 {
120    DEFAULT_TTL_SECS
121}
122
123#[inline(always)]
124pub fn default_max_entries() -> usize {
125    DEFAULT_MAX_ENTRIES
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use std::env;
132    use std::sync::atomic::{AtomicUsize, Ordering};
133
134    static COUNTER: AtomicUsize = AtomicUsize::new(0);
135
136    fn temp_db() -> Connection {
137        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
138        let path = env::temp_dir().join(format!("reliary_test_{}_{}_{}.db", std::process::id(), now(), n));
139        let _ = std::fs::remove_file(&path);
140        open(&path).unwrap()
141    }
142
143    #[test]
144    fn store_and_retrieve() {
145        let conn = temp_db();
146        let hash = store(&conn, "hello world").unwrap();
147        assert_eq!(retrieve(&conn, &hash).unwrap(), Some("hello world".to_string()));
148    }
149
150    #[test]
151    fn different_content_different_hash() {
152        let conn = temp_db();
153        let h1 = store(&conn, "alpha").unwrap();
154        let h2 = store(&conn, "beta").unwrap();
155        assert_ne!(h1, h2);
156    }
157
158    #[test]
159    fn missing_hash_returns_none() {
160        let conn = temp_db();
161        assert_eq!(retrieve(&conn, "nonexistent").unwrap(), None);
162    }
163
164    #[test]
165    fn evict_by_count() {
166        let conn = temp_db();
167        for i in 0..10 {
168            store(&conn, &format!("entry_{}", i)).unwrap();
169        }
170        let removed = evict(&conn, u64::MAX, 5).unwrap();
171        assert_eq!(removed, 5);
172        let (count, _) = stats(&conn).unwrap();
173        assert_eq!(count, 5);
174    }
175
176    #[test]
177    fn stats_reports_count_and_bytes() {
178        let conn = temp_db();
179        store(&conn, "one").unwrap();
180        store(&conn, "two").unwrap();
181        let (count, bytes) = stats(&conn).unwrap();
182        assert_eq!(count, 2);
183        assert!(bytes >= 6, "expected at least 6 bytes, got {}", bytes);
184    }
185}