Skip to main content

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}