1use 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 at_rest;
18pub mod miniapps;
19pub mod schema;
20pub mod profiles;
21pub mod id_cache;
22pub mod events;
23pub mod attachments;
24pub mod chats;
25pub mod wrappers;
26pub mod nip17_keys;
27pub mod community;
28pub mod bots;
29pub mod notify;
30#[cfg(target_arch = "wasm32")]
31pub mod webfs;
32#[cfg(target_arch = "wasm32")]
33use webfs::{clear_active_account_file_in, list_account_npubs_in, read_active_account_file_in, write_active_account_file_in};
34
35pub use settings::{
36 get_sql_setting, set_sql_setting, advance_u64_setting, get_pkey, set_pkey, get_seed, set_seed, remove_setting,
37 get_signer_type, set_signer_type,
38 get_bunker_url, set_bunker_url,
39 get_bunker_remote_pubkey, set_bunker_remote_pubkey,
40 commit_bunker_account_setup,
41 get_nip55_user_pubkey, set_nip55_user_pubkey,
42 get_nip55_signer_package, set_nip55_signer_package,
43 commit_nip55_account_setup, commit_external_signer_setup,
44};
45
46static APP_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
51
52pub fn set_app_data_dir(path: PathBuf) {
53 let _ = APP_DATA_DIR.set(path);
54}
55
56#[cfg(test)]
66pub(crate) fn shared_test_data_dir() -> &'static std::path::Path {
67 static DIR: OnceLock<tempfile::TempDir> = OnceLock::new();
68 DIR.get_or_init(|| tempfile::tempdir().expect("test data dir")).path()
71}
72
73pub fn get_app_data_dir() -> Result<&'static PathBuf, String> {
74 APP_DATA_DIR.get().ok_or_else(|| "App data directory not initialized".to_string())
75}
76
77static APP_VERSION: OnceLock<String> = OnceLock::new();
81
82pub fn set_app_version(version: impl Into<String>) {
83 let _ = APP_VERSION.set(version.into());
84}
85
86static DOWNLOAD_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
91
92pub fn set_download_dir(path: PathBuf) {
96 let _ = DOWNLOAD_DIR_OVERRIDE.set(path);
97}
98
99pub fn get_download_dir() -> PathBuf {
106 if let Some(installed) = DOWNLOAD_DIR_OVERRIDE.get() {
107 return installed.clone();
108 }
109 #[cfg(any(target_os = "macos", target_os = "linux"))]
110 {
111 if let Ok(home) = std::env::var("HOME") {
112 return PathBuf::from(home).join("Downloads/vector");
113 }
114 }
115 #[cfg(target_os = "windows")]
116 {
117 if let Ok(profile) = std::env::var("USERPROFILE") {
118 return PathBuf::from(profile).join("Downloads").join("vector");
119 }
120 }
121 if let Ok(data_dir) = get_app_data_dir() {
123 return data_dir.join("vector_downloads");
124 }
125 PathBuf::from("/tmp/vector_downloads")
126}
127
128static CURRENT_ACCOUNT: LazyLock<Arc<RwLock<Option<String>>>> = LazyLock::new(|| Arc::new(RwLock::new(None)));
133const ACTIVE_ACCOUNT_FILE: &str = "active_account";
138
139fn is_valid_npub(s: &str) -> bool {
141 if s.len() != 63 || !s.starts_with("npub1") {
142 return false;
143 }
144 s.bytes().skip(5).all(|c| matches!(c,
145 b'q' | b'p' | b'z' | b'r' | b'y' | b'9' | b'x' | b'8' |
146 b'g' | b'f' | b'2' | b't' | b'v' | b'd' | b'w' | b'0' |
147 b's' | b'3' | b'j' | b'n' | b'5' | b'4' | b'k' | b'h' |
148 b'c' | b'e' | b'6' | b'm' | b'u' | b'a' | b'7' | b'l'
149 ))
150}
151
152pub fn get_current_account() -> Result<String, String> {
153 CURRENT_ACCOUNT.read().unwrap()
154 .as_ref().cloned()
155 .ok_or_else(|| "No active account".to_string())
156}
157
158pub fn set_current_account(npub: String) -> Result<(), String> {
165 *CURRENT_ACCOUNT.write().unwrap() = Some(npub.clone());
166 let _ = write_active_account_file(&npub);
167 Ok(())
168}
169
170pub fn clear_current_account_in_memory() {
174 *CURRENT_ACCOUNT.write().unwrap() = None;
175}
176
177pub fn read_active_account_file() -> Result<Option<String>, String> {
181 let app_data = match get_app_data_dir() {
182 Ok(p) => p,
183 Err(_) => return Ok(None),
184 };
185 read_active_account_file_in(app_data)
186}
187
188pub fn write_active_account_file(npub: &str) -> Result<(), String> {
190 let app_data = get_app_data_dir()?.clone();
191 write_active_account_file_in(&app_data, npub)
192}
193
194pub fn clear_active_account_file() -> Result<(), String> {
196 let app_data = get_app_data_dir()?;
197 clear_active_account_file_in(app_data)
198}
199
200pub fn list_account_npubs() -> Result<Vec<String>, String> {
204 let app_data = get_app_data_dir()?;
205 Ok(list_account_npubs_in(app_data))
206}
207
208#[cfg(not(target_arch = "wasm32"))]
215const MARKER_MAX_BYTES: u64 = 256;
216
217#[cfg(not(target_arch = "wasm32"))]
218fn read_active_account_file_in(app_data: &std::path::Path) -> Result<Option<String>, String> {
219 use std::io::Read;
220
221 let path = app_data.join(ACTIVE_ACCOUNT_FILE);
222 if !path.exists() {
223 return Ok(None);
224 }
225 if let Ok(meta) = std::fs::metadata(&path) {
229 if meta.len() > MARKER_MAX_BYTES {
230 return Ok(None);
231 }
232 } else {
233 return Ok(None);
234 }
235 let mut buf = String::new();
236 let file = match std::fs::File::open(&path) {
237 Ok(f) => f,
238 Err(_) => return Ok(None),
239 };
240 if file.take(MARKER_MAX_BYTES).read_to_string(&mut buf).is_err() {
241 return Ok(None);
242 }
243 let npub = buf.trim().to_string();
244 if !is_valid_npub(&npub) {
245 return Ok(None);
246 }
247 match std::fs::symlink_metadata(app_data.join(&npub)) {
254 Ok(meta) if meta.file_type().is_dir() && !meta.file_type().is_symlink() => {}
255 _ => return Ok(None),
256 }
257 Ok(Some(npub))
258}
259
260#[cfg(not(target_arch = "wasm32"))]
261fn write_active_account_file_in(app_data: &std::path::Path, npub: &str) -> Result<(), String> {
262 if !is_valid_npub(npub) {
263 return Err(format!("Invalid npub format: {}", npub));
264 }
265 if !app_data.exists() {
266 std::fs::create_dir_all(app_data)
267 .map_err(|e| format!("Failed to create app data dir: {}", e))?;
268 }
269 match std::fs::symlink_metadata(app_data.join(npub)) {
275 Ok(meta) if meta.file_type().is_dir() && !meta.file_type().is_symlink() => {}
276 _ => return Err(format!("Account directory missing or invalid: {}", npub)),
277 }
278 let tmp = app_data.join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE));
279 let final_path = app_data.join(ACTIVE_ACCOUNT_FILE);
280
281 let mut payload = String::with_capacity(npub.len() + 1);
284 payload.push_str(npub);
285 payload.push('\n');
286
287 if let Err(e) = std::fs::write(&tmp, payload.as_bytes()) {
288 let _ = std::fs::remove_file(&tmp);
289 return Err(format!("Failed to write active account temp file: {}", e));
290 }
291
292 let mut last_err = None;
295 for attempt in 0..3 {
296 match std::fs::rename(&tmp, &final_path) {
297 Ok(_) => return Ok(()),
298 Err(e) => {
299 last_err = Some(e);
300 if attempt < 2 {
301 std::thread::sleep(std::time::Duration::from_millis(50));
302 }
303 }
304 }
305 }
306
307 let _ = std::fs::remove_file(&tmp);
309 Err(format!(
310 "Failed to rename active account file: {}",
311 last_err.map(|e| e.to_string()).unwrap_or_default()
312 ))
313}
314
315#[cfg(not(target_arch = "wasm32"))]
316fn clear_active_account_file_in(app_data: &std::path::Path) -> Result<(), String> {
317 let path = app_data.join(ACTIVE_ACCOUNT_FILE);
318 if path.exists() {
319 std::fs::remove_file(&path)
320 .map_err(|e| format!("Failed to remove active account file: {}", e))?;
321 }
322 Ok(())
323}
324
325#[cfg(not(target_arch = "wasm32"))]
326fn list_account_npubs_in(app_data: &std::path::Path) -> Vec<String> {
327 let mut out = Vec::new();
328 if let Ok(entries) = std::fs::read_dir(app_data) {
329 for entry in entries.flatten() {
330 if entry.file_type().is_ok_and(|ft| ft.is_dir()) {
331 let name = entry.file_name().to_string_lossy().to_string();
332 if is_valid_npub(&name) {
333 out.push(name);
334 }
335 }
336 }
337 }
338 out
339}
340
341#[cfg(test)]
342mod active_account_tests {
343 use super::*;
344 use std::fs;
345 use tempfile::TempDir;
346
347 const VALID_A: &str = "npub16ye7evyevwnl0fc9hujsxf9zym72e063awn0pvde0huvpyec5nyq4dg4wn";
349 const VALID_B: &str = "npub12w73tzcqgpr2pcy4el5x60d2emeud4cyeeayynzqgg2fefzgytaqm4ktz3";
350
351 fn touch_account_dir(base: &std::path::Path, npub: &str) {
352 fs::create_dir_all(base.join(npub)).unwrap();
353 }
354
355 #[test]
356 fn npub_validator_accepts_canonical_form() {
357 assert!(is_valid_npub(VALID_A));
358 assert!(is_valid_npub(VALID_B));
359 }
360
361 #[test]
362 fn npub_validator_rejects_wrong_length() {
363 assert!(!is_valid_npub("npub1abc"));
364 assert!(!is_valid_npub(&format!("{}x", VALID_A)));
365 assert!(!is_valid_npub(""));
366 }
367
368 #[test]
369 fn npub_validator_rejects_missing_prefix() {
370 let body = &VALID_A[5..];
371 assert!(!is_valid_npub(&format!("nsec1{}", body)));
372 assert!(!is_valid_npub(&format!("xxxx1{}", body)));
373 }
374
375 #[test]
376 fn npub_validator_rejects_non_bech32_chars() {
377 for bad in ['1', 'b', 'i', 'o', 'B', 'I', 'O', '!', '*', ' '] {
379 let mut s = String::from(VALID_A);
380 s.replace_range(10..11, &bad.to_string());
381 assert!(!is_valid_npub(&s), "should reject character {:?}", bad);
382 }
383 }
384
385 #[test]
386 fn write_then_read_round_trips() {
387 let tmp = TempDir::new().unwrap();
388 touch_account_dir(tmp.path(), VALID_A);
389
390 write_active_account_file_in(tmp.path(), VALID_A).unwrap();
391 assert_eq!(
392 read_active_account_file_in(tmp.path()).unwrap(),
393 Some(VALID_A.to_string())
394 );
395 }
396
397 #[test]
398 fn write_rejects_invalid_npub() {
399 let tmp = TempDir::new().unwrap();
400 let err = write_active_account_file_in(tmp.path(), "npub1nope").unwrap_err();
401 assert!(err.contains("Invalid"));
402 assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
404 assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
405 }
406
407 #[test]
408 fn write_rejects_missing_account_dir() {
409 let tmp = TempDir::new().unwrap();
413 let err = write_active_account_file_in(tmp.path(), VALID_A).unwrap_err();
414 assert!(err.contains("missing or invalid"),
415 "expected account-dir-missing error, got: {}", err);
416 assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
418 assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
419 }
420
421 #[test]
422 fn write_rejects_symlinked_account_dir() {
423 let tmp = TempDir::new().unwrap();
428 let target = TempDir::new().unwrap();
429 let link = tmp.path().join(VALID_A);
430 #[cfg(unix)]
431 {
432 std::os::unix::fs::symlink(target.path(), &link).unwrap();
433 let err = write_active_account_file_in(tmp.path(), VALID_A).unwrap_err();
434 assert!(err.contains("missing or invalid"),
435 "expected symlink rejection, got: {}", err);
436 }
437 #[cfg(not(unix))]
440 let _ = (target, link);
441 }
442
443 #[test]
444 fn read_returns_none_when_marker_missing() {
445 let tmp = TempDir::new().unwrap();
446 assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
447 }
448
449 #[test]
450 fn read_returns_none_when_marker_is_garbage() {
451 let tmp = TempDir::new().unwrap();
452 fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), b"not-an-npub\n").unwrap();
453 assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
454 }
455
456 #[test]
457 fn read_returns_none_when_account_dir_missing() {
458 let tmp = TempDir::new().unwrap();
461 fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), VALID_A).unwrap();
462 assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
463 }
464
465 #[test]
466 fn read_returns_none_when_marker_oversized() {
467 let tmp = TempDir::new().unwrap();
471 let payload = vec![b'x'; (MARKER_MAX_BYTES + 1024) as usize];
472 fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), &payload).unwrap();
473 assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
474 }
475
476 #[test]
477 fn read_trims_whitespace() {
478 let tmp = TempDir::new().unwrap();
479 touch_account_dir(tmp.path(), VALID_A);
480 fs::write(
481 tmp.path().join(ACTIVE_ACCOUNT_FILE),
482 format!(" {}\n", VALID_A),
483 ).unwrap();
484 assert_eq!(
485 read_active_account_file_in(tmp.path()).unwrap(),
486 Some(VALID_A.to_string())
487 );
488 }
489
490 #[test]
491 fn read_handles_crlf_line_endings() {
492 let tmp = TempDir::new().unwrap();
493 touch_account_dir(tmp.path(), VALID_A);
494 fs::write(
495 tmp.path().join(ACTIVE_ACCOUNT_FILE),
496 format!("{}\r\n", VALID_A),
497 ).unwrap();
498 assert_eq!(
499 read_active_account_file_in(tmp.path()).unwrap(),
500 Some(VALID_A.to_string())
501 );
502 }
503
504 #[test]
505 fn npub_validator_rejects_uppercase_prefix() {
506 let upper = format!("NPUB1{}", &VALID_A[5..]);
507 assert!(!is_valid_npub(&upper));
508 }
509
510 #[test]
511 fn write_then_read_round_trips_with_newline() {
512 let tmp = TempDir::new().unwrap();
515 touch_account_dir(tmp.path(), VALID_A);
516 write_active_account_file_in(tmp.path(), VALID_A).unwrap();
517
518 let raw = fs::read_to_string(tmp.path().join(ACTIVE_ACCOUNT_FILE)).unwrap();
519 assert!(raw.ends_with('\n'));
520
521 assert_eq!(
522 read_active_account_file_in(tmp.path()).unwrap(),
523 Some(VALID_A.to_string())
524 );
525 }
526
527 #[test]
528 fn write_overwrites_previous_marker_atomically() {
529 let tmp = TempDir::new().unwrap();
530 touch_account_dir(tmp.path(), VALID_A);
531 touch_account_dir(tmp.path(), VALID_B);
532
533 write_active_account_file_in(tmp.path(), VALID_A).unwrap();
534 write_active_account_file_in(tmp.path(), VALID_B).unwrap();
535
536 assert_eq!(
537 read_active_account_file_in(tmp.path()).unwrap(),
538 Some(VALID_B.to_string())
539 );
540 assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
542 }
543
544 #[test]
545 fn clear_removes_marker_and_is_idempotent() {
546 let tmp = TempDir::new().unwrap();
547 touch_account_dir(tmp.path(), VALID_A);
548 write_active_account_file_in(tmp.path(), VALID_A).unwrap();
549 assert!(tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
550
551 clear_active_account_file_in(tmp.path()).unwrap();
552 assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
553
554 clear_active_account_file_in(tmp.path()).unwrap();
556 }
557
558 #[test]
559 fn list_npubs_finds_valid_dirs_only() {
560 let tmp = TempDir::new().unwrap();
561 touch_account_dir(tmp.path(), VALID_A);
562 touch_account_dir(tmp.path(), VALID_B);
563 fs::create_dir_all(tmp.path().join("npub1tooshort")).unwrap();
565 fs::create_dir_all(tmp.path().join("not-an-npub-dir")).unwrap();
566 fs::create_dir_all(tmp.path().join("tor")).unwrap();
567 fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), VALID_A).unwrap();
568
569 let mut found = list_account_npubs_in(tmp.path());
570 found.sort();
571 let mut expected = vec![VALID_A.to_string(), VALID_B.to_string()];
572 expected.sort();
573 assert_eq!(found, expected);
574 }
575
576 #[test]
577 fn list_npubs_skips_dirs_containing_invalid_chars() {
578 let tmp = TempDir::new().unwrap();
579 let mut bogus = String::from(VALID_A);
581 bogus.replace_range(10..11, "b");
582 fs::create_dir_all(tmp.path().join(&bogus)).unwrap();
583
584 let found = list_account_npubs_in(tmp.path());
585 assert!(found.is_empty(), "found unexpected entries: {:?}", found);
586 }
587
588 #[test]
589 fn write_creates_app_data_dir_if_missing() {
590 let tmp = TempDir::new().unwrap();
591 let nested = tmp.path().join("does/not/exist/yet");
592 std::fs::create_dir_all(&nested).unwrap();
596 touch_account_dir(&nested, VALID_A);
597 write_active_account_file_in(&nested, VALID_A).unwrap();
598 assert!(nested.join(ACTIVE_ACCOUNT_FILE).exists());
599 }
600}
601
602pub struct Session {
620 id: u64,
627 db_path: Option<PathBuf>,
631 read_pool: Mutex<Vec<rusqlite::Connection>>,
632 write_conn: Mutex<Option<rusqlite::Connection>>,
633 chat_state: Arc<tokio::sync::Mutex<crate::state::ChatState>>,
636 scoped: RwLock<std::collections::HashMap<std::any::TypeId, Arc<dyn std::any::Any + Send + Sync>>>,
638 stopped: SessionStop,
640}
641
642#[derive(Default)]
645struct SessionStop {
646 flag: std::sync::atomic::AtomicBool,
647 wake: tokio::sync::Notify,
648}
649
650impl Session {
651 fn empty() -> Arc<Self> {
652 Arc::new(Session {
653 id: next_session_id(),
654 db_path: None,
655 read_pool: Mutex::new(Vec::new()),
656 write_conn: Mutex::new(None),
657 chat_state: Arc::new(tokio::sync::Mutex::new(crate::state::ChatState::new())),
658 scoped: RwLock::new(std::collections::HashMap::new()),
659 stopped: SessionStop::default(),
660 })
661 }
662
663 fn bound(db_path: PathBuf) -> Arc<Self> {
666 Arc::new(Session {
667 id: next_session_id(),
668 db_path: Some(db_path),
669 read_pool: Mutex::new(Vec::new()),
670 write_conn: Mutex::new(None),
671 chat_state: Arc::new(tokio::sync::Mutex::new(crate::state::ChatState::new())),
672 scoped: RwLock::new(std::collections::HashMap::new()),
673 stopped: SessionStop::default(),
674 })
675 }
676
677 fn rebound(&self, db_path: PathBuf) -> Arc<Self> {
688 Arc::new(Session {
689 id: self.id,
690 db_path: Some(db_path),
691 read_pool: Mutex::new(Vec::new()),
692 write_conn: Mutex::new(None),
693 chat_state: self.chat_state.clone(),
694 scoped: RwLock::new(self.scoped.read().unwrap_or_else(|e| e.into_inner()).clone()),
695 stopped: SessionStop::default(),
696 })
697 }
698
699 pub fn scoped<K: 'static, T: Default + Send + Sync + 'static>(self: &Arc<Self>) -> Arc<T> {
720 let key = std::any::TypeId::of::<(K, T)>();
721 if let Some(existing) = self.scoped.read().unwrap_or_else(|e| e.into_inner()).get(&key) {
722 return existing.clone().downcast::<T>().expect("keyed by its own TypeId");
723 }
724 let mut map = self.scoped.write().unwrap_or_else(|e| e.into_inner());
725 map.entry(key)
727 .or_insert_with(|| Arc::new(T::default()) as Arc<dyn std::any::Any + Send + Sync>)
728 .clone()
729 .downcast::<T>()
730 .expect("keyed by its own TypeId")
731 }
732
733 pub fn id(&self) -> u64 {
737 self.id
738 }
739
740 pub fn stopped(&self) -> bool {
750 self.stopped.flag.load(std::sync::atomic::Ordering::Acquire)
751 }
752
753 pub async fn on_stop(&self) {
756 loop {
757 let waiting = self.stopped.wake.notified();
758 if self.stopped() {
759 return;
760 }
761 waiting.await;
762 if self.stopped() {
763 return;
764 }
765 }
766 }
767
768 fn stop(&self) {
769 self.stopped.flag.store(true, std::sync::atomic::Ordering::Release);
770 self.stopped.wake.notify_waiters();
771 }
772
773 pub fn is_live(&self) -> bool {
779 self.id == CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner()).id
780 }
781
782 pub fn chat_state(&self) -> Arc<tokio::sync::Mutex<crate::state::ChatState>> {
788 self.chat_state.clone()
789 }
790
791 fn path(&self) -> Result<PathBuf, String> {
795 match &self.db_path {
796 Some(p) => Ok(p.clone()),
797 None => get_current_db_path(),
798 }
799 }
800
801 pub fn acquire_read(self: &Arc<Self>) -> Result<ConnectionGuard, String> {
804 if let Ok(mut pool) = self.read_pool.lock() {
805 if let Some(conn) = pool.pop() {
806 return Ok(ConnectionGuard::new(conn, self.clone()));
807 }
808 }
809 let conn = create_connection(&self.path()?)?;
810 Ok(ConnectionGuard::new(conn, self.clone()))
811 }
812
813 pub fn acquire_write(self: &Arc<Self>) -> Result<WriteConnectionGuard, String> {
816 {
817 let mut slot = self.write_conn.lock().unwrap_or_else(|e| e.into_inner());
818 if let Some(conn) = slot.take() {
819 return Ok(WriteConnectionGuard::new(conn, self.clone()));
820 }
821 }
822 let conn = create_connection(&self.path()?)?;
823 Ok(WriteConnectionGuard::new(conn, self.clone()))
824 }
825}
826
827static CURRENT_SESSION: LazyLock<RwLock<Arc<Session>>> = LazyLock::new(|| RwLock::new(Session::empty()));
830
831tokio::task_local! {
832 static TASK_SESSION: Arc<Session>;
838}
839
840pub fn current_session() -> Arc<Session> {
849 TASK_SESSION
850 .try_with(Arc::clone)
851 .unwrap_or_else(|_| CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner()).clone())
852}
853
854pub fn scoped<F: std::future::Future>(fut: F) -> impl std::future::Future<Output = F::Output> {
876 TASK_SESSION.scope(current_session(), Box::pin(fut))
877}
878
879pub fn scoped_result<T, E, F>(fut: F) -> impl std::future::Future<Output = Result<T, E>>
886where
887 F: std::future::Future<Output = Result<T, E>>,
888 E: From<String>,
889{
890 let session = current_session();
891 let id = session.id;
892 let bound = TASK_SESSION.scope(session, Box::pin(fut));
895 async move {
896 let out = bound.await;
897 if id != CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner()).id {
898 return Err(E::from("account changed during the operation".to_string()));
899 }
900 out
901 }
902}
903
904pub fn with_session<F: std::future::Future>(
910 session: Arc<Session>,
911 fut: F,
912) -> impl std::future::Future<Output = F::Output> {
913 TASK_SESSION.scope(session, Box::pin(fut))
914}
915
916pub fn spawn_bound<F>(fut: F) -> crate::rt::JoinHandle<F::Output>
923where
924 F: std::future::Future + crate::rt::MaybeSend + 'static,
925 F::Output: crate::rt::MaybeSend + 'static,
926{
927 let session = current_session();
928 crate::rt::spawn(TASK_SESSION.scope(session, fut))
930}
931
932impl std::fmt::Debug for Session {
933 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
934 f.debug_struct("Session").field("id", &self.id).field("db", &self.db_path).finish()
935 }
936}
937
938pub fn session_stopped() -> bool {
943 current_session().stopped()
944}
945
946pub fn current_session_id() -> u64 {
948 current_session().id
949}
950
951pub fn session_is_live() -> bool {
959 current_session().id == CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner()).id
960}
961
962fn next_session_id() -> u64 {
963 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
964 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
965}
966
967fn replace_session() {
971 install(Session::empty());
972}
973
974fn install(next: Arc<Session>) {
976 let mut current = CURRENT_SESSION.write().unwrap_or_else(|e| e.into_inner());
977 if current.id != next.id {
978 current.stop();
979 }
980 *current = next;
981}
982
983pub struct ConnectionGuard {
990 conn: Option<rusqlite::Connection>,
991 session: Arc<Session>,
992}
993
994impl ConnectionGuard {
995 fn new(conn: rusqlite::Connection, session: Arc<Session>) -> Self {
996 Self { conn: Some(conn), session }
997 }
998}
999
1000impl Deref for ConnectionGuard {
1001 type Target = rusqlite::Connection;
1002 fn deref(&self) -> &Self::Target { self.conn.as_ref().expect("Connection already taken") }
1003}
1004
1005impl DerefMut for ConnectionGuard {
1006 fn deref_mut(&mut self) -> &mut Self::Target { self.conn.as_mut().expect("Connection already taken") }
1007}
1008
1009impl Drop for ConnectionGuard {
1010 fn drop(&mut self) {
1011 if let Some(conn) = self.conn.take() {
1012 if let Ok(mut pool) = self.session.read_pool.lock() {
1013 pool.push(conn);
1014 }
1015 }
1016 }
1017}
1018
1019pub struct WriteConnectionGuard {
1021 conn: Option<rusqlite::Connection>,
1022 session: Arc<Session>,
1023}
1024
1025impl WriteConnectionGuard {
1026 fn new(conn: rusqlite::Connection, session: Arc<Session>) -> Self {
1027 Self { conn: Some(conn), session }
1028 }
1029}
1030
1031impl Deref for WriteConnectionGuard {
1032 type Target = rusqlite::Connection;
1033 fn deref(&self) -> &Self::Target { self.conn.as_ref().expect("Write connection already taken") }
1034}
1035
1036impl DerefMut for WriteConnectionGuard {
1037 fn deref_mut(&mut self) -> &mut Self::Target { self.conn.as_mut().expect("Write connection already taken") }
1038}
1039
1040impl Drop for WriteConnectionGuard {
1041 fn drop(&mut self) {
1042 if let Some(conn) = self.conn.take() {
1043 if let Ok(mut slot) = self.session.write_conn.lock() {
1046 if slot.is_none() {
1047 *slot = Some(conn);
1048 }
1049 }
1050 }
1051 }
1052}
1053
1054pub fn account_dir(npub: &str) -> Result<PathBuf, String> {
1068 if !is_valid_npub(npub) {
1069 return Err(format!("Invalid npub format: {}", npub));
1070 }
1071 Ok(get_app_data_dir()?.join(npub))
1072}
1073
1074pub fn current_account_dir() -> Result<PathBuf, String> {
1077 let db = current_session().path()?;
1078 db.parent().map(PathBuf::from).ok_or_else(|| "account directory unknown".to_string())
1079}
1080
1081fn get_current_db_path() -> Result<PathBuf, String> {
1082 let npub = get_current_account()?;
1083 Ok(account_dir(&npub)?.join("vector.db"))
1084}
1085
1086fn create_connection(path: &PathBuf) -> Result<rusqlite::Connection, String> {
1095 const OPEN_RETRIES: u32 = 4;
1096 let mut last_err = String::new();
1097 for attempt in 0..OPEN_RETRIES {
1098 match open_connection(path) {
1099 Ok(conn) => return Ok(conn),
1100 Err(e) if e.contains("locked") || e.contains("busy") => {
1101 last_err = e;
1102 #[cfg(not(target_arch = "wasm32"))]
1104 std::thread::sleep(std::time::Duration::from_millis(50 * u64::from(attempt + 1)));
1105 #[cfg(target_arch = "wasm32")]
1106 let _ = attempt;
1107 }
1108 Err(e) => return Err(e),
1109 }
1110 }
1111 Err(last_err)
1112}
1113
1114#[cfg(target_arch = "wasm32")]
1118static RELAXED_DURABILITY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1119
1120#[cfg(target_arch = "wasm32")]
1121pub fn set_relaxed_durability(on: bool) {
1122 RELAXED_DURABILITY.store(on, std::sync::atomic::Ordering::Relaxed);
1123}
1124
1125fn durability_pragmas() -> &'static str {
1126 #[cfg(target_arch = "wasm32")]
1127 if RELAXED_DURABILITY.load(std::sync::atomic::Ordering::Relaxed) {
1128 return "PRAGMA journal_mode=MEMORY; PRAGMA synchronous=OFF;";
1129 }
1130 "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;"
1131}
1132
1133fn open_connection(path: &PathBuf) -> Result<rusqlite::Connection, String> {
1134 let conn = rusqlite::Connection::open(path)
1135 .map_err(|e| format!("Failed to open database: {}", e))?;
1136
1137 conn.execute_batch("PRAGMA busy_timeout=5000;")
1142 .map_err(|e| format!("Failed to set busy_timeout: {}", e))?;
1143
1144 conn.execute_batch(&format!(
1148 "{} PRAGMA foreign_keys=ON; PRAGMA cache_size=-16000; PRAGMA temp_store=MEMORY;",
1149 durability_pragmas()
1150 ))
1151 .map_err(|e| format!("Failed to set pragmas: {}", e))?;
1152 conn.set_prepared_statement_cache_capacity(64);
1155
1156 #[cfg(target_pointer_width = "64")]
1159 let _ = conn.execute_batch("PRAGMA mmap_size=268435456;");
1160
1161 Ok(conn)
1162}
1163
1164pub fn get_db_connection_guard_static() -> Result<ConnectionGuard, String> {
1166 current_session().acquire_read()
1167}
1168
1169#[cfg(test)]
1175pub(crate) static DB_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
1176
1177pub fn get_write_connection_guard_static() -> Result<WriteConnectionGuard, String> {
1179 current_session().acquire_write()
1180}
1181
1182const LAST_APP_VERSION_KEY: &str = "last_app_version";
1188
1189#[derive(Debug, Clone, serde::Serialize)]
1195pub struct DowngradeBlock {
1196 pub db_schema: u32,
1198 pub supported_schema: u32,
1200 pub last_app_version: Option<String>,
1202}
1203
1204impl std::fmt::Display for DowngradeBlock {
1205 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1206 write!(f, "This account was last opened by a newer version of Vector")?;
1207 if let Some(version) = &self.last_app_version {
1208 write!(f, " ({version})")?;
1209 }
1210 write!(
1211 f,
1212 ". Its database is at schema {} and this build only understands {}. \
1213 Opening it would corrupt your messages, so Vector has stopped. \
1214 Reinstall the newer version to continue.",
1215 self.db_schema, self.supported_schema
1216 )
1217 }
1218}
1219
1220fn downgrade_block(conn: &rusqlite::Connection) -> Option<DowngradeBlock> {
1222 let db_schema = schema::applied_migration_high_water(conn);
1223 if db_schema <= schema::HIGHEST_MIGRATION_ID {
1224 return None;
1225 }
1226 Some(DowngradeBlock {
1227 db_schema,
1228 supported_schema: schema::HIGHEST_MIGRATION_ID,
1229 last_app_version: conn
1230 .query_row(
1231 "SELECT value FROM settings WHERE key = ?1",
1232 rusqlite::params![LAST_APP_VERSION_KEY],
1233 |row| row.get::<_, String>(0),
1234 )
1235 .ok(),
1236 })
1237}
1238
1239pub fn inspect_downgrade(npub: &str) -> Result<Option<DowngradeBlock>, String> {
1245 let db_path = account_dir(npub)?.join("vector.db");
1246 #[cfg(target_arch = "wasm32")]
1248 let exists = webfs::db_exists(&db_path);
1249 #[cfg(not(target_arch = "wasm32"))]
1250 let exists = db_path.exists();
1251 if !exists {
1252 return Ok(None);
1253 }
1254 let conn = create_connection(&db_path)?;
1255 Ok(downgrade_block(&conn))
1256}
1257
1258pub fn init_database(npub: &str) -> Result<(), String> {
1264 let profile_dir = account_dir(npub)?;
1265
1266 #[cfg(not(target_arch = "wasm32"))]
1267 if !profile_dir.exists() {
1268 std::fs::create_dir_all(&profile_dir)
1269 .map_err(|e| format!("Failed to create profile directory: {}", e))?;
1270 }
1271 #[cfg(target_arch = "wasm32")]
1272 webfs::register_account(get_app_data_dir()?, npub)?;
1273
1274 let db_path = profile_dir.join("vector.db");
1275 let mut conn = create_connection(&db_path)?;
1276
1277 conn.execute_batch("PRAGMA busy_timeout=30000;")
1282 .map_err(|e| format!("Failed to set migration busy_timeout: {}", e))?;
1283
1284 if let Some(block) = downgrade_block(&conn) {
1288 return Err(block.to_string());
1289 }
1290
1291 conn.execute_batch(schema::SQL_SCHEMA)
1292 .map_err(|e| format!("Failed to create schema: {}", e))?;
1293
1294 schema::run_migrations(&mut conn)?;
1296
1297 if let Some(version) = APP_VERSION.get() {
1301 let _ = conn.execute(
1302 "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
1303 rusqlite::params![LAST_APP_VERSION_KEY, version],
1304 );
1305 }
1306
1307 let _ = conn.execute_batch("PRAGMA optimize=0x10002;");
1312
1313 {
1317 let mut stmt = conn.prepare("SELECT event_id FROM deleted_messages")
1318 .map_err(|e| format!("tombstone seed prepare: {}", e))?;
1319 let ids: Vec<String> = stmt.query_map([], |row| row.get::<_, String>(0))
1320 .map_err(|e| format!("tombstone seed query: {}", e))?
1321 .filter_map(|r| r.ok())
1322 .collect();
1323 drop(stmt);
1324 crate::state::seed_message_tombstones(ids);
1325 }
1326
1327 let mls_dir = profile_dir.join("mls");
1333 if mls_dir.exists() {
1334 match std::fs::remove_dir_all(&mls_dir) {
1335 Ok(()) => crate::log_info!("[db] purged orphaned MLS store for account"),
1336 Err(e) => crate::log_warn!("[db] could not purge orphaned MLS store: {}", e),
1337 }
1338 }
1339
1340 let session = {
1348 let next = {
1349 let current = CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner());
1350 match current.db_path.as_deref() {
1351 Some(p) if p != db_path => Session::bound(db_path.clone()),
1352 _ => current.rebound(db_path.clone()),
1353 }
1354 };
1355 install(next.clone());
1356 next
1357 };
1358
1359 if let Ok(mut pool) = session.read_pool.lock() {
1361 for _ in 0..4 {
1362 if let Ok(c) = create_connection(&db_path) {
1363 pool.push(c);
1364 }
1365 }
1366 }
1367
1368 let write_conn = create_connection(&db_path)?;
1370 *session.write_conn.lock().unwrap_or_else(|e| e.into_inner()) = Some(write_conn);
1371
1372 #[cfg(feature = "tor")]
1378 {
1379 let enabled = create_connection(&db_path)
1380 .ok()
1381 .and_then(|c| {
1382 c.query_row(
1383 "SELECT value FROM settings WHERE key = 'tor_enabled'",
1384 [],
1385 |row| row.get::<_, String>(0),
1386 )
1387 .ok()
1388 })
1389 .map(|v| v == "1" || v == "true")
1390 .unwrap_or(false);
1391 crate::tor::set_tor_enabled_pref(enabled);
1392 }
1393
1394 crate::notify::warm();
1398
1399 Ok(())
1400}
1401
1402pub fn close_database() {
1408 replace_session();
1409}
1410
1411pub fn optimize_database() {
1416 let session = current_session();
1417 let guard = session.write_conn.lock().unwrap_or_else(|e| e.into_inner());
1418 if let Some(conn) = guard.as_ref() {
1419 let _ = conn.execute_batch("PRAGMA optimize;");
1420 }
1421}
1422
1423pub fn get_accounts() -> Result<Vec<String>, String> {
1425 let app_data = get_app_data_dir()?;
1426 #[cfg(target_arch = "wasm32")]
1427 return Ok(list_account_npubs_in(app_data)
1428 .into_iter()
1429 .filter(|npub| webfs::db_exists(&app_data.join(npub).join("vector.db")))
1430 .collect());
1431 #[cfg(not(target_arch = "wasm32"))]
1432 {
1433 let mut accounts = Vec::new();
1434
1435 if let Ok(entries) = std::fs::read_dir(app_data) {
1436 for entry in entries.flatten() {
1437 if entry.file_type().is_ok_and(|ft| ft.is_dir()) {
1438 let name = entry.file_name().to_string_lossy().to_string();
1439 if name.starts_with("npub1") {
1440 if entry.path().join("vector.db").exists() {
1442 accounts.push(name);
1443 }
1444 }
1445 }
1446 }
1447 }
1448
1449 Ok(accounts)
1450 }
1451}
1452
1453pub fn get_profile_directory(npub: &str) -> Result<PathBuf, String> {
1455 if !npub.starts_with("npub1") {
1456 return Err(format!("Invalid npub format: {}", npub));
1457 }
1458 let dir = account_dir(npub)?;
1459 #[cfg(not(target_arch = "wasm32"))]
1460 if !dir.exists() {
1461 std::fs::create_dir_all(&dir)
1462 .map_err(|e| format!("Failed to create profile directory: {}", e))?;
1463 }
1464 Ok(dir)
1465}
1466
1467pub fn get_database_path(npub: &str) -> Result<PathBuf, String> {
1469 Ok(get_profile_directory(npub)?.join("vector.db"))
1470}
1471
1472pub fn clear_id_caches() {
1481 id_cache::clear_id_caches();
1482 community::clear_banlist_cache();
1483 community::clear_channel_community_cache();
1484}
1485
1486#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1491#[repr(u8)]
1492pub enum SystemEventType {
1493 MemberLeft = 0,
1494 MemberJoined = 1,
1495 MemberRemoved = 2,
1496 WallpaperChanged = 3,
1497}
1498
1499impl SystemEventType {
1500 pub fn display_message(&self, display_name: &str) -> String {
1501 match self {
1502 SystemEventType::MemberLeft => format!("{} has left", display_name),
1503 SystemEventType::MemberJoined => format!("{} has joined", display_name),
1504 SystemEventType::MemberRemoved => format!("{} was removed", display_name),
1505 SystemEventType::WallpaperChanged => format!("{} changed the wallpaper", display_name),
1506 }
1507 }
1508
1509 pub fn as_u8(&self) -> u8 { *self as u8 }
1510}
1511
1512#[cfg(test)]
1513mod pool_generation_tests {
1514 use super::*;
1515
1516 fn fake_conn() -> rusqlite::Connection {
1520 rusqlite::Connection::open_in_memory().unwrap()
1521 }
1522
1523 #[test]
1524 fn close_database_installs_a_fresh_session() {
1525 let _guard = DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1529 let before = current_session();
1530 close_database();
1531 let after = current_session();
1532 assert!(
1533 !Arc::ptr_eq(&before, &after),
1534 "close_database must install a NEW session — the old one is what in-flight guards return to"
1535 );
1536 }
1537
1538 #[test]
1539 fn a_guard_returns_its_connection_to_its_own_session() {
1540 let session = Session::empty();
1541 drop(ConnectionGuard::new(fake_conn(), session.clone()));
1542 assert_eq!(session.read_pool.lock().unwrap().len(), 1, "the connection goes home");
1543 }
1544
1545 #[test]
1546 fn a_guard_outstanding_across_a_swap_returns_to_the_session_it_came_from() {
1547 let old = Session::empty();
1553 let new_session = Session::empty();
1554
1555 let guard = ConnectionGuard::new(fake_conn(), old.clone());
1556 drop(guard);
1558
1559 assert_eq!(old.read_pool.lock().unwrap().len(), 1, "returned to the session it was taken from");
1560 assert_eq!(new_session.read_pool.lock().unwrap().len(), 0, "never reachable from the new account");
1561 }
1562
1563 #[test]
1564 fn a_pool_miss_after_a_swap_opens_the_sessions_own_database() {
1565 let dir = tempfile::tempdir().unwrap();
1570 let path_a = dir.path().join("a.db");
1571 let path_b = dir.path().join("b.db");
1572
1573 let session_a = Session::bound(path_a.clone());
1574 let _unrelated = Session::bound(path_b.clone());
1575
1576 let guard = session_a.acquire_read().expect("acquire against the held session");
1578 let opened = guard.path().expect("a file-backed connection").to_string();
1580 assert!(
1581 opened.ends_with("a.db") && !opened.ends_with("b.db"),
1582 "a miss opens the session's OWN database, never the incoming account's (opened {opened})"
1583 );
1584 assert!(!Arc::ptr_eq(&session_a, ¤t_session()), "the live session really did move on");
1585 }
1586
1587 async fn bound_to<F: std::future::Future>(session: Arc<Session>, fut: F) -> F::Output {
1595 TASK_SESSION.scope(session, fut).await
1596 }
1597
1598 #[test]
1603 fn per_account_tasks_are_spawned_bound_to_their_account() {
1604 crate::spawn_audit::assert_all_spawns_bound(std::path::Path::new(env!("CARGO_MANIFEST_DIR")), &[]);
1605 }
1606
1607 #[tokio::test]
1608 async fn a_bound_task_keeps_its_account_across_a_swap() {
1609 let dir = tempfile::tempdir().unwrap();
1614 let a = Session::bound(dir.path().join("a.db"));
1615
1616 let seen = bound_to(a.clone(), async {
1617 crate::rt::yield_now().await;
1618 current_session()
1619 })
1620 .await;
1621
1622 assert!(Arc::ptr_eq(&seen, &a), "the task still sees the account it started under");
1623 assert!(!Arc::ptr_eq(&seen, ¤t_session()), "the live account is not reachable from it");
1624 }
1625
1626 #[tokio::test]
1627 async fn a_bound_tasks_chat_writes_cannot_reach_the_new_account() {
1628 use crate::chat::{Chat, ChatType};
1633 let dir = tempfile::tempdir().unwrap();
1634 let a = Session::bound(dir.path().join("a.db"));
1635 let live_before = current_session().chat_state().lock().await.chats.len();
1636
1637 bound_to(a.clone(), async {
1638 crate::rt::yield_now().await;
1639 crate::state::STATE.lock().await.chats.push(Chat::new("a-chat".into(), ChatType::DirectMessage, Vec::new()));
1640 })
1641 .await;
1642
1643 assert_eq!(
1644 a.chat_state().lock().await.chats.len(),
1645 1,
1646 "it landed in the state of the account the task began under"
1647 );
1648 assert_eq!(
1649 current_session().chat_state().lock().await.chats.len(),
1650 live_before,
1651 "and nothing reached the account on screen"
1652 );
1653 }
1654
1655 #[tokio::test]
1656 async fn a_bound_task_cannot_publish_through_the_new_accounts_client() {
1657 use nostr_sdk::prelude::*;
1662 let dir = tempfile::tempdir().unwrap();
1663 let a = Session::bound(dir.path().join("a.db"));
1664 let a_identity = Keys::generate().public_key();
1665
1666 let seen = bound_to(a.clone(), async move {
1667 crate::state::set_my_public_key(a_identity);
1668 crate::rt::yield_now().await;
1669 crate::state::my_public_key()
1670 })
1671 .await;
1672
1673 assert_eq!(seen, Some(a_identity), "the task signs as the account it began under");
1674 assert_ne!(crate::state::my_public_key(), Some(a_identity), "and the live account is someone else");
1675 }
1676
1677 #[test]
1678 fn binding_an_unbound_session_to_a_database_keeps_what_it_holds() {
1679 let dir = tempfile::tempdir().unwrap();
1683 let staging = Session::empty();
1684 let held = staging.scoped::<Session, Mutex<u8>>();
1685 *held.lock().unwrap() = 7;
1686
1687 let promoted = staging.rebound(dir.path().join("a.db"));
1688 assert_eq!(*promoted.scoped::<Session, Mutex<u8>>().lock().unwrap(), 7, "the login survives being bound");
1689 assert!(promoted.db_path.is_some(), "and it now has a database");
1690 assert_eq!(promoted.id, staging.id, "and it is still the same account, so its tasks keep painting");
1691 }
1692
1693 #[tokio::test]
1694 async fn a_bound_task_paints_nothing_into_the_account_on_screen() {
1695 let dir = tempfile::tempdir().unwrap();
1699 let previous = Session::bound(dir.path().join("previous.db"));
1700 assert!(session_is_live(), "work for the account on screen paints");
1701
1702 let painted = bound_to(previous, async {
1703 crate::rt::yield_now().await;
1704 session_is_live()
1705 })
1706 .await;
1707
1708 assert!(!painted, "a task bound to another account paints nothing");
1709 assert!(session_is_live(), "and the account now on screen still does");
1710 }
1711
1712 #[tokio::test]
1713 async fn switching_accounts_tells_the_previous_one_to_stop() {
1714 let _serialized = DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1718 let previous = current_session();
1719 assert!(!previous.stopped(), "running work is not told to stop");
1720
1721 let waiter = { let p = previous.clone(); crate::rt::spawn(async move { p.on_stop().await }) };
1723 crate::rt::yield_now().await;
1724 assert!(!waiter.is_finished(), "nothing to report while the account is current");
1725
1726 close_database();
1727
1728 assert!(previous.stopped(), "the outgoing account is told to stop");
1729 assert!(!current_session().stopped(), "the incoming one is not");
1730 crate::rt::time::timeout(std::time::Duration::from_secs(5), waiter)
1731 .await
1732 .expect("on_stop resolves on the switch")
1733 .expect("without panicking");
1734 }
1735
1736 #[test]
1737 fn re_initialising_the_same_account_does_not_tell_it_to_stop() {
1738 let dir = tempfile::tempdir().unwrap();
1742 let staging = Session::empty();
1743 let promoted = staging.rebound(dir.path().join("a.db"));
1744 assert!(!staging.stopped(), "binding a session is not switching away from it");
1745 assert_eq!(promoted.id, staging.id);
1746 }
1747
1748 #[test]
1758 fn binding_a_future_does_not_embed_it() {
1759 let fat = async {
1761 let block = [0u8; 8192];
1762 crate::rt::yield_now().await;
1763 block[0]
1764 };
1765 let fat_size = std::mem::size_of_val(&fat);
1766 assert!(fat_size >= 8192, "the body really is large ({fat_size})");
1767
1768 let bound = scoped(fat);
1769 let bound_size = std::mem::size_of_val(&bound);
1770 assert!(
1771 bound_size < 1024,
1772 "binding must cost a pointer, not a copy of the body \
1773 (body {fat_size} bytes, bound {bound_size})"
1774 );
1775 }
1776
1777 #[test]
1778 fn a_dropped_session_closes_its_pool() {
1779 let session = Session::empty();
1781 drop(ConnectionGuard::new(fake_conn(), session.clone()));
1782 assert_eq!(Arc::strong_count(&session), 1, "the guard released its reference");
1783 drop(session); }
1785
1786 #[test]
1787 fn a_stale_write_guard_cannot_clobber_the_new_accounts_connection() {
1788 let old = Session::empty();
1792 let new_session = Session::empty();
1793
1794 let stale_guard = WriteConnectionGuard::new(fake_conn(), old.clone());
1795 *new_session.write_conn.lock().unwrap() = Some(fake_conn());
1797
1798 drop(stale_guard);
1799
1800 assert!(
1801 new_session.write_conn.lock().unwrap().is_some(),
1802 "the new account's write connection is untouched"
1803 );
1804 assert!(
1805 old.write_conn.lock().unwrap().is_some(),
1806 "the stale guard returned to its own session's slot"
1807 );
1808 }
1809
1810 #[test]
1811 fn a_new_accounts_empty_write_slot_stays_empty() {
1812 let old = Session::empty();
1816 let new_session = Session::empty();
1817
1818 let stale_guard = WriteConnectionGuard::new(fake_conn(), old.clone());
1819 drop(stale_guard);
1820
1821 assert!(
1822 new_session.write_conn.lock().unwrap().is_none(),
1823 "the new account's slot is untouched by the previous account's guard"
1824 );
1825 }
1826}
1827
1828#[cfg(test)]
1829mod downgrade_tests {
1830 use super::*;
1831
1832 fn test_account() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, String) {
1833 let guard = DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1834 close_database();
1835 clear_id_caches();
1836 let tmp = tempfile::tempdir().unwrap();
1837 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(61_000);
1842 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1843 const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
1844 let mut acct = String::from("npub1");
1845 let mut v = n as usize;
1846 for _ in 0..58 {
1847 acct.push(B[v % 32] as char);
1848 v = v / 32 + 7;
1849 }
1850 set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
1851 set_current_account(acct.clone()).unwrap();
1852 (tmp, guard, acct)
1853 }
1854
1855 #[tokio::test]
1856 async fn re_initialising_the_same_account_keeps_its_loaded_state() {
1857 use crate::chat::{Chat, ChatType};
1862 let (_dir, _lock, acct) = test_account();
1863 init_database(&acct).unwrap();
1864 crate::state::STATE.lock().await.chats.push(Chat::new("kept".into(), ChatType::DirectMessage, Vec::new()));
1865
1866 init_database(&acct).unwrap();
1867 assert_eq!(crate::state::STATE.lock().await.chats.len(), 1, "same account, same in-memory state");
1868 }
1869
1870 #[test]
1872 fn an_equal_schema_opens_normally() {
1873 let (_tmp, _guard, acct) = test_account();
1874 init_database(&acct).unwrap();
1875 assert!(inspect_downgrade(&acct).unwrap().is_none());
1876 init_database(&acct).unwrap();
1878 assert!(inspect_downgrade(&acct).unwrap().is_none());
1879 }
1880
1881 #[test]
1883 fn a_missing_database_is_not_a_downgrade() {
1884 let (_tmp, _guard, acct) = test_account();
1885 assert!(inspect_downgrade(&acct).unwrap().is_none());
1886 assert!(!account_dir(&acct).unwrap().join("vector.db").exists());
1887 }
1888
1889 #[test]
1890 fn a_newer_schema_blocks_the_open_and_names_the_build() {
1891 let (_tmp, _guard, acct) = test_account();
1892 init_database(&acct).unwrap();
1893
1894 let db_path = account_dir(&acct).unwrap().join("vector.db");
1896 {
1897 let conn = create_connection(&db_path).unwrap();
1898 conn.execute(
1899 "INSERT OR REPLACE INTO schema_migrations (id, applied_at) VALUES (?1, 0)",
1900 rusqlite::params![schema::HIGHEST_MIGRATION_ID + 1],
1901 )
1902 .unwrap();
1903 conn.execute(
1904 "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
1905 rusqlite::params![LAST_APP_VERSION_KEY, "9.9.9"],
1906 )
1907 .unwrap();
1908 }
1909 close_database();
1910
1911 let block = inspect_downgrade(&acct)
1912 .unwrap()
1913 .expect("a higher migration id must read as a downgrade");
1914 assert_eq!(block.db_schema, schema::HIGHEST_MIGRATION_ID + 1);
1915 assert_eq!(block.supported_schema, schema::HIGHEST_MIGRATION_ID);
1916 assert_eq!(block.last_app_version.as_deref(), Some("9.9.9"));
1917
1918 let err = init_database(&acct).unwrap_err();
1919 assert!(err.contains("9.9.9"), "must name the newer build: {err}");
1920 }
1921
1922 #[test]
1925 fn a_blocked_open_writes_nothing() {
1926 let (_tmp, _guard, acct) = test_account();
1927 init_database(&acct).unwrap();
1928 let db_path = account_dir(&acct).unwrap().join("vector.db");
1929 {
1930 let conn = create_connection(&db_path).unwrap();
1931 conn.execute(
1932 "INSERT OR REPLACE INTO schema_migrations (id, applied_at) VALUES (?1, 0)",
1933 rusqlite::params![schema::HIGHEST_MIGRATION_ID + 5],
1934 )
1935 .unwrap();
1936 conn.execute("DROP TABLE IF EXISTS settings", []).unwrap();
1937 }
1938 close_database();
1939
1940 assert!(init_database(&acct).is_err());
1941
1942 let conn = create_connection(&db_path).unwrap();
1944 let exists: bool = conn
1945 .query_row(
1946 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='settings'",
1947 [],
1948 |_| Ok(true),
1949 )
1950 .unwrap_or(false);
1951 assert!(!exists, "a blocked open must not write to the database");
1952 }
1953}