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