Skip to main content

vector_core/db/
mod.rs

1//! Database layer — SQLite with per-account databases.
2//!
3//! Architecture:
4//! - Read pool: multiple connections for parallel reads (WAL mode)
5//! - Write pool: single Mutex-protected connection (serialized writes)
6//! - RAII guards: auto-return connections to pools on drop
7//!
8//! All connection functions use static `DATA_DIR` — no Tauri AppHandle required.
9
10use std::path::PathBuf;
11use std::sync::{Arc, Mutex, OnceLock, LazyLock, RwLock};
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::ops::{Deref, DerefMut};
14use std::collections::HashMap;
15
16use serde::{Deserialize, Serialize};
17
18pub mod settings;
19pub mod schema;
20pub mod profiles;
21pub mod id_cache;
22pub mod events;
23pub mod chats;
24pub mod wrappers;
25pub mod nip17_keys;
26pub mod community;
27pub mod bots;
28
29pub use settings::{
30    get_sql_setting, set_sql_setting, get_pkey, set_pkey, get_seed, set_seed, remove_setting,
31    get_signer_type, set_signer_type,
32    get_bunker_url, set_bunker_url,
33    get_bunker_remote_pubkey, set_bunker_remote_pubkey,
34    commit_bunker_account_setup,
35};
36
37// ============================================================================
38// App Data Directory
39// ============================================================================
40
41static APP_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
42
43pub fn set_app_data_dir(path: PathBuf) {
44    let _ = APP_DATA_DIR.set(path);
45}
46
47pub fn get_app_data_dir() -> Result<&'static PathBuf, String> {
48    APP_DATA_DIR.get().ok_or_else(|| "App data directory not initialized".to_string())
49}
50
51/// Host-installed override for the download directory. Tauri sets this
52/// at boot via `set_download_dir()` so platform conventions (XDG on
53/// Linux, Known Folders on Windows) are honored. Headless callers
54/// (vector-agent CLI, tests) fall through to the env-var path.
55static DOWNLOAD_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
56
57/// Install the host-resolved download directory. Must be called at
58/// startup before any `get_download_dir()` consumer runs; callers that
59/// run earlier hit the fallback.
60pub fn set_download_dir(path: PathBuf) {
61    let _ = DOWNLOAD_DIR_OVERRIDE.set(path);
62}
63
64/// Platform-appropriate download directory for file attachments.
65///
66/// Prefers the host-installed override (honors `xdg-user-dirs`,
67/// `FOLDERID_Downloads`, `NSDownloadsDirectory`, `NSDocumentDirectory`).
68/// Falls back to `$HOME/Downloads/vector` on desktop, then
69/// `<app_data>/vector_downloads` on mobile / pre-init.
70pub fn get_download_dir() -> PathBuf {
71    if let Some(installed) = DOWNLOAD_DIR_OVERRIDE.get() {
72        return installed.clone();
73    }
74    #[cfg(any(target_os = "macos", target_os = "linux"))]
75    {
76        if let Ok(home) = std::env::var("HOME") {
77            return PathBuf::from(home).join("Downloads/vector");
78        }
79    }
80    #[cfg(target_os = "windows")]
81    {
82        if let Ok(profile) = std::env::var("USERPROFILE") {
83            return PathBuf::from(profile).join("Downloads").join("vector");
84        }
85    }
86    // Mobile / fallback: use data dir
87    if let Ok(data_dir) = get_app_data_dir() {
88        return data_dir.join("vector_downloads");
89    }
90    PathBuf::from("/tmp/vector_downloads")
91}
92
93// ============================================================================
94// Current Account
95// ============================================================================
96
97static CURRENT_ACCOUNT: LazyLock<Arc<RwLock<Option<String>>>> = LazyLock::new(|| Arc::new(RwLock::new(None)));
98// PENDING_ACCOUNT lives exclusively in src-tauri's account_manager —
99// any "pending account" check must go through that crate, not here.
100
101/// Filename for the persistent active-account marker. Plain text, just the npub.
102const ACTIVE_ACCOUNT_FILE: &str = "active_account";
103
104/// npub bech32 form: `npub1` + 58 chars from the bech32 alphabet (no `1`, `b`, `i`, `o`).
105fn is_valid_npub(s: &str) -> bool {
106    if s.len() != 63 || !s.starts_with("npub1") {
107        return false;
108    }
109    s.bytes().skip(5).all(|c| matches!(c,
110        b'q' | b'p' | b'z' | b'r' | b'y' | b'9' | b'x' | b'8' |
111        b'g' | b'f' | b'2' | b't' | b'v' | b'd' | b'w' | b'0' |
112        b's' | b'3' | b'j' | b'n' | b'5' | b'4' | b'k' | b'h' |
113        b'c' | b'e' | b'6' | b'm' | b'u' | b'a' | b'7' | b'l'
114    ))
115}
116
117pub fn get_current_account() -> Result<String, String> {
118    CURRENT_ACCOUNT.read().unwrap()
119        .as_ref().cloned()
120        .ok_or_else(|| "No active account".to_string())
121}
122
123/// Set the currently-active npub for THIS process AND persist it to the
124/// `<app_data>/active_account` marker so the next boot picks the same account.
125///
126/// Every call site asserts user intent ("this account is now active"); the
127/// marker write is idempotent and gracefully no-ops when `APP_DATA_DIR` is
128/// not yet configured (e.g. during in-process unit tests).
129pub fn set_current_account(npub: String) -> Result<(), String> {
130    *CURRENT_ACCOUNT.write().unwrap() = Some(npub.clone());
131    let _ = write_active_account_file(&npub);
132    Ok(())
133}
134
135/// Clear the in-memory active account WITHOUT touching the on-disk marker.
136/// Used by `reset_session()` so the next-boot marker stays intact while
137/// in-process state is torn down for an inline account swap.
138pub fn clear_current_account_in_memory() {
139    *CURRENT_ACCOUNT.write().unwrap() = None;
140}
141
142/// Read the active-account marker file. Returns the stored npub if it exists,
143/// is well-formed, AND the corresponding account directory still exists.
144/// Any failure path returns Ok(None) so boot falls back to single-account or picker.
145pub fn read_active_account_file() -> Result<Option<String>, String> {
146    let app_data = match get_app_data_dir() {
147        Ok(p) => p,
148        Err(_) => return Ok(None),
149    };
150    read_active_account_file_in(app_data)
151}
152
153/// Atomic write of the active-account marker (temp + rename).
154pub fn write_active_account_file(npub: &str) -> Result<(), String> {
155    let app_data = get_app_data_dir()?.clone();
156    write_active_account_file_in(&app_data, npub)
157}
158
159/// Remove the active-account marker. Used after deleting the active account.
160pub fn clear_active_account_file() -> Result<(), String> {
161    let app_data = get_app_data_dir()?;
162    clear_active_account_file_in(app_data)
163}
164
165/// Scan the app data directory for valid npub directories. Strict bech32 regex
166/// rejects typos and stray subdirectories. Does NOT validate that each account
167/// has a usable database — callers do that separately.
168pub fn list_account_npubs() -> Result<Vec<String>, String> {
169    let app_data = get_app_data_dir()?;
170    Ok(list_account_npubs_in(app_data))
171}
172
173// ----- path-parameterized internals (kept private so tests can inject a temp dir) -----
174
175/// Bound on bytes read from the active-account marker. A valid marker
176/// is 63 bytes (canonical npub) plus optional trailing newline. The
177/// marker lives in a user-writable dir, so accidental / malicious
178/// multi-gigabyte writes are a realistic OOM vector if read unbounded.
179const MARKER_MAX_BYTES: u64 = 256;
180
181fn read_active_account_file_in(app_data: &std::path::Path) -> Result<Option<String>, String> {
182    use std::io::Read;
183
184    let path = app_data.join(ACTIVE_ACCOUNT_FILE);
185    if !path.exists() {
186        return Ok(None);
187    }
188    // Pre-check size, then belt-and-suspenders cap via `take()` to
189    // cover the TOCTOU window between metadata and open. Metadata
190    // failures fail-safe to "missing".
191    if let Ok(meta) = std::fs::metadata(&path) {
192        if meta.len() > MARKER_MAX_BYTES {
193            return Ok(None);
194        }
195    } else {
196        return Ok(None);
197    }
198    let mut buf = String::new();
199    let file = match std::fs::File::open(&path) {
200        Ok(f) => f,
201        Err(_) => return Ok(None),
202    };
203    if file.take(MARKER_MAX_BYTES).read_to_string(&mut buf).is_err() {
204        return Ok(None);
205    }
206    let npub = buf.trim().to_string();
207    if !is_valid_npub(&npub) {
208        return Ok(None);
209    }
210    // `symlink_metadata` instead of `is_dir()` (which follows links): a
211    // crafted symlink at `<app_data>/<valid-npub-name>` pointing at
212    // `~/Documents` etc. would otherwise pass, and downstream
213    // `remove_dir_all` in delete_account / logout would traverse it.
214    // Bech32 validation alone is insufficient — the attacker controls
215    // the filename, not the npub semantic.
216    match std::fs::symlink_metadata(app_data.join(&npub)) {
217        Ok(meta) if meta.file_type().is_dir() && !meta.file_type().is_symlink() => {}
218        _ => return Ok(None),
219    }
220    Ok(Some(npub))
221}
222
223fn write_active_account_file_in(app_data: &std::path::Path, npub: &str) -> Result<(), String> {
224    if !is_valid_npub(npub) {
225        return Err(format!("Invalid npub format: {}", npub));
226    }
227    if !app_data.exists() {
228        std::fs::create_dir_all(app_data)
229            .map_err(|e| format!("Failed to create app data dir: {}", e))?;
230    }
231    // Refuse to point the marker at a directory that doesn't exist as a
232    // real subfolder. Closes the race where a concurrent `delete_account`
233    // for `npub` runs between the caller's existence check and this write.
234    // `symlink_metadata` (matching the read path) so a crafted
235    // `<app_data>/<valid-npub-name>` symlink can't satisfy the check.
236    match std::fs::symlink_metadata(app_data.join(npub)) {
237        Ok(meta) if meta.file_type().is_dir() && !meta.file_type().is_symlink() => {}
238        _ => return Err(format!("Account directory missing or invalid: {}", npub)),
239    }
240    let tmp = app_data.join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE));
241    let final_path = app_data.join(ACTIVE_ACCOUNT_FILE);
242
243    // Trailing newline so `cat` doesn't mangle the shell prompt and so editors
244    // that auto-strip trailing newlines don't dirty-mark the file on save.
245    let mut payload = String::with_capacity(npub.len() + 1);
246    payload.push_str(npub);
247    payload.push('\n');
248
249    if let Err(e) = std::fs::write(&tmp, payload.as_bytes()) {
250        let _ = std::fs::remove_file(&tmp);
251        return Err(format!("Failed to write active account temp file: {}", e));
252    }
253
254    // Retry the rename a few times. On Windows, transient antivirus or backup
255    // scans can hold a brief sharing-violation lock on the destination file.
256    let mut last_err = None;
257    for attempt in 0..3 {
258        match std::fs::rename(&tmp, &final_path) {
259            Ok(_) => return Ok(()),
260            Err(e) => {
261                last_err = Some(e);
262                if attempt < 2 {
263                    std::thread::sleep(std::time::Duration::from_millis(50));
264                }
265            }
266        }
267    }
268
269    // Don't leave the temp file behind if every attempt failed.
270    let _ = std::fs::remove_file(&tmp);
271    Err(format!(
272        "Failed to rename active account file: {}",
273        last_err.map(|e| e.to_string()).unwrap_or_default()
274    ))
275}
276
277fn clear_active_account_file_in(app_data: &std::path::Path) -> Result<(), String> {
278    let path = app_data.join(ACTIVE_ACCOUNT_FILE);
279    if path.exists() {
280        std::fs::remove_file(&path)
281            .map_err(|e| format!("Failed to remove active account file: {}", e))?;
282    }
283    Ok(())
284}
285
286fn list_account_npubs_in(app_data: &std::path::Path) -> Vec<String> {
287    let mut out = Vec::new();
288    if let Ok(entries) = std::fs::read_dir(app_data) {
289        for entry in entries.flatten() {
290            if entry.file_type().map_or(false, |ft| ft.is_dir()) {
291                let name = entry.file_name().to_string_lossy().to_string();
292                if is_valid_npub(&name) {
293                    out.push(name);
294                }
295            }
296        }
297    }
298    out
299}
300
301#[cfg(test)]
302mod active_account_tests {
303    use super::*;
304    use std::fs;
305    use tempfile::TempDir;
306
307    /// Real npub from the project's own test fixtures (matches bech32 regex).
308    const VALID_A: &str = "npub16ye7evyevwnl0fc9hujsxf9zym72e063awn0pvde0huvpyec5nyq4dg4wn";
309    const VALID_B: &str = "npub12w73tzcqgpr2pcy4el5x60d2emeud4cyeeayynzqgg2fefzgytaqm4ktz3";
310
311    fn touch_account_dir(base: &std::path::Path, npub: &str) {
312        fs::create_dir_all(base.join(npub)).unwrap();
313    }
314
315    #[test]
316    fn npub_validator_accepts_canonical_form() {
317        assert!(is_valid_npub(VALID_A));
318        assert!(is_valid_npub(VALID_B));
319    }
320
321    #[test]
322    fn npub_validator_rejects_wrong_length() {
323        assert!(!is_valid_npub("npub1abc"));
324        assert!(!is_valid_npub(&format!("{}x", VALID_A)));
325        assert!(!is_valid_npub(""));
326    }
327
328    #[test]
329    fn npub_validator_rejects_missing_prefix() {
330        let body = &VALID_A[5..];
331        assert!(!is_valid_npub(&format!("nsec1{}", body)));
332        assert!(!is_valid_npub(&format!("xxxx1{}", body)));
333    }
334
335    #[test]
336    fn npub_validator_rejects_non_bech32_chars() {
337        // Replace one char in the body with each disallowed bech32 letter.
338        for bad in ['1', 'b', 'i', 'o', 'B', 'I', 'O', '!', '*', ' '] {
339            let mut s = String::from(VALID_A);
340            s.replace_range(10..11, &bad.to_string());
341            assert!(!is_valid_npub(&s), "should reject character {:?}", bad);
342        }
343    }
344
345    #[test]
346    fn write_then_read_round_trips() {
347        let tmp = TempDir::new().unwrap();
348        touch_account_dir(tmp.path(), VALID_A);
349
350        write_active_account_file_in(tmp.path(), VALID_A).unwrap();
351        assert_eq!(
352            read_active_account_file_in(tmp.path()).unwrap(),
353            Some(VALID_A.to_string())
354        );
355    }
356
357    #[test]
358    fn write_rejects_invalid_npub() {
359        let tmp = TempDir::new().unwrap();
360        let err = write_active_account_file_in(tmp.path(), "npub1nope").unwrap_err();
361        assert!(err.contains("Invalid"));
362        // No file should have been created (neither final nor temp).
363        assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
364        assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
365    }
366
367    #[test]
368    fn write_rejects_missing_account_dir() {
369        // A concurrent `delete_account` between the caller's existence check
370        // and write_active_account_file would otherwise leave a stale marker
371        // pointing at a now-deleted account.
372        let tmp = TempDir::new().unwrap();
373        let err = write_active_account_file_in(tmp.path(), VALID_A).unwrap_err();
374        assert!(err.contains("missing or invalid"),
375            "expected account-dir-missing error, got: {}", err);
376        // Marker must not have been written.
377        assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
378        assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
379    }
380
381    #[test]
382    fn write_rejects_symlinked_account_dir() {
383        // A crafted `<app_data>/<valid-npub-name>` symlink to ~/Documents
384        // would otherwise pass `is_dir()` and let the marker point at an
385        // attacker-controlled location, which downstream delete/logout paths
386        // would then traverse.
387        let tmp = TempDir::new().unwrap();
388        let target = TempDir::new().unwrap();
389        let link = tmp.path().join(VALID_A);
390        #[cfg(unix)]
391        {
392            std::os::unix::fs::symlink(target.path(), &link).unwrap();
393            let err = write_active_account_file_in(tmp.path(), VALID_A).unwrap_err();
394            assert!(err.contains("missing or invalid"),
395                "expected symlink rejection, got: {}", err);
396        }
397        // On Windows symlink creation may require elevated privileges; skip
398        // the assertion there rather than gate the whole test on platform.
399        #[cfg(not(unix))]
400        let _ = (target, link);
401    }
402
403    #[test]
404    fn read_returns_none_when_marker_missing() {
405        let tmp = TempDir::new().unwrap();
406        assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
407    }
408
409    #[test]
410    fn read_returns_none_when_marker_is_garbage() {
411        let tmp = TempDir::new().unwrap();
412        fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), b"not-an-npub\n").unwrap();
413        assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
414    }
415
416    #[test]
417    fn read_returns_none_when_account_dir_missing() {
418        // Marker exists, npub is well-formed, but the account directory was
419        // deleted out from under us. Boot must fall through to picker, never crash.
420        let tmp = TempDir::new().unwrap();
421        fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), VALID_A).unwrap();
422        assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
423    }
424
425    #[test]
426    fn read_returns_none_when_marker_oversized() {
427        // Marker lives in a user-writable directory — guard against a giant
428        // file OOMing the app. Anything past MARKER_MAX_BYTES is treated as
429        // corrupt.
430        let tmp = TempDir::new().unwrap();
431        let payload = vec![b'x'; (MARKER_MAX_BYTES + 1024) as usize];
432        fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), &payload).unwrap();
433        assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
434    }
435
436    #[test]
437    fn read_trims_whitespace() {
438        let tmp = TempDir::new().unwrap();
439        touch_account_dir(tmp.path(), VALID_A);
440        fs::write(
441            tmp.path().join(ACTIVE_ACCOUNT_FILE),
442            format!("  {}\n", VALID_A),
443        ).unwrap();
444        assert_eq!(
445            read_active_account_file_in(tmp.path()).unwrap(),
446            Some(VALID_A.to_string())
447        );
448    }
449
450    #[test]
451    fn read_handles_crlf_line_endings() {
452        let tmp = TempDir::new().unwrap();
453        touch_account_dir(tmp.path(), VALID_A);
454        fs::write(
455            tmp.path().join(ACTIVE_ACCOUNT_FILE),
456            format!("{}\r\n", VALID_A),
457        ).unwrap();
458        assert_eq!(
459            read_active_account_file_in(tmp.path()).unwrap(),
460            Some(VALID_A.to_string())
461        );
462    }
463
464    #[test]
465    fn npub_validator_rejects_uppercase_prefix() {
466        let upper = format!("NPUB1{}", &VALID_A[5..]);
467        assert!(!is_valid_npub(&upper));
468    }
469
470    #[test]
471    fn write_then_read_round_trips_with_newline() {
472        // Belt-and-braces check: confirms our own writer (which appends \n)
473        // round-trips through our own reader (which trims) with no surprises.
474        let tmp = TempDir::new().unwrap();
475        touch_account_dir(tmp.path(), VALID_A);
476        write_active_account_file_in(tmp.path(), VALID_A).unwrap();
477
478        let raw = fs::read_to_string(tmp.path().join(ACTIVE_ACCOUNT_FILE)).unwrap();
479        assert!(raw.ends_with('\n'));
480
481        assert_eq!(
482            read_active_account_file_in(tmp.path()).unwrap(),
483            Some(VALID_A.to_string())
484        );
485    }
486
487    #[test]
488    fn write_overwrites_previous_marker_atomically() {
489        let tmp = TempDir::new().unwrap();
490        touch_account_dir(tmp.path(), VALID_A);
491        touch_account_dir(tmp.path(), VALID_B);
492
493        write_active_account_file_in(tmp.path(), VALID_A).unwrap();
494        write_active_account_file_in(tmp.path(), VALID_B).unwrap();
495
496        assert_eq!(
497            read_active_account_file_in(tmp.path()).unwrap(),
498            Some(VALID_B.to_string())
499        );
500        // The temp file used for atomic rename should not linger.
501        assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
502    }
503
504    #[test]
505    fn clear_removes_marker_and_is_idempotent() {
506        let tmp = TempDir::new().unwrap();
507        touch_account_dir(tmp.path(), VALID_A);
508        write_active_account_file_in(tmp.path(), VALID_A).unwrap();
509        assert!(tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
510
511        clear_active_account_file_in(tmp.path()).unwrap();
512        assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
513
514        // Calling clear again on an already-clean state must not error.
515        clear_active_account_file_in(tmp.path()).unwrap();
516    }
517
518    #[test]
519    fn list_npubs_finds_valid_dirs_only() {
520        let tmp = TempDir::new().unwrap();
521        touch_account_dir(tmp.path(), VALID_A);
522        touch_account_dir(tmp.path(), VALID_B);
523        // Decoys: stray dirs and files that must NOT be picked up.
524        fs::create_dir_all(tmp.path().join("npub1tooshort")).unwrap();
525        fs::create_dir_all(tmp.path().join("not-an-npub-dir")).unwrap();
526        fs::create_dir_all(tmp.path().join("tor")).unwrap();
527        fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), VALID_A).unwrap();
528
529        let mut found = list_account_npubs_in(tmp.path());
530        found.sort();
531        let mut expected = vec![VALID_A.to_string(), VALID_B.to_string()];
532        expected.sort();
533        assert_eq!(found, expected);
534    }
535
536    #[test]
537    fn list_npubs_skips_dirs_containing_invalid_chars() {
538        let tmp = TempDir::new().unwrap();
539        // Insert a 'b', 'i', 'o', or '1' into the body — invalid bech32 chars.
540        let mut bogus = String::from(VALID_A);
541        bogus.replace_range(10..11, "b");
542        fs::create_dir_all(tmp.path().join(&bogus)).unwrap();
543
544        let found = list_account_npubs_in(tmp.path());
545        assert!(found.is_empty(), "found unexpected entries: {:?}", found);
546    }
547
548    #[test]
549    fn write_creates_app_data_dir_if_missing() {
550        let tmp = TempDir::new().unwrap();
551        let nested = tmp.path().join("does/not/exist/yet");
552        // Parent app_data is auto-created by mkdir_all; the account dir must
553        // also exist by the time we write, so the marker can't end up
554        // pointing at a non-existent account.
555        std::fs::create_dir_all(&nested).unwrap();
556        touch_account_dir(&nested, VALID_A);
557        write_active_account_file_in(&nested, VALID_A).unwrap();
558        assert!(nested.join(ACTIVE_ACCOUNT_FILE).exists());
559    }
560}
561
562// pending-account accessors removed — see comment above the static decl.
563// All callers use `src-tauri::account_manager::{get,set,clear}_pending_account`.
564
565// ============================================================================
566// Connection Pools
567// ============================================================================
568
569static DB_READ_POOL: LazyLock<Arc<Mutex<Vec<rusqlite::Connection>>>> =
570    LazyLock::new(|| Arc::new(Mutex::new(Vec::new())));
571
572static DB_WRITE_CONN: LazyLock<Arc<Mutex<Option<rusqlite::Connection>>>> =
573    LazyLock::new(|| Arc::new(Mutex::new(None)));
574
575/// Monotonic generation counter for the connection pool.
576///
577/// Every guard captures the current value at construction and compares
578/// on `Drop` — mismatch means the pool was reset (account switch /
579/// `close_database`) and the connection MUST be dropped instead of
580/// returned. Without this, an in-flight guard from account A could
581/// re-enter the pool after account B has initialized, causing account
582/// B's queries to silently run against account A's database.
583///
584/// Bumped by both `close_database()` and `init_database()`, so a swap
585/// (close → init) advances twice; either bump alone invalidates
586/// outstanding guards.
587static POOL_GENERATION: AtomicU64 = AtomicU64::new(0);
588
589#[inline]
590fn current_pool_generation() -> u64 {
591    POOL_GENERATION.load(Ordering::Acquire)
592}
593
594#[inline]
595fn bump_pool_generation() -> u64 {
596    // fetch_add returns the previous value; the new generation is +1.
597    POOL_GENERATION.fetch_add(1, Ordering::AcqRel).wrapping_add(1)
598}
599
600/// RAII guard for READ connections — auto-returns to pool on drop.
601pub struct ConnectionGuard {
602    conn: Option<rusqlite::Connection>,
603    generation: u64,
604}
605
606impl ConnectionGuard {
607    fn new(conn: rusqlite::Connection, generation: u64) -> Self {
608        Self { conn: Some(conn), generation }
609    }
610}
611
612impl Deref for ConnectionGuard {
613    type Target = rusqlite::Connection;
614    fn deref(&self) -> &Self::Target { self.conn.as_ref().expect("Connection already taken") }
615}
616
617impl DerefMut for ConnectionGuard {
618    fn deref_mut(&mut self) -> &mut Self::Target { self.conn.as_mut().expect("Connection already taken") }
619}
620
621impl Drop for ConnectionGuard {
622    fn drop(&mut self) {
623        if let Some(conn) = self.conn.take() {
624            // Only return to pool if our generation still matches —
625            // otherwise the pool was reset mid-flight and pushing back
626            // would let account A's connection serve account B's queries.
627            if self.generation == current_pool_generation() {
628                if let Ok(mut pool) = DB_READ_POOL.lock() {
629                    pool.push(conn);
630                }
631            }
632        }
633    }
634}
635
636/// RAII guard for the WRITE connection — auto-returns on drop.
637pub struct WriteConnectionGuard {
638    conn: Option<rusqlite::Connection>,
639    generation: u64,
640}
641
642impl WriteConnectionGuard {
643    fn new(conn: rusqlite::Connection, generation: u64) -> Self {
644        Self { conn: Some(conn), generation }
645    }
646}
647
648impl Deref for WriteConnectionGuard {
649    type Target = rusqlite::Connection;
650    fn deref(&self) -> &Self::Target { self.conn.as_ref().expect("Write connection already taken") }
651}
652
653impl DerefMut for WriteConnectionGuard {
654    fn deref_mut(&mut self) -> &mut Self::Target { self.conn.as_mut().expect("Write connection already taken") }
655}
656
657impl Drop for WriteConnectionGuard {
658    fn drop(&mut self) {
659        if let Some(conn) = self.conn.take() {
660            // Same generation gate as ConnectionGuard, plus a slot-empty
661            // check: if `init_database` already installed a fresh write
662            // connection for the new account, dropping ours over the top
663            // would clobber it.
664            if self.generation == current_pool_generation() {
665                if let Ok(mut slot) = DB_WRITE_CONN.lock() {
666                    if slot.is_none() {
667                        *slot = Some(conn);
668                    }
669                }
670            }
671        }
672    }
673}
674
675// ============================================================================
676// Connection Factory
677// ============================================================================
678
679/// Single source of truth for per-account directories. Every per-account
680/// subsystem (DB, Tor state) resolves its path through this;
681/// compose further subpaths with `.join(...)` — never insert layers
682/// between `<app_data>` and `<npub>`.
683///
684/// Validates npub format before joining as defence-in-depth against
685/// path traversal: a crafted IPC input like `"../../etc"` would
686/// otherwise yield `<app_data>/../../etc` and downstream
687/// `remove_dir_all` (delete_account, logout) would walk arbitrary dirs.
688pub fn account_dir(npub: &str) -> Result<PathBuf, String> {
689    if !is_valid_npub(npub) {
690        return Err(format!("Invalid npub format: {}", npub));
691    }
692    Ok(get_app_data_dir()?.join(npub))
693}
694
695fn get_current_db_path() -> Result<PathBuf, String> {
696    let npub = get_current_account()?;
697    Ok(account_dir(&npub)?.join("vector.db"))
698}
699
700fn create_connection(path: &PathBuf) -> Result<rusqlite::Connection, String> {
701    let conn = rusqlite::Connection::open(path)
702        .map_err(|e| format!("Failed to open database: {}", e))?;
703
704    // WAL mode for concurrent reads, busy_timeout for lock contention
705    conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000; PRAGMA cache_size=-1000;")
706        .map_err(|e| format!("Failed to set pragmas: {}", e))?;
707
708    Ok(conn)
709}
710
711/// Get a READ connection (headless-safe — no AppHandle).
712pub fn get_db_connection_guard_static() -> Result<ConnectionGuard, String> {
713    let generation = current_pool_generation();
714    // Try to get from pool first
715    if let Ok(mut pool) = DB_READ_POOL.lock() {
716        if let Some(conn) = pool.pop() {
717            return Ok(ConnectionGuard::new(conn, generation));
718        }
719    }
720    // Create new connection
721    let path = get_current_db_path()?;
722    let conn = create_connection(&path)?;
723    Ok(ConnectionGuard::new(conn, generation))
724}
725
726/// Process-wide serialization lock for tests that install into the global DB pool.
727/// Any test calling `init_database` must hold this for its whole body — otherwise
728/// concurrent inits race on `POOL_GENERATION` and clobber each other's connections.
729/// One shared guard across every module (community, ...) so cross-module test
730/// parallelism can't collide.
731#[cfg(test)]
732pub(crate) static DB_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
733
734/// Get the WRITE connection (headless-safe — no AppHandle).
735pub fn get_write_connection_guard_static() -> Result<WriteConnectionGuard, String> {
736    let generation = current_pool_generation();
737    let mut write_slot = DB_WRITE_CONN.lock().unwrap();
738    if let Some(conn) = write_slot.take() {
739        return Ok(WriteConnectionGuard::new(conn, generation));
740    }
741    drop(write_slot);
742
743    let path = get_current_db_path()?;
744    let conn = create_connection(&path)?;
745    Ok(WriteConnectionGuard::new(conn, generation))
746}
747
748// ============================================================================
749// Database Initialization
750// ============================================================================
751
752/// Initialize the database for a given account (creates tables if needed).
753pub fn init_database(npub: &str) -> Result<(), String> {
754    let profile_dir = account_dir(npub)?;
755
756    if !profile_dir.exists() {
757        std::fs::create_dir_all(&profile_dir)
758            .map_err(|e| format!("Failed to create profile directory: {}", e))?;
759    }
760
761    let db_path = profile_dir.join("vector.db");
762    let mut conn = create_connection(&db_path)?;
763    conn.execute_batch(schema::SQL_SCHEMA)
764        .map_err(|e| format!("Failed to create schema: {}", e))?;
765
766    // Run migrations
767    schema::run_migrations(&mut conn)?;
768
769    // MLS is fully removed. Migration 41 drops the relational tables, but the OpenMLS/MDK
770    // crypto store lived in a SEPARATE per-account file (`<account>/mls/`) that no migration
771    // can reach. Purge it here: it's dead weight (can run to hundreds of MB) and, worse,
772    // stale MLS private key material lingering for a feature that no longer exists. Best-effort
773    // and idempotent — a cleanup failure must never block account init.
774    let mls_dir = profile_dir.join("mls");
775    if mls_dir.exists() {
776        match std::fs::remove_dir_all(&mls_dir) {
777            Ok(()) => crate::log_info!("[db] purged orphaned MLS store for account"),
778            Err(e) => crate::log_warn!("[db] could not purge orphaned MLS store: {}", e),
779        }
780    }
781
782    // Bump BEFORE installing the new pool so any in-flight guards from
783    // the previous account fail their Drop check and don't pollute the
784    // freshly-initialized pool.
785    bump_pool_generation();
786
787    // Pre-warm read pool
788    if let Ok(mut pool) = DB_READ_POOL.lock() {
789        pool.clear();
790        for _ in 0..4 {
791            if let Ok(c) = create_connection(&db_path) {
792                pool.push(c);
793            }
794        }
795    }
796
797    // Set write connection
798    let write_conn = create_connection(&db_path)?;
799    *DB_WRITE_CONN.lock().unwrap() = Some(write_conn);
800
801    // Hydrate Tor's hot-path settings cache directly from `db_path`,
802    // NOT via `get_sql_setting()` — the global helper resolves through
803    // the read pool + `get_current_account()`, neither of which yet
804    // reflects this account (switch_account calls init_database BEFORE
805    // set_current_account).
806    #[cfg(feature = "tor")]
807    {
808        let enabled = create_connection(&db_path)
809            .ok()
810            .and_then(|c| {
811                c.query_row(
812                    "SELECT value FROM settings WHERE key = 'tor_enabled'",
813                    [],
814                    |row| row.get::<_, String>(0),
815                )
816                .ok()
817            })
818            .map(|v| v == "1" || v == "true")
819            .unwrap_or(false);
820        crate::tor::set_tor_enabled_pref(enabled);
821    }
822
823    Ok(())
824}
825
826/// Close all database connections (for logout / account switch).
827/// Bumps `POOL_GENERATION` first so in-flight guards fail their Drop
828/// check and discard the connection instead of returning it to the
829/// (now-cleared) pool.
830pub fn close_database() {
831    bump_pool_generation();
832    if let Ok(mut pool) = DB_READ_POOL.lock() {
833        pool.clear();
834    }
835    *DB_WRITE_CONN.lock().unwrap() = None;
836}
837
838/// Get all available accounts (npub directories in app data).
839pub fn get_accounts() -> Result<Vec<String>, String> {
840    let app_data = get_app_data_dir()?;
841    let mut accounts = Vec::new();
842
843    if let Ok(entries) = std::fs::read_dir(app_data) {
844        for entry in entries.flatten() {
845            if entry.file_type().map_or(false, |ft| ft.is_dir()) {
846                let name = entry.file_name().to_string_lossy().to_string();
847                if name.starts_with("npub1") {
848                    // Check if vector.db exists
849                    if entry.path().join("vector.db").exists() {
850                        accounts.push(name);
851                    }
852                }
853            }
854        }
855    }
856
857    Ok(accounts)
858}
859
860/// Get the profile directory path for a given npub.
861pub fn get_profile_directory(npub: &str) -> Result<PathBuf, String> {
862    if !npub.starts_with("npub1") {
863        return Err(format!("Invalid npub format: {}", npub));
864    }
865    let dir = account_dir(npub)?;
866    if !dir.exists() {
867        std::fs::create_dir_all(&dir)
868            .map_err(|e| format!("Failed to create profile directory: {}", e))?;
869    }
870    Ok(dir)
871}
872
873/// Get database path for a given npub.
874pub fn get_database_path(npub: &str) -> Result<PathBuf, String> {
875    Ok(get_profile_directory(npub)?.join("vector.db"))
876}
877
878// ============================================================================
879// ID Caches
880// ============================================================================
881
882static CHAT_ID_CACHE: LazyLock<Arc<RwLock<HashMap<String, i64>>>> =
883    LazyLock::new(|| Arc::new(RwLock::new(HashMap::new())));
884
885static USER_ID_CACHE: LazyLock<Arc<RwLock<HashMap<String, i64>>>> =
886    LazyLock::new(|| Arc::new(RwLock::new(HashMap::new())));
887
888pub fn clear_id_caches() {
889    CHAT_ID_CACHE.write().unwrap().clear();
890    USER_ID_CACHE.write().unwrap().clear();
891    // ALSO clear the parallel caches in `id_cache` — that's the pair `db::events::save_message` and the
892    // community member-activity read actually use. Chat/user row ids are PER-ACCOUNT (each account has its
893    // own DB + id sequence), so a stale entry after an account swap points into the wrong DB → writes
894    // FK-fail (silently) and reads hit the wrong/empty row. One public clear must wipe every id cache.
895    id_cache::clear_id_caches();
896}
897
898/// Get or create a chat_id integer for a chat identifier string.
899pub fn get_or_create_chat_id(conn: &rusqlite::Connection, identifier: &str, chat_type: i32) -> Result<i64, String> {
900    // Check cache first
901    if let Some(&id) = CHAT_ID_CACHE.read().unwrap().get(identifier) {
902        return Ok(id);
903    }
904
905    // Try to find existing
906    let result: Option<i64> = conn.query_row(
907        "SELECT id FROM chats WHERE chat_identifier = ?1",
908        rusqlite::params![identifier],
909        |row| row.get(0),
910    ).ok();
911
912    if let Some(id) = result {
913        CHAT_ID_CACHE.write().unwrap().insert(identifier.to_string(), id);
914        return Ok(id);
915    }
916
917    // Create new
918    let now = std::time::SystemTime::now()
919        .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
920    conn.execute(
921        "INSERT INTO chats (chat_identifier, chat_type, participants, created_at) VALUES (?1, ?2, '', ?3)",
922        rusqlite::params![identifier, chat_type, now],
923    ).map_err(|e| format!("Failed to create chat: {}", e))?;
924
925    let id = conn.last_insert_rowid();
926    CHAT_ID_CACHE.write().unwrap().insert(identifier.to_string(), id);
927    Ok(id)
928}
929
930/// Get or create a user_id integer for an npub.
931pub fn get_or_create_user_id(conn: &rusqlite::Connection, npub: &str) -> Result<i64, String> {
932    if let Some(&id) = USER_ID_CACHE.read().unwrap().get(npub) {
933        return Ok(id);
934    }
935
936    let result: Option<i64> = conn.query_row(
937        "SELECT id FROM profiles WHERE npub = ?1",
938        rusqlite::params![npub],
939        |row| row.get(0),
940    ).ok();
941
942    if let Some(id) = result {
943        USER_ID_CACHE.write().unwrap().insert(npub.to_string(), id);
944        return Ok(id);
945    }
946
947    conn.execute(
948        "INSERT OR IGNORE INTO profiles (npub) VALUES (?1)",
949        rusqlite::params![npub],
950    ).map_err(|e| format!("Failed to create user: {}", e))?;
951
952    let id = conn.query_row(
953        "SELECT id FROM profiles WHERE npub = ?1",
954        rusqlite::params![npub],
955        |row| row.get(0),
956    ).map_err(|e| format!("Failed to get user id: {}", e))?;
957
958    USER_ID_CACHE.write().unwrap().insert(npub.to_string(), id);
959    Ok(id)
960}
961
962// ============================================================================
963// System Event Types
964// ============================================================================
965
966#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
967#[repr(u8)]
968pub enum SystemEventType {
969    MemberLeft = 0,
970    MemberJoined = 1,
971    MemberRemoved = 2,
972    WallpaperChanged = 3,
973}
974
975impl SystemEventType {
976    pub fn display_message(&self, display_name: &str) -> String {
977        match self {
978            SystemEventType::MemberLeft => format!("{} has left", display_name),
979            SystemEventType::MemberJoined => format!("{} has joined", display_name),
980            SystemEventType::MemberRemoved => format!("{} was removed", display_name),
981            SystemEventType::WallpaperChanged => format!("{} changed the wallpaper", display_name),
982        }
983    }
984
985    pub fn as_u8(&self) -> u8 { *self as u8 }
986}
987
988#[cfg(test)]
989mod pool_generation_tests {
990    use super::*;
991    use tempfile::TempDir;
992
993    /// Build a minimal in-memory SQLite connection — just enough to drop
994    /// a connection through the guard machinery. We don't run schema or
995    /// migrations because we only care about the guard's Drop pathway.
996    fn fake_conn() -> rusqlite::Connection {
997        rusqlite::Connection::open_in_memory().unwrap()
998    }
999
1000    #[test]
1001    fn close_database_bumps_generation() {
1002        let before = current_pool_generation();
1003        close_database();
1004        let after = current_pool_generation();
1005        assert!(after > before, "close_database must advance POOL_GENERATION");
1006    }
1007
1008    #[test]
1009    fn init_database_bumps_generation() {
1010        // init_database requires APP_DATA_DIR to be set; we don't fully exercise
1011        // it here (would need schema/migrations). The cheaper invariant we test
1012        // is that bump_pool_generation itself advances the counter — which is
1013        // what init_database does at the top of its body.
1014        let before = current_pool_generation();
1015        let bumped = bump_pool_generation();
1016        assert_eq!(bumped, before.wrapping_add(1));
1017        assert_eq!(current_pool_generation(), bumped);
1018    }
1019
1020    #[test]
1021    fn stale_read_guard_does_not_return_to_pool_after_generation_bump() {
1022        // Snapshot the pool generation, construct a guard at that generation,
1023        // bump the generation (simulating a swap), then drop the guard.
1024        // The drop must NOT push back into the pool.
1025        let _tmp = TempDir::new().unwrap(); // keeps any side-effects scoped
1026
1027        // Drain whatever happens to be in the pool to start from a known state.
1028        let pool_size_before = DB_READ_POOL.lock().unwrap().len();
1029
1030        let stale_generation = current_pool_generation();
1031        let guard = ConnectionGuard::new(fake_conn(), stale_generation);
1032
1033        // Account swap: bump generation invalidates outstanding guards.
1034        bump_pool_generation();
1035
1036        drop(guard);
1037
1038        let pool_size_after = DB_READ_POOL.lock().unwrap().len();
1039        assert_eq!(
1040            pool_size_after, pool_size_before,
1041            "stale read guard must not re-enter the pool"
1042        );
1043    }
1044
1045    #[test]
1046    fn fresh_read_guard_returns_to_pool() {
1047        let pool_size_before = DB_READ_POOL.lock().unwrap().len();
1048
1049        let generation = current_pool_generation();
1050        let guard = ConnectionGuard::new(fake_conn(), generation);
1051
1052        // No generation bump — guard is still valid.
1053        drop(guard);
1054
1055        let pool_size_after = DB_READ_POOL.lock().unwrap().len();
1056        assert_eq!(
1057            pool_size_after,
1058            pool_size_before + 1,
1059            "fresh read guard should be returned to the pool"
1060        );
1061
1062        // Cleanup: drain the connection we just pushed so we don't pollute
1063        // sibling tests sharing the global.
1064        DB_READ_POOL.lock().unwrap().pop();
1065    }
1066
1067    #[test]
1068    fn stale_write_guard_does_not_overwrite_fresh_slot() {
1069        // The dropped stale guard must not clobber a write connection that
1070        // init_database has freshly installed for the new account.
1071        let stale_generation = current_pool_generation();
1072        let stale_guard = WriteConnectionGuard::new(fake_conn(), stale_generation);
1073
1074        bump_pool_generation();
1075
1076        // Simulate init_database installing a new write connection.
1077        let fresh_conn = fake_conn();
1078        *DB_WRITE_CONN.lock().unwrap() = Some(fresh_conn);
1079
1080        drop(stale_guard);
1081
1082        // The slot must still hold the fresh connection, not be overwritten
1083        // by the stale guard's drop.
1084        assert!(
1085            DB_WRITE_CONN.lock().unwrap().is_some(),
1086            "write slot must keep the freshly installed connection"
1087        );
1088
1089        // Cleanup.
1090        *DB_WRITE_CONN.lock().unwrap() = None;
1091    }
1092
1093    #[test]
1094    fn stale_write_guard_does_not_fill_empty_slot() {
1095        // Even if the write slot is empty (e.g., reset just happened and
1096        // the new account hasn't initialized yet), a stale guard from the
1097        // previous account must NOT fill it — that connection points at a
1098        // different DB.
1099        let stale_generation = current_pool_generation();
1100        let stale_guard = WriteConnectionGuard::new(fake_conn(), stale_generation);
1101
1102        bump_pool_generation();
1103        *DB_WRITE_CONN.lock().unwrap() = None;
1104
1105        drop(stale_guard);
1106
1107        assert!(
1108            DB_WRITE_CONN.lock().unwrap().is_none(),
1109            "stale write guard must not fill an empty slot"
1110        );
1111    }
1112}