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