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