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::ops::{Deref, DerefMut};
13
14use serde::{Deserialize, Serialize};
15
16pub mod settings;
17pub mod schema;
18pub mod profiles;
19pub mod id_cache;
20pub mod events;
21pub mod attachments;
22pub mod chats;
23pub mod wrappers;
24pub mod nip17_keys;
25pub mod community;
26pub mod bots;
27
28pub use settings::{
29    get_sql_setting, set_sql_setting, advance_u64_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    get_nip55_user_pubkey, set_nip55_user_pubkey,
35    get_nip55_signer_package, set_nip55_signer_package,
36    commit_nip55_account_setup,
37};
38
39// ============================================================================
40// App Data Directory
41// ============================================================================
42
43static APP_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
44
45pub fn set_app_data_dir(path: PathBuf) {
46    let _ = APP_DATA_DIR.set(path);
47}
48
49/// The data dir every test must use — ONE per process, alive for its whole life.
50///
51/// `APP_DATA_DIR` is set-once, so in a test binary only the first
52/// `set_app_data_dir` ever takes effect: every later helper silently kept using
53/// the FIRST test's `TempDir`, which was deleted the moment that test returned,
54/// leaving the rest to build account dirs under a path that no longer existed.
55/// Which test drew the short straw depended on thread scheduling, so the failures
56/// wandered. Tests already isolate by account subdirectory, so sharing the root is
57/// safe; what they cannot share is a lifetime owned by one of them.
58#[cfg(test)]
59pub(crate) fn shared_test_data_dir() -> &'static std::path::Path {
60    static DIR: OnceLock<tempfile::TempDir> = OnceLock::new();
61    // Never dropped: process-lifetime by construction, so no test can pull it out
62    // from under another. The OS reclaims it after the run.
63    DIR.get_or_init(|| tempfile::tempdir().expect("test data dir")).path()
64}
65
66pub fn get_app_data_dir() -> Result<&'static PathBuf, String> {
67    APP_DATA_DIR.get().ok_or_else(|| "App data directory not initialized".to_string())
68}
69
70/// Host app version, stamped into each account on open so a later downgrade can
71/// name what wrote the schema. Optional: the downgrade guard keys off the
72/// migration high-water mark, never this.
73static APP_VERSION: OnceLock<String> = OnceLock::new();
74
75pub fn set_app_version(version: impl Into<String>) {
76    let _ = APP_VERSION.set(version.into());
77}
78
79/// Host-installed override for the download directory. Tauri sets this
80/// at boot via `set_download_dir()` so platform conventions (XDG on
81/// Linux, Known Folders on Windows) are honored. Headless callers
82/// (vector-agent CLI, tests) fall through to the env-var path.
83static DOWNLOAD_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
84
85/// Install the host-resolved download directory. Must be called at
86/// startup before any `get_download_dir()` consumer runs; callers that
87/// run earlier hit the fallback.
88pub fn set_download_dir(path: PathBuf) {
89    let _ = DOWNLOAD_DIR_OVERRIDE.set(path);
90}
91
92/// Platform-appropriate download directory for file attachments.
93///
94/// Prefers the host-installed override (honors `xdg-user-dirs`,
95/// `FOLDERID_Downloads`, `NSDownloadsDirectory`, `NSDocumentDirectory`).
96/// Falls back to `$HOME/Downloads/vector` on desktop, then
97/// `<app_data>/vector_downloads` on mobile / pre-init.
98pub fn get_download_dir() -> PathBuf {
99    if let Some(installed) = DOWNLOAD_DIR_OVERRIDE.get() {
100        return installed.clone();
101    }
102    #[cfg(any(target_os = "macos", target_os = "linux"))]
103    {
104        if let Ok(home) = std::env::var("HOME") {
105            return PathBuf::from(home).join("Downloads/vector");
106        }
107    }
108    #[cfg(target_os = "windows")]
109    {
110        if let Ok(profile) = std::env::var("USERPROFILE") {
111            return PathBuf::from(profile).join("Downloads").join("vector");
112        }
113    }
114    // Mobile / fallback: use data dir
115    if let Ok(data_dir) = get_app_data_dir() {
116        return data_dir.join("vector_downloads");
117    }
118    PathBuf::from("/tmp/vector_downloads")
119}
120
121// ============================================================================
122// Current Account
123// ============================================================================
124
125static CURRENT_ACCOUNT: LazyLock<Arc<RwLock<Option<String>>>> = LazyLock::new(|| Arc::new(RwLock::new(None)));
126// PENDING_ACCOUNT lives exclusively in src-tauri's account_manager —
127// any "pending account" check must go through that crate, not here.
128
129/// Filename for the persistent active-account marker. Plain text, just the npub.
130const ACTIVE_ACCOUNT_FILE: &str = "active_account";
131
132/// npub bech32 form: `npub1` + 58 chars from the bech32 alphabet (no `1`, `b`, `i`, `o`).
133fn is_valid_npub(s: &str) -> bool {
134    if s.len() != 63 || !s.starts_with("npub1") {
135        return false;
136    }
137    s.bytes().skip(5).all(|c| matches!(c,
138        b'q' | b'p' | b'z' | b'r' | b'y' | b'9' | b'x' | b'8' |
139        b'g' | b'f' | b'2' | b't' | b'v' | b'd' | b'w' | b'0' |
140        b's' | b'3' | b'j' | b'n' | b'5' | b'4' | b'k' | b'h' |
141        b'c' | b'e' | b'6' | b'm' | b'u' | b'a' | b'7' | b'l'
142    ))
143}
144
145pub fn get_current_account() -> Result<String, String> {
146    CURRENT_ACCOUNT.read().unwrap()
147        .as_ref().cloned()
148        .ok_or_else(|| "No active account".to_string())
149}
150
151/// Set the currently-active npub for THIS process AND persist it to the
152/// `<app_data>/active_account` marker so the next boot picks the same account.
153///
154/// Every call site asserts user intent ("this account is now active"); the
155/// marker write is idempotent and gracefully no-ops when `APP_DATA_DIR` is
156/// not yet configured (e.g. during in-process unit tests).
157pub fn set_current_account(npub: String) -> Result<(), String> {
158    *CURRENT_ACCOUNT.write().unwrap() = Some(npub.clone());
159    let _ = write_active_account_file(&npub);
160    Ok(())
161}
162
163/// Clear the in-memory active account WITHOUT touching the on-disk marker.
164/// Used by `reset_session()` so the next-boot marker stays intact while
165/// in-process state is torn down for an inline account swap.
166pub fn clear_current_account_in_memory() {
167    *CURRENT_ACCOUNT.write().unwrap() = None;
168}
169
170/// Read the active-account marker file. Returns the stored npub if it exists,
171/// is well-formed, AND the corresponding account directory still exists.
172/// Any failure path returns Ok(None) so boot falls back to single-account or picker.
173pub fn read_active_account_file() -> Result<Option<String>, String> {
174    let app_data = match get_app_data_dir() {
175        Ok(p) => p,
176        Err(_) => return Ok(None),
177    };
178    read_active_account_file_in(app_data)
179}
180
181/// Atomic write of the active-account marker (temp + rename).
182pub fn write_active_account_file(npub: &str) -> Result<(), String> {
183    let app_data = get_app_data_dir()?.clone();
184    write_active_account_file_in(&app_data, npub)
185}
186
187/// Remove the active-account marker. Used after deleting the active account.
188pub fn clear_active_account_file() -> Result<(), String> {
189    let app_data = get_app_data_dir()?;
190    clear_active_account_file_in(app_data)
191}
192
193/// Scan the app data directory for valid npub directories. Strict bech32 regex
194/// rejects typos and stray subdirectories. Does NOT validate that each account
195/// has a usable database — callers do that separately.
196pub fn list_account_npubs() -> Result<Vec<String>, String> {
197    let app_data = get_app_data_dir()?;
198    Ok(list_account_npubs_in(app_data))
199}
200
201// ----- path-parameterized internals (kept private so tests can inject a temp dir) -----
202
203/// Bound on bytes read from the active-account marker. A valid marker
204/// is 63 bytes (canonical npub) plus optional trailing newline. The
205/// marker lives in a user-writable dir, so accidental / malicious
206/// multi-gigabyte writes are a realistic OOM vector if read unbounded.
207const MARKER_MAX_BYTES: u64 = 256;
208
209fn read_active_account_file_in(app_data: &std::path::Path) -> Result<Option<String>, String> {
210    use std::io::Read;
211
212    let path = app_data.join(ACTIVE_ACCOUNT_FILE);
213    if !path.exists() {
214        return Ok(None);
215    }
216    // Pre-check size, then belt-and-suspenders cap via `take()` to
217    // cover the TOCTOU window between metadata and open. Metadata
218    // failures fail-safe to "missing".
219    if let Ok(meta) = std::fs::metadata(&path) {
220        if meta.len() > MARKER_MAX_BYTES {
221            return Ok(None);
222        }
223    } else {
224        return Ok(None);
225    }
226    let mut buf = String::new();
227    let file = match std::fs::File::open(&path) {
228        Ok(f) => f,
229        Err(_) => return Ok(None),
230    };
231    if file.take(MARKER_MAX_BYTES).read_to_string(&mut buf).is_err() {
232        return Ok(None);
233    }
234    let npub = buf.trim().to_string();
235    if !is_valid_npub(&npub) {
236        return Ok(None);
237    }
238    // `symlink_metadata` instead of `is_dir()` (which follows links): a
239    // crafted symlink at `<app_data>/<valid-npub-name>` pointing at
240    // `~/Documents` etc. would otherwise pass, and downstream
241    // `remove_dir_all` in delete_account / logout would traverse it.
242    // Bech32 validation alone is insufficient — the attacker controls
243    // the filename, not the npub semantic.
244    match std::fs::symlink_metadata(app_data.join(&npub)) {
245        Ok(meta) if meta.file_type().is_dir() && !meta.file_type().is_symlink() => {}
246        _ => return Ok(None),
247    }
248    Ok(Some(npub))
249}
250
251fn write_active_account_file_in(app_data: &std::path::Path, npub: &str) -> Result<(), String> {
252    if !is_valid_npub(npub) {
253        return Err(format!("Invalid npub format: {}", npub));
254    }
255    if !app_data.exists() {
256        std::fs::create_dir_all(app_data)
257            .map_err(|e| format!("Failed to create app data dir: {}", e))?;
258    }
259    // Refuse to point the marker at a directory that doesn't exist as a
260    // real subfolder. Closes the race where a concurrent `delete_account`
261    // for `npub` runs between the caller's existence check and this write.
262    // `symlink_metadata` (matching the read path) so a crafted
263    // `<app_data>/<valid-npub-name>` symlink can't satisfy the check.
264    match std::fs::symlink_metadata(app_data.join(npub)) {
265        Ok(meta) if meta.file_type().is_dir() && !meta.file_type().is_symlink() => {}
266        _ => return Err(format!("Account directory missing or invalid: {}", npub)),
267    }
268    let tmp = app_data.join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE));
269    let final_path = app_data.join(ACTIVE_ACCOUNT_FILE);
270
271    // Trailing newline so `cat` doesn't mangle the shell prompt and so editors
272    // that auto-strip trailing newlines don't dirty-mark the file on save.
273    let mut payload = String::with_capacity(npub.len() + 1);
274    payload.push_str(npub);
275    payload.push('\n');
276
277    if let Err(e) = std::fs::write(&tmp, payload.as_bytes()) {
278        let _ = std::fs::remove_file(&tmp);
279        return Err(format!("Failed to write active account temp file: {}", e));
280    }
281
282    // Retry the rename a few times. On Windows, transient antivirus or backup
283    // scans can hold a brief sharing-violation lock on the destination file.
284    let mut last_err = None;
285    for attempt in 0..3 {
286        match std::fs::rename(&tmp, &final_path) {
287            Ok(_) => return Ok(()),
288            Err(e) => {
289                last_err = Some(e);
290                if attempt < 2 {
291                    std::thread::sleep(std::time::Duration::from_millis(50));
292                }
293            }
294        }
295    }
296
297    // Don't leave the temp file behind if every attempt failed.
298    let _ = std::fs::remove_file(&tmp);
299    Err(format!(
300        "Failed to rename active account file: {}",
301        last_err.map(|e| e.to_string()).unwrap_or_default()
302    ))
303}
304
305fn clear_active_account_file_in(app_data: &std::path::Path) -> Result<(), String> {
306    let path = app_data.join(ACTIVE_ACCOUNT_FILE);
307    if path.exists() {
308        std::fs::remove_file(&path)
309            .map_err(|e| format!("Failed to remove active account file: {}", e))?;
310    }
311    Ok(())
312}
313
314fn list_account_npubs_in(app_data: &std::path::Path) -> Vec<String> {
315    let mut out = Vec::new();
316    if let Ok(entries) = std::fs::read_dir(app_data) {
317        for entry in entries.flatten() {
318            if entry.file_type().map_or(false, |ft| ft.is_dir()) {
319                let name = entry.file_name().to_string_lossy().to_string();
320                if is_valid_npub(&name) {
321                    out.push(name);
322                }
323            }
324        }
325    }
326    out
327}
328
329#[cfg(test)]
330mod active_account_tests {
331    use super::*;
332    use std::fs;
333    use tempfile::TempDir;
334
335    /// Real npub from the project's own test fixtures (matches bech32 regex).
336    const VALID_A: &str = "npub16ye7evyevwnl0fc9hujsxf9zym72e063awn0pvde0huvpyec5nyq4dg4wn";
337    const VALID_B: &str = "npub12w73tzcqgpr2pcy4el5x60d2emeud4cyeeayynzqgg2fefzgytaqm4ktz3";
338
339    fn touch_account_dir(base: &std::path::Path, npub: &str) {
340        fs::create_dir_all(base.join(npub)).unwrap();
341    }
342
343    #[test]
344    fn npub_validator_accepts_canonical_form() {
345        assert!(is_valid_npub(VALID_A));
346        assert!(is_valid_npub(VALID_B));
347    }
348
349    #[test]
350    fn npub_validator_rejects_wrong_length() {
351        assert!(!is_valid_npub("npub1abc"));
352        assert!(!is_valid_npub(&format!("{}x", VALID_A)));
353        assert!(!is_valid_npub(""));
354    }
355
356    #[test]
357    fn npub_validator_rejects_missing_prefix() {
358        let body = &VALID_A[5..];
359        assert!(!is_valid_npub(&format!("nsec1{}", body)));
360        assert!(!is_valid_npub(&format!("xxxx1{}", body)));
361    }
362
363    #[test]
364    fn npub_validator_rejects_non_bech32_chars() {
365        // Replace one char in the body with each disallowed bech32 letter.
366        for bad in ['1', 'b', 'i', 'o', 'B', 'I', 'O', '!', '*', ' '] {
367            let mut s = String::from(VALID_A);
368            s.replace_range(10..11, &bad.to_string());
369            assert!(!is_valid_npub(&s), "should reject character {:?}", bad);
370        }
371    }
372
373    #[test]
374    fn write_then_read_round_trips() {
375        let tmp = TempDir::new().unwrap();
376        touch_account_dir(tmp.path(), VALID_A);
377
378        write_active_account_file_in(tmp.path(), VALID_A).unwrap();
379        assert_eq!(
380            read_active_account_file_in(tmp.path()).unwrap(),
381            Some(VALID_A.to_string())
382        );
383    }
384
385    #[test]
386    fn write_rejects_invalid_npub() {
387        let tmp = TempDir::new().unwrap();
388        let err = write_active_account_file_in(tmp.path(), "npub1nope").unwrap_err();
389        assert!(err.contains("Invalid"));
390        // No file should have been created (neither final nor temp).
391        assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
392        assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
393    }
394
395    #[test]
396    fn write_rejects_missing_account_dir() {
397        // A concurrent `delete_account` between the caller's existence check
398        // and write_active_account_file would otherwise leave a stale marker
399        // pointing at a now-deleted account.
400        let tmp = TempDir::new().unwrap();
401        let err = write_active_account_file_in(tmp.path(), VALID_A).unwrap_err();
402        assert!(err.contains("missing or invalid"),
403            "expected account-dir-missing error, got: {}", err);
404        // Marker must not have been written.
405        assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
406        assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
407    }
408
409    #[test]
410    fn write_rejects_symlinked_account_dir() {
411        // A crafted `<app_data>/<valid-npub-name>` symlink to ~/Documents
412        // would otherwise pass `is_dir()` and let the marker point at an
413        // attacker-controlled location, which downstream delete/logout paths
414        // would then traverse.
415        let tmp = TempDir::new().unwrap();
416        let target = TempDir::new().unwrap();
417        let link = tmp.path().join(VALID_A);
418        #[cfg(unix)]
419        {
420            std::os::unix::fs::symlink(target.path(), &link).unwrap();
421            let err = write_active_account_file_in(tmp.path(), VALID_A).unwrap_err();
422            assert!(err.contains("missing or invalid"),
423                "expected symlink rejection, got: {}", err);
424        }
425        // On Windows symlink creation may require elevated privileges; skip
426        // the assertion there rather than gate the whole test on platform.
427        #[cfg(not(unix))]
428        let _ = (target, link);
429    }
430
431    #[test]
432    fn read_returns_none_when_marker_missing() {
433        let tmp = TempDir::new().unwrap();
434        assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
435    }
436
437    #[test]
438    fn read_returns_none_when_marker_is_garbage() {
439        let tmp = TempDir::new().unwrap();
440        fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), b"not-an-npub\n").unwrap();
441        assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
442    }
443
444    #[test]
445    fn read_returns_none_when_account_dir_missing() {
446        // Marker exists, npub is well-formed, but the account directory was
447        // deleted out from under us. Boot must fall through to picker, never crash.
448        let tmp = TempDir::new().unwrap();
449        fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), VALID_A).unwrap();
450        assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
451    }
452
453    #[test]
454    fn read_returns_none_when_marker_oversized() {
455        // Marker lives in a user-writable directory — guard against a giant
456        // file OOMing the app. Anything past MARKER_MAX_BYTES is treated as
457        // corrupt.
458        let tmp = TempDir::new().unwrap();
459        let payload = vec![b'x'; (MARKER_MAX_BYTES + 1024) as usize];
460        fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), &payload).unwrap();
461        assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
462    }
463
464    #[test]
465    fn read_trims_whitespace() {
466        let tmp = TempDir::new().unwrap();
467        touch_account_dir(tmp.path(), VALID_A);
468        fs::write(
469            tmp.path().join(ACTIVE_ACCOUNT_FILE),
470            format!("  {}\n", VALID_A),
471        ).unwrap();
472        assert_eq!(
473            read_active_account_file_in(tmp.path()).unwrap(),
474            Some(VALID_A.to_string())
475        );
476    }
477
478    #[test]
479    fn read_handles_crlf_line_endings() {
480        let tmp = TempDir::new().unwrap();
481        touch_account_dir(tmp.path(), VALID_A);
482        fs::write(
483            tmp.path().join(ACTIVE_ACCOUNT_FILE),
484            format!("{}\r\n", VALID_A),
485        ).unwrap();
486        assert_eq!(
487            read_active_account_file_in(tmp.path()).unwrap(),
488            Some(VALID_A.to_string())
489        );
490    }
491
492    #[test]
493    fn npub_validator_rejects_uppercase_prefix() {
494        let upper = format!("NPUB1{}", &VALID_A[5..]);
495        assert!(!is_valid_npub(&upper));
496    }
497
498    #[test]
499    fn write_then_read_round_trips_with_newline() {
500        // Belt-and-braces check: confirms our own writer (which appends \n)
501        // round-trips through our own reader (which trims) with no surprises.
502        let tmp = TempDir::new().unwrap();
503        touch_account_dir(tmp.path(), VALID_A);
504        write_active_account_file_in(tmp.path(), VALID_A).unwrap();
505
506        let raw = fs::read_to_string(tmp.path().join(ACTIVE_ACCOUNT_FILE)).unwrap();
507        assert!(raw.ends_with('\n'));
508
509        assert_eq!(
510            read_active_account_file_in(tmp.path()).unwrap(),
511            Some(VALID_A.to_string())
512        );
513    }
514
515    #[test]
516    fn write_overwrites_previous_marker_atomically() {
517        let tmp = TempDir::new().unwrap();
518        touch_account_dir(tmp.path(), VALID_A);
519        touch_account_dir(tmp.path(), VALID_B);
520
521        write_active_account_file_in(tmp.path(), VALID_A).unwrap();
522        write_active_account_file_in(tmp.path(), VALID_B).unwrap();
523
524        assert_eq!(
525            read_active_account_file_in(tmp.path()).unwrap(),
526            Some(VALID_B.to_string())
527        );
528        // The temp file used for atomic rename should not linger.
529        assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
530    }
531
532    #[test]
533    fn clear_removes_marker_and_is_idempotent() {
534        let tmp = TempDir::new().unwrap();
535        touch_account_dir(tmp.path(), VALID_A);
536        write_active_account_file_in(tmp.path(), VALID_A).unwrap();
537        assert!(tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
538
539        clear_active_account_file_in(tmp.path()).unwrap();
540        assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
541
542        // Calling clear again on an already-clean state must not error.
543        clear_active_account_file_in(tmp.path()).unwrap();
544    }
545
546    #[test]
547    fn list_npubs_finds_valid_dirs_only() {
548        let tmp = TempDir::new().unwrap();
549        touch_account_dir(tmp.path(), VALID_A);
550        touch_account_dir(tmp.path(), VALID_B);
551        // Decoys: stray dirs and files that must NOT be picked up.
552        fs::create_dir_all(tmp.path().join("npub1tooshort")).unwrap();
553        fs::create_dir_all(tmp.path().join("not-an-npub-dir")).unwrap();
554        fs::create_dir_all(tmp.path().join("tor")).unwrap();
555        fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), VALID_A).unwrap();
556
557        let mut found = list_account_npubs_in(tmp.path());
558        found.sort();
559        let mut expected = vec![VALID_A.to_string(), VALID_B.to_string()];
560        expected.sort();
561        assert_eq!(found, expected);
562    }
563
564    #[test]
565    fn list_npubs_skips_dirs_containing_invalid_chars() {
566        let tmp = TempDir::new().unwrap();
567        // Insert a 'b', 'i', 'o', or '1' into the body — invalid bech32 chars.
568        let mut bogus = String::from(VALID_A);
569        bogus.replace_range(10..11, "b");
570        fs::create_dir_all(tmp.path().join(&bogus)).unwrap();
571
572        let found = list_account_npubs_in(tmp.path());
573        assert!(found.is_empty(), "found unexpected entries: {:?}", found);
574    }
575
576    #[test]
577    fn write_creates_app_data_dir_if_missing() {
578        let tmp = TempDir::new().unwrap();
579        let nested = tmp.path().join("does/not/exist/yet");
580        // Parent app_data is auto-created by mkdir_all; the account dir must
581        // also exist by the time we write, so the marker can't end up
582        // pointing at a non-existent account.
583        std::fs::create_dir_all(&nested).unwrap();
584        touch_account_dir(&nested, VALID_A);
585        write_active_account_file_in(&nested, VALID_A).unwrap();
586        assert!(nested.join(ACTIVE_ACCOUNT_FILE).exists());
587    }
588}
589
590// pending-account accessors removed — see comment above the static decl.
591// All callers use `src-tauri::account_manager::{get,set,clear}_pending_account`.
592
593// ============================================================================
594// Connection Pools
595// ============================================================================
596
597/// One account's database resources, held behind an `Arc`.
598///
599/// The connections an account uses are reachable ONLY through its own
600/// `Session`, so a task that captured one keeps talking to the account it
601/// started with even after a swap — it cannot be handed the next account's
602/// database, because it never asks "who is current?" again.
603///
604/// That makes teardown structural: swapping installs a new `Session` and drops
605/// the reference to the old one, whose pool closes when the last in-flight
606/// guard finishes with it. Nothing to clear, nothing to remember to clear.
607pub struct Session {
608    /// Identity for "is this the account on screen?".
609    ///
610    /// Not the `Arc` address: binding a session to a database builds a new one
611    /// holding the same state (see `rebound`), and a task bound to the session
612    /// it grew from is still that account's. Comparing addresses would make
613    /// those tasks silently stop painting after a re-initialise.
614    id: u64,
615    /// The database this session opens, resolved ONCE when it is built.
616    /// `None` only before an account is bound, where there is nothing better
617    /// than the ambient lookup to fall back to.
618    db_path: Option<PathBuf>,
619    read_pool: Mutex<Vec<rusqlite::Connection>>,
620    write_conn: Mutex<Option<rusqlite::Connection>>,
621    /// This account's chats and profiles in memory — the DB's read-through
622    /// cache, and so bound to the same account for the same reason.
623    chat_state: Arc<tokio::sync::Mutex<crate::state::ChatState>>,
624    /// Everything else the account owns, keyed by type. See [`Session::scoped`].
625    scoped: RwLock<std::collections::HashMap<std::any::TypeId, Arc<dyn std::any::Any + Send + Sync>>>,
626    /// Raised when this account stops being the one on screen.
627    stopped: SessionStop,
628}
629
630/// "This account is no longer current" — a flag long work polls, and a signal
631/// it can await.
632#[derive(Default)]
633struct SessionStop {
634    flag: std::sync::atomic::AtomicBool,
635    wake: tokio::sync::Notify,
636}
637
638impl Session {
639    fn empty() -> Arc<Self> {
640        Arc::new(Session {
641            id: next_session_id(),
642            db_path: None,
643            read_pool: Mutex::new(Vec::new()),
644            write_conn: Mutex::new(None),
645            chat_state: Arc::new(tokio::sync::Mutex::new(crate::state::ChatState::new())),
646            scoped: RwLock::new(std::collections::HashMap::new()),
647            stopped: SessionStop::default(),
648        })
649    }
650
651    /// A session bound to one account's database file, with a fresh in-memory
652    /// state — the caller loads it from that database.
653    fn bound(db_path: PathBuf) -> Arc<Self> {
654        Arc::new(Session {
655            id: next_session_id(),
656            db_path: Some(db_path),
657            read_pool: Mutex::new(Vec::new()),
658            write_conn: Mutex::new(None),
659            chat_state: Arc::new(tokio::sync::Mutex::new(crate::state::ChatState::new())),
660            scoped: RwLock::new(std::collections::HashMap::new()),
661            stopped: SessionStop::default(),
662        })
663    }
664
665    /// The same account's session, pointed at its database, keeping everything
666    /// it already holds.
667    ///
668    /// Two callers need this. Re-initialising an account that is already open —
669    /// `init_database` is documented idempotent, and the schema check and
670    /// Android's background sync both re-run it — must not empty the chat list
671    /// under a running app. And a login legitimately fills a session before the
672    /// account's database exists: creating an account installs its keys and
673    /// client first, and only reaches `init_database` once the user has chosen
674    /// a PIN. Binding the session it was filling is a promotion, not a swap.
675    fn rebound(&self, db_path: PathBuf) -> Arc<Self> {
676        Arc::new(Session {
677            id: self.id,
678            db_path: Some(db_path),
679            read_pool: Mutex::new(Vec::new()),
680            write_conn: Mutex::new(None),
681            chat_state: self.chat_state.clone(),
682            scoped: RwLock::new(self.scoped.read().unwrap_or_else(|e| e.into_inner()).clone()),
683            stopped: SessionStop::default(),
684        })
685    }
686
687    /// This account's instance of `T`, built on first touch.
688    ///
689    /// The home for anything an account owns beyond its database: caches keyed
690    /// by its row ids, queues holding its work, routing tables holding its
691    /// keys. Previously these were process globals with a hand-written clear in
692    /// two teardown paths, which is how they drifted — one path cleared fields
693    /// the other did not, and a cache added without a clear leaked into the
694    /// next account. Here there is nothing to clear: the account's instances go
695    /// when its session does.
696    ///
697    /// Keyed by a marker type the owning module declares, so the session never
698    /// has to know what any of this is, and two modules storing the same SHAPE
699    /// (say a `Mutex<HashMap<PublicKey, _>>`) can't collide on one slot.
700    ///
701    /// ```ignore
702    /// struct InboxRelayCache;               // the key, private to this module
703    /// fn cache() -> Arc<Mutex<HashMap<PublicKey, CachedRelays>>> {
704    ///     db::current_session().scoped::<InboxRelayCache, _>()
705    /// }
706    /// ```
707    pub fn scoped<K: 'static, T: Default + Send + Sync + 'static>(self: &Arc<Self>) -> Arc<T> {
708        let key = std::any::TypeId::of::<(K, T)>();
709        if let Some(existing) = self.scoped.read().unwrap_or_else(|e| e.into_inner()).get(&key) {
710            return existing.clone().downcast::<T>().expect("keyed by its own TypeId");
711        }
712        let mut map = self.scoped.write().unwrap_or_else(|e| e.into_inner());
713        // Another thread may have inserted while the read lock was released.
714        map.entry(key)
715            .or_insert_with(|| Arc::new(T::default()) as Arc<dyn std::any::Any + Send + Sync>)
716            .clone()
717            .downcast::<T>()
718            .expect("keyed by its own TypeId")
719    }
720
721    /// This session's identity. Stable across a re-initialise of the same
722    /// account, distinct for every other. Callers that key a cache by "which
723    /// account is this" use it in place of the old generation counter.
724    pub fn id(&self) -> u64 {
725        self.id
726    }
727
728    /// Whether this account has been switched away from.
729    ///
730    /// Purely an efficiency signal. Work that continues past it is still
731    /// CORRECT — it writes to this account's own storage and paints nothing —
732    /// it is just no longer work anyone is waiting for. Long syncs poll this at
733    /// their loop heads so a swap stops the relay traffic and the decryption
734    /// rather than grinding on for a screen nobody is looking at.
735    ///
736    /// Never use it to decide whether a write is SAFE. That is structural now.
737    pub fn stopped(&self) -> bool {
738        self.stopped.flag.load(std::sync::atomic::Ordering::Acquire)
739    }
740
741    /// Resolves once this account is switched away from — for `select!` against
742    /// a fetch, so an in-flight request is dropped instead of awaited out.
743    pub async fn on_stop(&self) {
744        loop {
745            let waiting = self.stopped.wake.notified();
746            if self.stopped() {
747                return;
748            }
749            waiting.await;
750            if self.stopped() {
751                return;
752            }
753        }
754    }
755
756    fn stop(&self) {
757        self.stopped.flag.store(true, std::sync::atomic::Ordering::Release);
758        self.stopped.wake.notify_waiters();
759    }
760
761    /// Whether this session is the account currently on screen.
762    ///
763    /// For code holding a session it captured earlier — a drop handler, a
764    /// callback fired by a relay OK — where there is no await to be bound
765    /// across and the question is "is what I captured still current?".
766    pub fn is_live(&self) -> bool {
767        self.id == CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner()).id
768    }
769
770    /// This account's in-memory chats and profiles.
771    ///
772    /// Reached through the session, so a task bound to account A keeps reading
773    /// and writing A's chats after a swap. Its state is then an orphan nothing
774    /// displays, rather than corruption in the account now on screen.
775    pub fn chat_state(&self) -> Arc<tokio::sync::Mutex<crate::state::ChatState>> {
776        self.chat_state.clone()
777    }
778
779    /// Where THIS session opens connections. A bound session never consults the
780    /// ambient account, so a pool miss after a swap still opens the file this
781    /// session belongs to rather than the incoming account's.
782    fn path(&self) -> Result<PathBuf, String> {
783        match &self.db_path {
784            Some(p) => Ok(p.clone()),
785            None => get_current_db_path(),
786        }
787    }
788
789    /// Take a READ connection from this session, opening one against this
790    /// session's own database if the pool is empty.
791    pub fn acquire_read(self: &Arc<Self>) -> Result<ConnectionGuard, String> {
792        if let Ok(mut pool) = self.read_pool.lock() {
793            if let Some(conn) = pool.pop() {
794                return Ok(ConnectionGuard::new(conn, self.clone()));
795            }
796        }
797        let conn = create_connection(&self.path()?)?;
798        Ok(ConnectionGuard::new(conn, self.clone()))
799    }
800
801    /// Take THE write connection from this session, opening one against this
802    /// session's own database if the slot is empty.
803    pub fn acquire_write(self: &Arc<Self>) -> Result<WriteConnectionGuard, String> {
804        {
805            let mut slot = self.write_conn.lock().unwrap_or_else(|e| e.into_inner());
806            if let Some(conn) = slot.take() {
807                return Ok(WriteConnectionGuard::new(conn, self.clone()));
808            }
809        }
810        let conn = create_connection(&self.path()?)?;
811        Ok(WriteConnectionGuard::new(conn, self.clone()))
812    }
813}
814
815/// The account whose resources new work binds to. Read once at the START of a
816/// unit of work; hold the `Arc` for its duration.
817static CURRENT_SESSION: LazyLock<RwLock<Arc<Session>>> = LazyLock::new(|| RwLock::new(Session::empty()));
818
819tokio::task_local! {
820    /// The session this task is bound to, installed by [`spawn_bound`].
821    ///
822    /// Readable from any code running on the task, including the synchronous
823    /// `db::` helpers an async body calls — which is what lets every existing
824    /// call site become account-correct without growing a parameter.
825    static TASK_SESSION: Arc<Session>;
826}
827
828/// The session THIS work belongs to.
829///
830/// A task bound by [`spawn_bound`] gets the account it started under, for its
831/// whole life, however many awaits it spans and whoever logs in meanwhile. That
832/// is the property the std::sync::Arc<crate::db::Session> checks were approximating by hand.
833///
834/// Unbound callers (startup, the UI command that performs the swap itself) get
835/// the live account, which for them is the correct and only meaningful answer.
836pub fn current_session() -> Arc<Session> {
837    TASK_SESSION
838        .try_with(Arc::clone)
839        .unwrap_or_else(|_| CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner()).clone())
840}
841
842/// Run `fut` pinned to the CURRENT account, without spawning.
843///
844/// The counterpart to [`spawn_bound`] for work that is NOT a task we started:
845/// a Tauri command, an SDK call, a JNI entry point. Nothing installs a session
846/// for those, so every `db::` call inside them re-resolves the live account and
847/// a swap mid-operation silently moves the rest of their writes. Wrapping the
848/// body fixes the account for its whole duration — a swap then leaves the
849/// operation completing against the account that asked for it, which is both
850/// the correct outcome and the one the user intended.
851///
852/// This is what makes the hand-written "did the account change?" checks
853/// unnecessary rather than merely redundant.
854/// NOT an `async fn`, and that is load-bearing. An `async fn` stores its
855/// parameters in the state machine it returns, so `scoped` would be sized to
856/// hold the body it binds — and nesting would multiply, exactly as passing the
857/// future to `scope` unboxed does. A plain fn returning `impl Future` hands
858/// back a wrapper around a `Pin<Box<_>>`: a pointer, whatever the body.
859///
860/// This is not hypothetical. Boot crashed with a stack overflow on BOTH Android
861/// and macOS, several layers into the community sync, on stacks every test here
862/// runs on happily. `binding_a_future_does_not_embed_it` is the guard.
863pub fn scoped<F: std::future::Future>(fut: F) -> impl std::future::Future<Output = F::Output> {
864    TASK_SESSION.scope(current_session(), Box::pin(fut))
865}
866
867/// [`scoped`], but the RESULT is refused if the account changed while it ran.
868///
869/// For an operation whose value goes back to the UI. The work itself completed
870/// correctly against its own account; what must not happen is that value being
871/// painted into, or acted on by, the account now on screen. One wrapper in
872/// place of a check before every write.
873pub fn scoped_result<T, E, F>(fut: F) -> impl std::future::Future<Output = Result<T, E>>
874where
875    F: std::future::Future<Output = Result<T, E>>,
876    E: From<String>,
877{
878    let session = current_session();
879    let id = session.id;
880    // Bind first, so the async block below holds only the (pointer-sized)
881    // bound future rather than the body. See `scoped`.
882    let bound = TASK_SESSION.scope(session, Box::pin(fut));
883    async move {
884        let out = bound.await;
885        if id != CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner()).id {
886            return Err(E::from("account changed during the operation".to_string()));
887        }
888        out
889    }
890}
891
892/// Run `fut` pinned to a session you already hold.
893///
894/// For code that captured a session earlier and wants to finish that account's
895/// work through it — and for tests, which need to read an account's storage
896/// after the live session has moved on.
897pub fn with_session<F: std::future::Future>(
898    session: Arc<Session>,
899    fut: F,
900) -> impl std::future::Future<Output = F::Output> {
901    TASK_SESSION.scope(session, Box::pin(fut))
902}
903
904/// Spawn a task pinned to the CURRENT account.
905///
906/// Everything it does through `db::` resolves to that account for as long as it
907/// runs, so an account switch mid-flight can no longer redirect its writes. Use
908/// this for any task that touches per-account state; a bare `tokio::spawn`
909/// leaves the task reading whoever is live at the moment it asks.
910pub fn spawn_bound<F>(fut: F) -> tokio::task::JoinHandle<F::Output>
911where
912    F: std::future::Future + Send + 'static,
913    F::Output: Send + 'static,
914{
915    let session = current_session();
916    // spawn-detached: this IS the binding — it installs the session it just read.
917    tokio::spawn(TASK_SESSION.scope(session, fut))
918}
919
920impl std::fmt::Debug for Session {
921    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
922        f.debug_struct("Session").field("id", &self.id).field("db", &self.db_path).finish()
923    }
924}
925
926/// Has the account this work belongs to been switched away from?
927///
928/// Check it at the head of a long loop — a sync page, a relay in a fan-out, a
929/// channel in a sweep — and stop. Continuing is safe, just wasteful.
930pub fn session_stopped() -> bool {
931    current_session().stopped()
932}
933
934/// The live account's session id — for caches keyed by "which account is this".
935pub fn current_session_id() -> u64 {
936    current_session().id
937}
938
939/// Whether the work running here belongs to the account currently on screen.
940///
941/// There is exactly one UI, showing one account. A task bound to a previous
942/// account keeps working correctly — its database, its chats, its client — but
943/// what it produces must not be painted into the account the user is now
944/// looking at. Every emission asks this, which is why almost none of them has
945/// to ask it by hand.
946pub fn session_is_live() -> bool {
947    current_session().id == CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner()).id
948}
949
950fn next_session_id() -> u64 {
951    static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
952    NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
953}
954
955/// Install a fresh session, dropping the reference to the previous one. Any
956/// guard still outstanding against the old session returns its connection
957/// there, and that pool closes with it.
958fn replace_session() {
959    install(Session::empty());
960}
961
962/// Install `next` and tell the outgoing account's work to stop.
963fn install(next: Arc<Session>) {
964    let mut current = CURRENT_SESSION.write().unwrap_or_else(|e| e.into_inner());
965    if current.id != next.id {
966        current.stop();
967    }
968    *current = next;
969}
970
971/// RAII guard for READ connections — auto-returns to its OWN session's pool.
972///
973/// Holding the `Arc` is what makes the return safe: the connection goes back
974/// where it came from, so a guard outstanding across an account swap can never
975/// hand account A's connection to account B. When that older session has no
976/// references left, its pool closes with it.
977pub struct ConnectionGuard {
978    conn: Option<rusqlite::Connection>,
979    session: Arc<Session>,
980}
981
982impl ConnectionGuard {
983    fn new(conn: rusqlite::Connection, session: Arc<Session>) -> Self {
984        Self { conn: Some(conn), session }
985    }
986}
987
988impl Deref for ConnectionGuard {
989    type Target = rusqlite::Connection;
990    fn deref(&self) -> &Self::Target { self.conn.as_ref().expect("Connection already taken") }
991}
992
993impl DerefMut for ConnectionGuard {
994    fn deref_mut(&mut self) -> &mut Self::Target { self.conn.as_mut().expect("Connection already taken") }
995}
996
997impl Drop for ConnectionGuard {
998    fn drop(&mut self) {
999        if let Some(conn) = self.conn.take() {
1000            if let Ok(mut pool) = self.session.read_pool.lock() {
1001                pool.push(conn);
1002            }
1003        }
1004    }
1005}
1006
1007/// RAII guard for the WRITE connection — auto-returns on drop.
1008pub struct WriteConnectionGuard {
1009    conn: Option<rusqlite::Connection>,
1010    session: Arc<Session>,
1011}
1012
1013impl WriteConnectionGuard {
1014    fn new(conn: rusqlite::Connection, session: Arc<Session>) -> Self {
1015        Self { conn: Some(conn), session }
1016    }
1017}
1018
1019impl Deref for WriteConnectionGuard {
1020    type Target = rusqlite::Connection;
1021    fn deref(&self) -> &Self::Target { self.conn.as_ref().expect("Write connection already taken") }
1022}
1023
1024impl DerefMut for WriteConnectionGuard {
1025    fn deref_mut(&mut self) -> &mut Self::Target { self.conn.as_mut().expect("Write connection already taken") }
1026}
1027
1028impl Drop for WriteConnectionGuard {
1029    fn drop(&mut self) {
1030        if let Some(conn) = self.conn.take() {
1031            // Slot-empty check remains: a fresh write connection may already
1032            // have been installed for this same session.
1033            if let Ok(mut slot) = self.session.write_conn.lock() {
1034                if slot.is_none() {
1035                    *slot = Some(conn);
1036                }
1037            }
1038        }
1039    }
1040}
1041
1042// ============================================================================
1043// Connection Factory
1044// ============================================================================
1045
1046/// Single source of truth for per-account directories. Every per-account
1047/// subsystem (DB, Tor state) resolves its path through this;
1048/// compose further subpaths with `.join(...)` — never insert layers
1049/// between `<app_data>` and `<npub>`.
1050///
1051/// Validates npub format before joining as defence-in-depth against
1052/// path traversal: a crafted IPC input like `"../../etc"` would
1053/// otherwise yield `<app_data>/../../etc` and downstream
1054/// `remove_dir_all` (delete_account, logout) would walk arbitrary dirs.
1055pub fn account_dir(npub: &str) -> Result<PathBuf, String> {
1056    if !is_valid_npub(npub) {
1057        return Err(format!("Invalid npub format: {}", npub));
1058    }
1059    Ok(get_app_data_dir()?.join(npub))
1060}
1061
1062fn get_current_db_path() -> Result<PathBuf, String> {
1063    let npub = get_current_account()?;
1064    Ok(account_dir(&npub)?.join("vector.db"))
1065}
1066
1067/// Open a connection, riding out a TRANSIENT lock on the file.
1068///
1069/// `busy_timeout` governs waits inside a connection that already exists; it
1070/// cannot help the open itself, and `journal_mode=WAL` needs the lock briefly.
1071/// A task started under the previous account can still be holding this file for
1072/// a moment — it resolves the CURRENT account when it takes a connection, so a
1073/// swap points it here — and failing outright turns that into a failed login or
1074/// a failed account switch. Bounded: a genuinely stuck holder still surfaces.
1075fn create_connection(path: &PathBuf) -> Result<rusqlite::Connection, String> {
1076    const OPEN_RETRIES: u32 = 4;
1077    let mut last_err = String::new();
1078    for attempt in 0..OPEN_RETRIES {
1079        match open_connection(path) {
1080            Ok(conn) => return Ok(conn),
1081            Err(e) if e.contains("locked") || e.contains("busy") => {
1082                last_err = e;
1083                std::thread::sleep(std::time::Duration::from_millis(50 * u64::from(attempt + 1)));
1084            }
1085            Err(e) => return Err(e),
1086        }
1087    }
1088    Err(last_err)
1089}
1090
1091fn open_connection(path: &PathBuf) -> Result<rusqlite::Connection, String> {
1092    let conn = rusqlite::Connection::open(path)
1093        .map_err(|e| format!("Failed to open database: {}", e))?;
1094
1095    // busy_timeout FIRST, alone: `journal_mode=WAL` takes a brief exclusive lock,
1096    // and until the timeout is set it is still 0 — so a connection opened while
1097    // another is active failed outright ("database is locked") instead of waiting
1098    // the moment or two the other needed. Every pragma below now waits too.
1099    conn.execute_batch("PRAGMA busy_timeout=5000;")
1100        .map_err(|e| format!("Failed to set busy_timeout: {}", e))?;
1101
1102    // WAL for concurrent reads. cache_size negative = KiB (16 MiB page cache) to keep hot
1103    // pages resident on a large DB; temp_store=MEMORY keeps GROUP BY / sort scratch in
1104    // memory instead of spilling to disk.
1105    conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON; PRAGMA cache_size=-16000; PRAGMA temp_store=MEMORY;")
1106        .map_err(|e| format!("Failed to set pragmas: {}", e))?;
1107
1108    Ok(conn)
1109}
1110
1111/// Get a READ connection (headless-safe — no AppHandle).
1112pub fn get_db_connection_guard_static() -> Result<ConnectionGuard, String> {
1113    current_session().acquire_read()
1114}
1115
1116/// Process-wide serialization lock for tests that install into the global DB pool.
1117/// Any test calling `init_database` must hold this for its whole body — otherwise
1118/// concurrent inits race on the shared account/data-dir state and clobber each other.
1119/// One shared guard across every module (community, ...) so cross-module test
1120/// parallelism can't collide.
1121#[cfg(test)]
1122pub(crate) static DB_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
1123
1124/// Get the WRITE connection (headless-safe — no AppHandle).
1125pub fn get_write_connection_guard_static() -> Result<WriteConnectionGuard, String> {
1126    current_session().acquire_write()
1127}
1128
1129// ============================================================================
1130// Downgrade guard
1131// ============================================================================
1132
1133/// Where each build stamps its version after successfully opening an account.
1134const LAST_APP_VERSION_KEY: &str = "last_app_version";
1135
1136/// A newer Vector already opened this account's database.
1137///
1138/// Vector has no downgrade path: older builds neither recognise nor preserve
1139/// newer schema, and the corruption that follows is silent until the user opens
1140/// the wrong chat.
1141#[derive(Debug, Clone, serde::Serialize)]
1142pub struct DowngradeBlock {
1143    /// Migration high-water mark found in the account's DB.
1144    pub db_schema: u32,
1145    /// Highest migration this build can apply.
1146    pub supported_schema: u32,
1147    /// App version that last opened it, when one was stamped.
1148    pub last_app_version: Option<String>,
1149}
1150
1151impl std::fmt::Display for DowngradeBlock {
1152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1153        write!(f, "This account was last opened by a newer version of Vector")?;
1154        if let Some(version) = &self.last_app_version {
1155            write!(f, " ({version})")?;
1156        }
1157        write!(
1158            f,
1159            ". Its database is at schema {} and this build only understands {}. \
1160             Opening it would corrupt your messages, so Vector has stopped. \
1161             Reinstall the newer version to continue.",
1162            self.db_schema, self.supported_schema
1163        )
1164    }
1165}
1166
1167/// The check itself, against an already-open connection.
1168fn downgrade_block(conn: &rusqlite::Connection) -> Option<DowngradeBlock> {
1169    let db_schema = schema::applied_migration_high_water(conn);
1170    if db_schema <= schema::HIGHEST_MIGRATION_ID {
1171        return None;
1172    }
1173    Some(DowngradeBlock {
1174        db_schema,
1175        supported_schema: schema::HIGHEST_MIGRATION_ID,
1176        last_app_version: conn
1177            .query_row(
1178                "SELECT value FROM settings WHERE key = ?1",
1179                rusqlite::params![LAST_APP_VERSION_KEY],
1180                |row| row.get::<_, String>(0),
1181            )
1182            .ok(),
1183    })
1184}
1185
1186/// Whether this account's DB was written by a newer Vector.
1187///
1188/// Read-only and side-effect free, so a host can call it before
1189/// [`init_database`] and put a real dialog in front of the user rather than
1190/// surfacing a failed boot.
1191pub fn inspect_downgrade(npub: &str) -> Result<Option<DowngradeBlock>, String> {
1192    let db_path = account_dir(npub)?.join("vector.db");
1193    // Never create the file just to inspect it.
1194    if !db_path.exists() {
1195        return Ok(None);
1196    }
1197    let conn = create_connection(&db_path)?;
1198    Ok(downgrade_block(&conn))
1199}
1200
1201// ============================================================================
1202// Database Initialization
1203// ============================================================================
1204
1205/// Initialize the database for a given account (creates tables if needed).
1206pub fn init_database(npub: &str) -> Result<(), String> {
1207    let profile_dir = account_dir(npub)?;
1208
1209    if !profile_dir.exists() {
1210        std::fs::create_dir_all(&profile_dir)
1211            .map_err(|e| format!("Failed to create profile directory: {}", e))?;
1212    }
1213
1214    let db_path = profile_dir.join("vector.db");
1215    let mut conn = create_connection(&db_path)?;
1216
1217    // Before ANY write. SQL_SCHEMA is all CREATE TABLE IF NOT EXISTS, so an
1218    // older build would resurrect tables newer migrations dropped and then
1219    // start writing rows against a schema it cannot see.
1220    if let Some(block) = downgrade_block(&conn) {
1221        return Err(block.to_string());
1222    }
1223
1224    conn.execute_batch(schema::SQL_SCHEMA)
1225        .map_err(|e| format!("Failed to create schema: {}", e))?;
1226
1227    // Run migrations
1228    schema::run_migrations(&mut conn)?;
1229
1230    // Stamped after migrations, so it names the build whose schema is now on
1231    // disk. A blocked build never reaches here, so this only ever records a
1232    // version that could actually read what it wrote.
1233    if let Some(version) = APP_VERSION.get() {
1234        let _ = conn.execute(
1235            "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
1236            rusqlite::params![LAST_APP_VERSION_KEY, version],
1237        );
1238    }
1239
1240    // SQLite's prescribed open-time step for long-lived connections: analyze every table that needs
1241    // it (missing stats, or grown/shrunk 10x), bounded by a temporary analysis_limit so it stays
1242    // fast. The 0x10000 bit forces checking all tables since a fresh connection has no query history;
1243    // this also satisfies the "run optimize after CREATE INDEX" guidance for the migrations above.
1244    let _ = conn.execute_batch("PRAGMA optimize=0x10002;");
1245
1246    // Seed the in-session delete-tombstone set from this account's durable rows,
1247    // so ingest keeps refusing deleted messages across restarts (the swap's
1248    // session bump cleared the set; a fresh boot starts empty).
1249    {
1250        let mut stmt = conn.prepare("SELECT event_id FROM deleted_messages")
1251            .map_err(|e| format!("tombstone seed prepare: {}", e))?;
1252        let ids: Vec<String> = stmt.query_map([], |row| row.get::<_, String>(0))
1253            .map_err(|e| format!("tombstone seed query: {}", e))?
1254            .filter_map(|r| r.ok())
1255            .collect();
1256        drop(stmt);
1257        crate::state::seed_message_tombstones(ids);
1258    }
1259
1260    // MLS is fully removed. Migration 41 drops the relational tables, but the OpenMLS/MDK
1261    // crypto store lived in a SEPARATE per-account file (`<account>/mls/`) that no migration
1262    // can reach. Purge it here: it's dead weight (can run to hundreds of MB) and, worse,
1263    // stale MLS private key material lingering for a feature that no longer exists. Best-effort
1264    // and idempotent — a cleanup failure must never block account init.
1265    let mls_dir = profile_dir.join("mls");
1266    if mls_dir.exists() {
1267        match std::fs::remove_dir_all(&mls_dir) {
1268            Ok(()) => crate::log_info!("[db] purged orphaned MLS store for account"),
1269            Err(e) => crate::log_warn!("[db] could not purge orphaned MLS store: {}", e),
1270        }
1271    }
1272
1273    // Install a NEW session before opening anything: guards still outstanding
1274    // against the previous one return their connections there, and that pool
1275    // closes with it. Nothing from the old account can reach the new pool.
1276    //
1277    // A session already pointed at this database, or not yet pointed at one at
1278    // all, is THIS account's — bind it and keep what it holds (see `rebound`).
1279    // Only a session belonging to a different account starts over.
1280    let session = {
1281        let next = {
1282            let current = CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner());
1283            match current.db_path.as_deref() {
1284                Some(p) if p != db_path => Session::bound(db_path.clone()),
1285                _ => current.rebound(db_path.clone()),
1286            }
1287        };
1288        install(next.clone());
1289        next
1290    };
1291
1292    // Pre-warm read pool
1293    if let Ok(mut pool) = session.read_pool.lock() {
1294        for _ in 0..4 {
1295            if let Ok(c) = create_connection(&db_path) {
1296                pool.push(c);
1297            }
1298        }
1299    }
1300
1301    // Set write connection
1302    let write_conn = create_connection(&db_path)?;
1303    *session.write_conn.lock().unwrap_or_else(|e| e.into_inner()) = Some(write_conn);
1304
1305    // Hydrate Tor's hot-path settings cache directly from `db_path`,
1306    // NOT via `get_sql_setting()` — the global helper resolves through
1307    // the read pool + `get_current_account()`, neither of which yet
1308    // reflects this account (switch_account calls init_database BEFORE
1309    // set_current_account).
1310    #[cfg(feature = "tor")]
1311    {
1312        let enabled = create_connection(&db_path)
1313            .ok()
1314            .and_then(|c| {
1315                c.query_row(
1316                    "SELECT value FROM settings WHERE key = 'tor_enabled'",
1317                    [],
1318                    |row| row.get::<_, String>(0),
1319                )
1320                .ok()
1321            })
1322            .map(|v| v == "1" || v == "true")
1323            .unwrap_or(false);
1324        crate::tor::set_tor_enabled_pref(enabled);
1325    }
1326
1327    Ok(())
1328}
1329
1330/// Drop this account's database resources.
1331///
1332/// Installing a fresh session IS the teardown: the previous one is released,
1333/// and its connections close once the last outstanding guard returns. A guard
1334/// still in flight keeps serving the account it began under.
1335pub fn close_database() {
1336    replace_session();
1337}
1338
1339/// Run plain `PRAGMA optimize` on the live write connection — the periodic top-up SQLite recommends
1340/// for long-lived connections (the heavy lifting is the `optimize=0x10002` at connection open).
1341/// Best-effort and cheap: re-analyzes only tables whose stats the planner used and that changed
1342/// materially since the last run.
1343pub fn optimize_database() {
1344    let session = current_session();
1345    let guard = session.write_conn.lock().unwrap_or_else(|e| e.into_inner());
1346    if let Some(conn) = guard.as_ref() {
1347        let _ = conn.execute_batch("PRAGMA optimize;");
1348    }
1349}
1350
1351/// Get all available accounts (npub directories in app data).
1352pub fn get_accounts() -> Result<Vec<String>, String> {
1353    let app_data = get_app_data_dir()?;
1354    let mut accounts = Vec::new();
1355
1356    if let Ok(entries) = std::fs::read_dir(app_data) {
1357        for entry in entries.flatten() {
1358            if entry.file_type().map_or(false, |ft| ft.is_dir()) {
1359                let name = entry.file_name().to_string_lossy().to_string();
1360                if name.starts_with("npub1") {
1361                    // Check if vector.db exists
1362                    if entry.path().join("vector.db").exists() {
1363                        accounts.push(name);
1364                    }
1365                }
1366            }
1367        }
1368    }
1369
1370    Ok(accounts)
1371}
1372
1373/// Get the profile directory path for a given npub.
1374pub fn get_profile_directory(npub: &str) -> Result<PathBuf, String> {
1375    if !npub.starts_with("npub1") {
1376        return Err(format!("Invalid npub format: {}", npub));
1377    }
1378    let dir = account_dir(npub)?;
1379    if !dir.exists() {
1380        std::fs::create_dir_all(&dir)
1381            .map_err(|e| format!("Failed to create profile directory: {}", e))?;
1382    }
1383    Ok(dir)
1384}
1385
1386/// Get database path for a given npub.
1387pub fn get_database_path(npub: &str) -> Result<PathBuf, String> {
1388    Ok(get_profile_directory(npub)?.join("vector.db"))
1389}
1390
1391// ============================================================================
1392// ID Caches
1393// ============================================================================
1394
1395/// Clear every id cache on account switch. Row ids are PER-ACCOUNT (each account
1396/// has its own DB + id sequence), so a stale entry after a swap points into the
1397/// wrong DB: writes FK-fail silently and reads hit the wrong row. The caches live
1398/// in `id_cache`; this is the public entry the swap path + callers already use.
1399pub fn clear_id_caches() {
1400    id_cache::clear_id_caches();
1401    community::clear_banlist_cache();
1402    community::clear_channel_community_cache();
1403}
1404
1405// ============================================================================
1406// System Event Types
1407// ============================================================================
1408
1409#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1410#[repr(u8)]
1411pub enum SystemEventType {
1412    MemberLeft = 0,
1413    MemberJoined = 1,
1414    MemberRemoved = 2,
1415    WallpaperChanged = 3,
1416}
1417
1418impl SystemEventType {
1419    pub fn display_message(&self, display_name: &str) -> String {
1420        match self {
1421            SystemEventType::MemberLeft => format!("{} has left", display_name),
1422            SystemEventType::MemberJoined => format!("{} has joined", display_name),
1423            SystemEventType::MemberRemoved => format!("{} was removed", display_name),
1424            SystemEventType::WallpaperChanged => format!("{} changed the wallpaper", display_name),
1425        }
1426    }
1427
1428    pub fn as_u8(&self) -> u8 { *self as u8 }
1429}
1430
1431#[cfg(test)]
1432mod pool_generation_tests {
1433    use super::*;
1434
1435    /// Build a minimal in-memory SQLite connection — just enough to drop
1436    /// a connection through the guard machinery. We don't run schema or
1437    /// migrations because we only care about the guard's Drop pathway.
1438    fn fake_conn() -> rusqlite::Connection {
1439        rusqlite::Connection::open_in_memory().unwrap()
1440    }
1441
1442    #[test]
1443    fn close_database_installs_a_fresh_session() {
1444        // The guard is the POINT here, not boilerplate: `close_database`
1445        // replaces the process-global session, so running unguarded yanks the
1446        // database out from under whichever test is mid-query.
1447        let _guard = DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1448        let before = current_session();
1449        close_database();
1450        let after = current_session();
1451        assert!(
1452            !Arc::ptr_eq(&before, &after),
1453            "close_database must install a NEW session — the old one is what in-flight guards return to"
1454        );
1455    }
1456
1457    #[test]
1458    fn a_guard_returns_its_connection_to_its_own_session() {
1459        let session = Session::empty();
1460        drop(ConnectionGuard::new(fake_conn(), session.clone()));
1461        assert_eq!(session.read_pool.lock().unwrap().len(), 1, "the connection goes home");
1462    }
1463
1464    #[test]
1465    fn a_guard_outstanding_across_a_swap_returns_to_the_session_it_came_from() {
1466        // The property that replaced the generation counter, and a stronger
1467        // one: the old design could only assert a stale connection did NOT
1468        // pollute the new pool. Holding the Arc says where it actually went,
1469        // so account A's connection is unreachable from account B by
1470        // construction rather than by a comparison someone has to remember.
1471        let old = Session::empty();
1472        let new_session = Session::empty();
1473
1474        let guard = ConnectionGuard::new(fake_conn(), old.clone());
1475        // ...the account switches while the guard is still in flight...
1476        drop(guard);
1477
1478        assert_eq!(old.read_pool.lock().unwrap().len(), 1, "returned to the session it was taken from");
1479        assert_eq!(new_session.read_pool.lock().unwrap().len(), 0, "never reachable from the new account");
1480    }
1481
1482    #[test]
1483    fn a_pool_miss_after_a_swap_opens_the_sessions_own_database() {
1484        // The other half of the guarantee. Returning a connection was already
1485        // safe; ACQUIRING one was not, because a miss resolved the ambient
1486        // account and could open the incoming account's file into the outgoing
1487        // account's pool. A bound session never asks what is current.
1488        let dir = tempfile::tempdir().unwrap();
1489        let path_a = dir.path().join("a.db");
1490        let path_b = dir.path().join("b.db");
1491
1492        let session_a = Session::bound(path_a.clone());
1493        let _unrelated = Session::bound(path_b.clone());
1494
1495        // A task still holding A misses A's (empty) pool and opens a connection.
1496        let guard = session_a.acquire_read().expect("acquire against the held session");
1497        // Compare by filename: macOS reports the /private-resolved path.
1498        let opened = guard.path().expect("a file-backed connection").to_string();
1499        assert!(
1500            opened.ends_with("a.db") && !opened.ends_with("b.db"),
1501            "a miss opens the session's OWN database, never the incoming account's (opened {opened})"
1502        );
1503        assert!(!Arc::ptr_eq(&session_a, &current_session()), "the live session really did move on");
1504    }
1505
1506    /// Run `fut` bound to `session`, exactly as `spawn_bound` would.
1507    ///
1508    /// These tests never install their throwaway sessions globally. They used
1509    /// to, and every other test in the binary reads chats, caches and keys
1510    /// through the live session — so one of these swapping it mid-run pulled
1511    /// them out from under whatever was executing in parallel. Binding proves
1512    /// the same property without a global write.
1513    async fn bound_to<F: std::future::Future>(session: Arc<Session>, fut: F) -> F::Output {
1514        TASK_SESSION.scope(session, fut).await
1515    }
1516
1517    /// Every task that touches per-account state must be bound to an account.
1518    ///
1519    /// The worklist is empty: every spawn in this crate either binds or says on
1520    /// the line why it owns no account state. See [`crate::spawn_audit`].
1521    #[test]
1522    fn per_account_tasks_are_spawned_bound_to_their_account() {
1523        crate::spawn_audit::assert_all_spawns_bound(std::path::Path::new(env!("CARGO_MANIFEST_DIR")), &[]);
1524    }
1525
1526    #[tokio::test]
1527    async fn a_bound_task_keeps_its_account_across_a_swap() {
1528        // What the std::sync::Arc<crate::db::Session> checks were approximating by hand: the task
1529        // began under account A, so its work resolves to A however many awaits
1530        // it spans and whoever logs in meanwhile. No check, nothing to forget.
1531        // The live session stands in for whoever logged in next.
1532        let dir = tempfile::tempdir().unwrap();
1533        let a = Session::bound(dir.path().join("a.db"));
1534
1535        let seen = bound_to(a.clone(), async {
1536            tokio::task::yield_now().await;
1537            current_session()
1538        })
1539        .await;
1540
1541        assert!(Arc::ptr_eq(&seen, &a), "the task still sees the account it started under");
1542        assert!(!Arc::ptr_eq(&seen, &current_session()), "the live account is not reachable from it");
1543    }
1544
1545    #[tokio::test]
1546    async fn a_bound_tasks_chat_writes_cannot_reach_the_new_account() {
1547        // The bug this closes, in the form it actually took: a task holding a
1548        // chat id from account A finishes after the swap and inserts it into
1549        // whatever STATE it finds. The group chats that appeared in a freshly
1550        // created account arrived exactly this way.
1551        use crate::chat::{Chat, ChatType};
1552        let dir = tempfile::tempdir().unwrap();
1553        let a = Session::bound(dir.path().join("a.db"));
1554        let live_before = current_session().chat_state().lock().await.chats.len();
1555
1556        bound_to(a.clone(), async {
1557            tokio::task::yield_now().await;
1558            crate::state::STATE.lock().await.chats.push(Chat::new("a-chat".into(), ChatType::DirectMessage, Vec::new()));
1559        })
1560        .await;
1561
1562        assert_eq!(
1563            a.chat_state().lock().await.chats.len(),
1564            1,
1565            "it landed in the state of the account the task began under"
1566        );
1567        assert_eq!(
1568            current_session().chat_state().lock().await.chats.len(),
1569            live_before,
1570            "and nothing reached the account on screen"
1571        );
1572    }
1573
1574    #[tokio::test]
1575    async fn a_bound_task_cannot_publish_through_the_new_accounts_client() {
1576        // The client carries the signer, so reaching the wrong one means
1577        // publishing account A's payload under account B's identity. A bound
1578        // task reaches the client it started with, which the swap has already
1579        // shut down — its send fails instead of succeeding as the wrong person.
1580        use nostr_sdk::prelude::*;
1581        let dir = tempfile::tempdir().unwrap();
1582        let a = Session::bound(dir.path().join("a.db"));
1583        let a_identity = Keys::generate().public_key();
1584
1585        let seen = bound_to(a.clone(), async move {
1586            crate::state::set_my_public_key(a_identity);
1587            tokio::task::yield_now().await;
1588            crate::state::my_public_key()
1589        })
1590        .await;
1591
1592        assert_eq!(seen, Some(a_identity), "the task signs as the account it began under");
1593        assert_ne!(crate::state::my_public_key(), Some(a_identity), "and the live account is someone else");
1594    }
1595
1596    #[test]
1597    fn binding_an_unbound_session_to_a_database_keeps_what_it_holds() {
1598        // Creating an account installs its keys and client BEFORE its database
1599        // exists — `init_database` only runs once the user has chosen a PIN.
1600        // Replacing the session there would discard the login in progress.
1601        let dir = tempfile::tempdir().unwrap();
1602        let staging = Session::empty();
1603        let held = staging.scoped::<Session, Mutex<u8>>();
1604        *held.lock().unwrap() = 7;
1605
1606        let promoted = staging.rebound(dir.path().join("a.db"));
1607        assert_eq!(*promoted.scoped::<Session, Mutex<u8>>().lock().unwrap(), 7, "the login survives being bound");
1608        assert!(promoted.db_path.is_some(), "and it now has a database");
1609        assert_eq!(promoted.id, staging.id, "and it is still the same account, so its tasks keep painting");
1610    }
1611
1612    #[tokio::test]
1613    async fn a_bound_task_paints_nothing_into_the_account_on_screen() {
1614        // Binding fixes where work LANDS; it cannot fix what the user SEES,
1615        // because there is one UI showing one account. So emission asks once,
1616        // centrally, and a task belonging to a previous account goes quiet.
1617        let dir = tempfile::tempdir().unwrap();
1618        let previous = Session::bound(dir.path().join("previous.db"));
1619        assert!(session_is_live(), "work for the account on screen paints");
1620
1621        let painted = bound_to(previous, async {
1622            tokio::task::yield_now().await;
1623            session_is_live()
1624        })
1625        .await;
1626
1627        assert!(!painted, "a task bound to another account paints nothing");
1628        assert!(session_is_live(), "and the account now on screen still does");
1629    }
1630
1631    #[tokio::test]
1632    async fn switching_accounts_tells_the_previous_one_to_stop() {
1633        // Purely an efficiency signal — the work would still be correct — but a
1634        // boot sync is minutes of relay traffic and decryption, and after a swap
1635        // nobody is waiting for any of it.
1636        let _serialized = DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1637        let previous = current_session();
1638        assert!(!previous.stopped(), "running work is not told to stop");
1639
1640        // `on_stop` must be pending until the switch, then resolve.
1641        let waiter = { let p = previous.clone(); tokio::spawn(async move { p.on_stop().await }) };
1642        tokio::task::yield_now().await;
1643        assert!(!waiter.is_finished(), "nothing to report while the account is current");
1644
1645        close_database();
1646
1647        assert!(previous.stopped(), "the outgoing account is told to stop");
1648        assert!(!current_session().stopped(), "the incoming one is not");
1649        tokio::time::timeout(std::time::Duration::from_secs(5), waiter)
1650            .await
1651            .expect("on_stop resolves on the switch")
1652            .expect("without panicking");
1653    }
1654
1655    #[test]
1656    fn re_initialising_the_same_account_does_not_tell_it_to_stop() {
1657        // `init_database` runs more than once per account — the schema check at
1658        // boot, Android's background sync. Reading that as a swap would abort a
1659        // sync that is still perfectly wanted.
1660        let dir = tempfile::tempdir().unwrap();
1661        let staging = Session::empty();
1662        let promoted = staging.rebound(dir.path().join("a.db"));
1663        assert!(!staging.stopped(), "binding a session is not switching away from it");
1664        assert_eq!(promoted.id, staging.id);
1665    }
1666
1667    /// Binding must not grow with what it binds.
1668    ///
1669    /// `scope` takes the future BY VALUE, so an unboxed `scoped` embeds the
1670    /// whole inner state machine in its caller's. Nesting then multiplies, and
1671    /// a deep chain of bound calls overflows a worker stack — which is exactly
1672    /// what happened: boot crashed on BOTH Android and macOS, several layers
1673    /// into the community sync, on stacks every one of these tests runs on
1674    /// happily. Sizes are what the compiler gives us; the invariant is that the
1675    /// wrapper adds a bounded amount regardless of the body.
1676    #[test]
1677    fn binding_a_future_does_not_embed_it() {
1678        // A deliberately fat body: 8KB of state the future must carry.
1679        let fat = async {
1680            let block = [0u8; 8192];
1681            tokio::task::yield_now().await;
1682            block[0]
1683        };
1684        let fat_size = std::mem::size_of_val(&fat);
1685        assert!(fat_size >= 8192, "the body really is large ({fat_size})");
1686
1687        let bound = scoped(fat);
1688        let bound_size = std::mem::size_of_val(&bound);
1689        assert!(
1690            bound_size < 1024,
1691            "binding must cost a pointer, not a copy of the body \
1692             (body {fat_size} bytes, bound {bound_size})"
1693        );
1694    }
1695
1696    #[test]
1697    fn a_dropped_session_closes_its_pool() {
1698        // Teardown IS the drop: no clearing step to forget.
1699        let session = Session::empty();
1700        drop(ConnectionGuard::new(fake_conn(), session.clone()));
1701        assert_eq!(Arc::strong_count(&session), 1, "the guard released its reference");
1702        drop(session); // pool + connections close here
1703    }
1704
1705    #[test]
1706    fn a_stale_write_guard_cannot_clobber_the_new_accounts_connection() {
1707        // Under the old design the stale guard and the fresh connection shared
1708        // one global slot, so the guard's Drop had to be talked out of
1709        // overwriting it. They are now in different sessions and cannot meet.
1710        let old = Session::empty();
1711        let new_session = Session::empty();
1712
1713        let stale_guard = WriteConnectionGuard::new(fake_conn(), old.clone());
1714        // init_database installs the new account's write connection.
1715        *new_session.write_conn.lock().unwrap() = Some(fake_conn());
1716
1717        drop(stale_guard);
1718
1719        assert!(
1720            new_session.write_conn.lock().unwrap().is_some(),
1721            "the new account's write connection is untouched"
1722        );
1723        assert!(
1724            old.write_conn.lock().unwrap().is_some(),
1725            "the stale guard returned to its own session's slot"
1726        );
1727    }
1728
1729    #[test]
1730    fn a_new_accounts_empty_write_slot_stays_empty() {
1731        // The old worry: a stale guard filling the fresh account's empty slot
1732        // with a connection pointing at a different database. It has no way to
1733        // reach that slot now — it only knows its own session.
1734        let old = Session::empty();
1735        let new_session = Session::empty();
1736
1737        let stale_guard = WriteConnectionGuard::new(fake_conn(), old.clone());
1738        drop(stale_guard);
1739
1740        assert!(
1741            new_session.write_conn.lock().unwrap().is_none(),
1742            "the new account's slot is untouched by the previous account's guard"
1743        );
1744    }
1745}
1746
1747#[cfg(test)]
1748mod downgrade_tests {
1749    use super::*;
1750
1751    fn test_account() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, String) {
1752        let guard = DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1753        close_database();
1754        clear_id_caches();
1755        let tmp = tempfile::tempdir().unwrap();
1756        // Bases must not collide across test modules: APP_DATA_DIR is a
1757        // OnceLock shared by the whole binary, and this generator collapses
1758        // after ~4 chars, so nearby seeds yield near-identical npubs.
1759        // Taken: 0, 900, 5_000, 50_000, 70_000, 71_000, 81_000, 90_000.
1760        static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(61_000);
1761        let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1762        const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
1763        let mut acct = String::from("npub1");
1764        let mut v = n as usize;
1765        for _ in 0..58 {
1766            acct.push(B[v % 32] as char);
1767            v = v / 32 + 7;
1768        }
1769        set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
1770        set_current_account(acct.clone()).unwrap();
1771        (tmp, guard, acct)
1772    }
1773
1774    #[tokio::test]
1775    async fn re_initialising_the_same_account_keeps_its_loaded_state() {
1776        // init_database is documented idempotent, and callers lean on that —
1777        // the schema check and Android's background sync both re-run it against
1778        // an account already loaded. Dropping its chats there would empty the
1779        // list under a running app.
1780        use crate::chat::{Chat, ChatType};
1781        let (_dir, _lock, acct) = test_account();
1782        init_database(&acct).unwrap();
1783        crate::state::STATE.lock().await.chats.push(Chat::new("kept".into(), ChatType::DirectMessage, Vec::new()));
1784
1785        init_database(&acct).unwrap();
1786        assert_eq!(crate::state::STATE.lock().await.chats.len(), 1, "same account, same in-memory state");
1787    }
1788
1789    /// A DB this build fully understands must open, or the guard is useless.
1790    #[test]
1791    fn an_equal_schema_opens_normally() {
1792        let (_tmp, _guard, acct) = test_account();
1793        init_database(&acct).unwrap();
1794        assert!(inspect_downgrade(&acct).unwrap().is_none());
1795        // Re-opening is still fine: the stamp write must not trip the guard.
1796        init_database(&acct).unwrap();
1797        assert!(inspect_downgrade(&acct).unwrap().is_none());
1798    }
1799
1800    /// Absent DB is not a downgrade; it must not be created just to look.
1801    #[test]
1802    fn a_missing_database_is_not_a_downgrade() {
1803        let (_tmp, _guard, acct) = test_account();
1804        assert!(inspect_downgrade(&acct).unwrap().is_none());
1805        assert!(!account_dir(&acct).unwrap().join("vector.db").exists());
1806    }
1807
1808    #[test]
1809    fn a_newer_schema_blocks_the_open_and_names_the_build() {
1810        let (_tmp, _guard, acct) = test_account();
1811        init_database(&acct).unwrap();
1812
1813        // Stand in for a newer Vector having run one migration past this build.
1814        let db_path = account_dir(&acct).unwrap().join("vector.db");
1815        {
1816            let conn = create_connection(&db_path).unwrap();
1817            conn.execute(
1818                "INSERT OR REPLACE INTO schema_migrations (id, applied_at) VALUES (?1, 0)",
1819                rusqlite::params![schema::HIGHEST_MIGRATION_ID + 1],
1820            )
1821            .unwrap();
1822            conn.execute(
1823                "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
1824                rusqlite::params![LAST_APP_VERSION_KEY, "9.9.9"],
1825            )
1826            .unwrap();
1827        }
1828        close_database();
1829
1830        let block = inspect_downgrade(&acct)
1831            .unwrap()
1832            .expect("a higher migration id must read as a downgrade");
1833        assert_eq!(block.db_schema, schema::HIGHEST_MIGRATION_ID + 1);
1834        assert_eq!(block.supported_schema, schema::HIGHEST_MIGRATION_ID);
1835        assert_eq!(block.last_app_version.as_deref(), Some("9.9.9"));
1836
1837        let err = init_database(&acct).unwrap_err();
1838        assert!(err.contains("9.9.9"), "must name the newer build: {err}");
1839    }
1840
1841    /// The guard has to fire before SQL_SCHEMA runs: its CREATE TABLE IF NOT
1842    /// EXISTS statements would otherwise resurrect tables newer migrations drop.
1843    #[test]
1844    fn a_blocked_open_writes_nothing() {
1845        let (_tmp, _guard, acct) = test_account();
1846        init_database(&acct).unwrap();
1847        let db_path = account_dir(&acct).unwrap().join("vector.db");
1848        {
1849            let conn = create_connection(&db_path).unwrap();
1850            conn.execute(
1851                "INSERT OR REPLACE INTO schema_migrations (id, applied_at) VALUES (?1, 0)",
1852                rusqlite::params![schema::HIGHEST_MIGRATION_ID + 5],
1853            )
1854            .unwrap();
1855            conn.execute("DROP TABLE IF EXISTS settings", []).unwrap();
1856        }
1857        close_database();
1858
1859        assert!(init_database(&acct).is_err());
1860
1861        // SQL_SCHEMA would have recreated `settings`; it must still be gone.
1862        let conn = create_connection(&db_path).unwrap();
1863        let exists: bool = conn
1864            .query_row(
1865                "SELECT 1 FROM sqlite_master WHERE type='table' AND name='settings'",
1866                [],
1867                |_| Ok(true),
1868            )
1869            .unwrap_or(false);
1870        assert!(!exists, "a blocked open must not write to the database");
1871    }
1872}