huddle_core/storage/mod.rs
1pub mod keychain;
2pub mod repo;
3pub mod schema;
4
5use rusqlite::Connection;
6use std::path::Path;
7use std::sync::{Arc, Mutex};
8
9use crate::error::{HuddleError, Result};
10
11pub type Db = Arc<Mutex<Connection>>;
12
13/// Open the DB. If `master_key` is `Some`, SQLCipher is unlocked with
14/// `PRAGMA key`; otherwise the DB is opened unencrypted (the Phase 1
15/// path, kept for tests and `--no-master-passphrase` runs).
16///
17/// huddle 0.7.11: after `PRAGMA key` we run a sentinel query that
18/// forces SQLCipher to actually try to decrypt a page. A wrong master
19/// key (typo on the prompt) used to surface as a cryptic "file is not
20/// a database" error from a downstream `CREATE TABLE`; we now catch
21/// it here and return a clean "wrong master passphrase" message.
22pub fn open_db(path: &Path, master_key: Option<&[u8; 32]>) -> Result<Db> {
23 let conn = Connection::open(path)?;
24 if let Some(key) = master_key {
25 let pragma = format!("PRAGMA key = \"x'{}'\";", hex::encode(key));
26 conn.execute_batch(&pragma)?;
27 // Sentinel query: forces decryption of page 1. If the key is
28 // wrong, SQLCipher returns an error here with a recognizable
29 // shape — turn it into a domain-specific error so the TUI can
30 // re-prompt rather than crashing with a generic message.
31 if let Err(e) = conn.query_row("SELECT count(*) FROM sqlite_master", [], |r| {
32 r.get::<_, i64>(0)
33 }) {
34 return Err(HuddleError::Session(format!(
35 "wrong master passphrase, or DB file corrupt: {e}"
36 )));
37 }
38 }
39 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
40 run_migrations(&conn)?;
41 Ok(Arc::new(Mutex::new(conn)))
42}
43
44pub fn open_db_in_memory() -> Result<Db> {
45 let conn = Connection::open_in_memory()?;
46 conn.execute_batch("PRAGMA foreign_keys=ON;")?;
47 run_migrations(&conn)?;
48 Ok(Arc::new(Mutex::new(conn)))
49}
50
51/// Apply pending schema migrations, tracked by `PRAGMA user_version`.
52/// Each entry in `schema::MIGRATIONS` runs exactly once, in order; the
53/// version cursor advances after each so a real SQL error aborts startup
54/// instead of being silently swallowed. Migrations are therefore
55/// append-only — never reorder or delete an existing entry.
56///
57/// huddle 0.7.11: each migration runs inside a transaction that ALSO
58/// bumps `user_version`. Pre-0.7.11 a partial-batch failure (e.g. the
59/// second statement in a multi-statement migration errored) left the
60/// schema in a half-applied state with `user_version` un-bumped, so
61/// the next launch retried the first statement (now a duplicate) and
62/// wedged startup forever. Wrapping in a tx means a failure rolls back
63/// cleanly.
64fn run_migrations(conn: &Connection) -> Result<()> {
65 let applied: i64 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
66 for (idx, migration) in schema::MIGRATIONS.iter().enumerate() {
67 if (idx as i64) < applied {
68 continue;
69 }
70 // Atomic apply: migration + version bump in one transaction.
71 let target = (idx + 1) as i64;
72 let batch = format!(
73 "BEGIN; {migration}; PRAGMA user_version = {target}; COMMIT;",
74 migration = migration,
75 target = target
76 );
77 if let Err(e) = conn.execute_batch(&batch) {
78 // Best-effort rollback (no-op if not in a tx).
79 let _ = conn.execute_batch("ROLLBACK;");
80 return Err(HuddleError::Other(format!(
81 "migration {idx} failed: {e}"
82 )));
83 }
84 }
85 Ok(())
86}