Skip to main content

memory_core/store/
mod.rs

1pub mod dedup;
2pub mod eventlog;
3pub mod memory;
4pub mod metrics;
5pub mod privacy;
6pub mod relations;
7pub mod schema;
8pub mod scope;
9
10use rusqlite::Connection;
11
12use crate::config::Config;
13use crate::Error;
14
15pub struct Store {
16    conn: Connection,
17    config: Config,
18}
19
20impl Store {
21    pub fn open(path: &str, config: Config, passphrase: Option<&str>) -> crate::Result<Self> {
22        if config.storage.encryption_enabled && passphrase.is_none() {
23            return Err(Error::Encryption(
24                "encryption is enabled but no passphrase was provided".into(),
25            ));
26        }
27        let mut conn = Connection::open(path)?;
28        if let Some(key) = passphrase {
29            apply_passphrase(&conn, key)?;
30            verify_access(&conn)?;
31        }
32        configure_connection(&conn, &config)?;
33        schema::check_version(&conn)?;
34        run_migrations(&mut conn)?;
35        Ok(Self { conn, config })
36    }
37
38    pub fn open_in_memory() -> crate::Result<Self> {
39        Self::open_in_memory_with_config(Config::default())
40    }
41
42    pub fn open_in_memory_with_config(config: Config) -> crate::Result<Self> {
43        let mut conn = Connection::open_in_memory()?;
44        configure_connection(&conn, &config)?;
45        run_migrations(&mut conn)?;
46        Ok(Self { conn, config })
47    }
48
49    pub fn config(&self) -> &Config {
50        &self.config
51    }
52
53    pub(crate) fn conn(&self) -> &Connection {
54        &self.conn
55    }
56
57    pub fn get_metadata(&self, key: &str) -> crate::Result<Option<String>> {
58        match self.conn.query_row(
59            "SELECT value FROM _metadata WHERE key = ?1",
60            rusqlite::params![key],
61            |row| row.get::<_, String>(0),
62        ) {
63            Ok(val) => Ok(Some(val)),
64            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
65            Err(e) => Err(e.into()),
66        }
67    }
68
69    pub fn set_metadata(&self, key: &str, value: &str) -> crate::Result<()> {
70        self.conn.execute(
71            "INSERT INTO _metadata (key, value) VALUES (?1, ?2)
72             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
73            rusqlite::params![key, value],
74        )?;
75        Ok(())
76    }
77
78    pub fn schema_version(&self) -> crate::Result<i64> {
79        Ok(self
80            .conn
81            .pragma_query_value(None, "user_version", |r| r.get(0))?)
82    }
83
84    /// Check if a database file is encrypted (requires `encryption` feature to detect).
85    pub fn is_encrypted(path: &str) -> bool {
86        // Try opening without a key — if it fails on sqlite_master, it's encrypted
87        let conn = match Connection::open(path) {
88            Ok(c) => c,
89            Err(_) => return false,
90        };
91        conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(()))
92            .is_err()
93    }
94
95    /// Encrypt an existing unencrypted database using SQLCipher's ATTACH + sqlcipher_export.
96    /// Uses backup-before-rename: original DB is preserved until verification passes.
97    #[cfg(feature = "encryption")]
98    pub fn encrypt(path: &str, passphrase: &str, config: Config) -> crate::Result<()> {
99        use std::fs;
100
101        if Self::is_encrypted(path) {
102            return Err(Error::Encryption(
103                "database is already encrypted — decrypt first or delete and reinitialise".into(),
104            ));
105        }
106
107        let tmp_path = format!("{}.encrypting", path);
108        let backup_path = format!("{}.backup", path);
109
110        // Remove stale temp file from a previous failed attempt
111        fs::remove_file(&tmp_path).ok();
112
113        // Export to encrypted temp file
114        {
115            let conn = Connection::open(path)?;
116            conn.execute_batch(&format!(
117                "ATTACH DATABASE '{}' AS encrypted KEY '{}';",
118                escape_sql_string(&tmp_path),
119                escape_sql_string(passphrase)
120            ))?;
121            conn.execute_batch("SELECT sqlcipher_export('encrypted');")?;
122            let ver: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
123            conn.execute_batch(&format!("PRAGMA encrypted.user_version = {};", ver))?;
124            conn.execute_batch("DETACH DATABASE encrypted;")?;
125        }
126
127        // Verify the encrypted file BEFORE touching the original
128        let verify_result = Store::open(&tmp_path, config.clone(), Some(passphrase));
129        if let Err(e) = verify_result {
130            fs::remove_file(&tmp_path).ok();
131            return Err(Error::Encryption(format!(
132                "encrypted DB failed verification, original preserved: {}",
133                e
134            )));
135        }
136        drop(verify_result);
137
138        // Backup original, then replace
139        fs::rename(path, &backup_path)
140            .map_err(|e| Error::Encryption(format!("failed to backup original: {}", e)))?;
141        cleanup_wal_files(path);
142
143        if let Err(e) = fs::rename(&tmp_path, path) {
144            // Restore backup on failure
145            fs::rename(&backup_path, path).ok();
146            return Err(Error::Encryption(format!("failed to replace db: {}", e)));
147        }
148
149        // Success — remove backup
150        fs::remove_file(&backup_path).ok();
151        Ok(())
152    }
153
154    /// Rotate the encryption passphrase in-place using SQLCipher's PRAGMA rekey.
155    /// No intermediate plaintext file is created — the re-encryption is atomic.
156    #[cfg(feature = "encryption")]
157    pub fn rekey(&self, new_passphrase: &str) -> crate::Result<()> {
158        self.conn
159            .pragma_update(None, "rekey", new_passphrase)
160            .map_err(|e| Error::Encryption(format!("rekey failed: {}", e)))?;
161        // Verify the new passphrase works by reading user_version
162        self.conn
163            .query_row("PRAGMA user_version", [], |r| r.get::<_, i32>(0))
164            .map_err(|_| {
165                Error::Encryption("rekey verification failed — database may be in an inconsistent state".into())
166            })?;
167        Ok(())
168    }
169
170    /// Decrypt an encrypted database back to plaintext.
171    /// Uses backup-before-rename: original DB is preserved until verification passes.
172    #[cfg(feature = "encryption")]
173    pub fn decrypt(path: &str, passphrase: &str, config: Config) -> crate::Result<()> {
174        use std::fs;
175        let tmp_path = format!("{}.decrypting", path);
176        let backup_path = format!("{}.backup", path);
177
178        // Export to plaintext temp file
179        {
180            let conn = Connection::open(path)?;
181            apply_passphrase(&conn, passphrase)?;
182            verify_access(&conn)?;
183            conn.execute_batch(&format!(
184                "ATTACH DATABASE '{}' AS plaintext KEY '';",
185                escape_sql_string(&tmp_path)
186            ))?;
187            conn.execute_batch("SELECT sqlcipher_export('plaintext');")?;
188            let ver: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
189            conn.execute_batch(&format!("PRAGMA plaintext.user_version = {};", ver))?;
190            conn.execute_batch("DETACH DATABASE plaintext;")?;
191        }
192
193        // Verify the plaintext file BEFORE touching the original
194        let mut verify_config = config;
195        verify_config.storage.encryption_enabled = false;
196        let verify_result = Store::open(&tmp_path, verify_config.clone(), None);
197        if let Err(e) = verify_result {
198            fs::remove_file(&tmp_path).ok();
199            return Err(Error::Encryption(format!(
200                "decrypted DB failed verification, original preserved: {}",
201                e
202            )));
203        }
204        drop(verify_result);
205
206        // Backup original, then replace
207        fs::rename(path, &backup_path)
208            .map_err(|e| Error::Encryption(format!("failed to backup original: {}", e)))?;
209        cleanup_wal_files(path);
210
211        if let Err(e) = fs::rename(&tmp_path, path) {
212            fs::rename(&backup_path, path).ok();
213            return Err(Error::Encryption(format!("failed to replace db: {}", e)));
214        }
215
216        // Success — remove backup
217        fs::remove_file(&backup_path).ok();
218        Ok(())
219    }
220}
221
222fn apply_passphrase(conn: &Connection, passphrase: &str) -> crate::Result<()> {
223    // PRAGMA key must be the FIRST statement after opening (SQLCipher requirement)
224    conn.pragma_update(None, "key", passphrase)
225        .map_err(|e| Error::Encryption(format!("failed to set encryption key: {}", e)))?;
226    Ok(())
227}
228
229fn verify_access(conn: &Connection) -> crate::Result<()> {
230    conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(()))
231        .map_err(|_| {
232            Error::Encryption("cannot access database — wrong passphrase or not encrypted".into())
233        })?;
234    Ok(())
235}
236
237fn configure_connection(conn: &Connection, config: &Config) -> rusqlite::Result<()> {
238    conn.execute_batch(&format!(
239        "PRAGMA journal_mode = WAL;
240         PRAGMA busy_timeout = {};
241         PRAGMA synchronous = NORMAL;
242         PRAGMA foreign_keys = ON;
243         PRAGMA cache_size = -{};",
244        config.storage.busy_timeout_ms, config.storage.cache_size_kb
245    ))?;
246    Ok(())
247}
248
249fn run_migrations(conn: &mut Connection) -> crate::Result<()> {
250    let migrations = schema::migrations();
251    migrations
252        .to_latest(conn)
253        .map_err(|e| Error::Migration(e.to_string()))?;
254    Ok(())
255}
256
257#[cfg(feature = "encryption")]
258fn escape_sql_string(s: &str) -> String {
259    s.replace('\'', "''")
260}
261
262/// Remove stale WAL-mode sidecar files after replacing a DB file.
263/// SQLite creates .db-shm and .db-wal; these are invalid after the main
264/// file is swapped and will confuse the new connection.
265#[cfg(feature = "encryption")]
266fn cleanup_wal_files(path: &str) {
267    use std::fs;
268    fs::remove_file(format!("{}-shm", path)).ok();
269    fs::remove_file(format!("{}-wal", path)).ok();
270}