use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock, LazyLock, RwLock};
use std::sync::atomic::{AtomicU64, Ordering};
use std::ops::{Deref, DerefMut};
use serde::{Deserialize, Serialize};
pub mod settings;
pub mod schema;
pub mod profiles;
pub mod id_cache;
pub mod events;
pub mod attachments;
pub mod chats;
pub mod wrappers;
pub mod nip17_keys;
pub mod community;
pub mod bots;
pub use settings::{
get_sql_setting, set_sql_setting, advance_u64_setting, get_pkey, set_pkey, get_seed, set_seed, remove_setting,
get_signer_type, set_signer_type,
get_bunker_url, set_bunker_url,
get_bunker_remote_pubkey, set_bunker_remote_pubkey,
commit_bunker_account_setup,
get_nip55_user_pubkey, set_nip55_user_pubkey,
get_nip55_signer_package, set_nip55_signer_package,
commit_nip55_account_setup,
};
static APP_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
pub fn set_app_data_dir(path: PathBuf) {
let _ = APP_DATA_DIR.set(path);
}
pub fn get_app_data_dir() -> Result<&'static PathBuf, String> {
APP_DATA_DIR.get().ok_or_else(|| "App data directory not initialized".to_string())
}
static APP_VERSION: OnceLock<String> = OnceLock::new();
pub fn set_app_version(version: impl Into<String>) {
let _ = APP_VERSION.set(version.into());
}
static DOWNLOAD_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
pub fn set_download_dir(path: PathBuf) {
let _ = DOWNLOAD_DIR_OVERRIDE.set(path);
}
pub fn get_download_dir() -> PathBuf {
if let Some(installed) = DOWNLOAD_DIR_OVERRIDE.get() {
return installed.clone();
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join("Downloads/vector");
}
}
#[cfg(target_os = "windows")]
{
if let Ok(profile) = std::env::var("USERPROFILE") {
return PathBuf::from(profile).join("Downloads").join("vector");
}
}
if let Ok(data_dir) = get_app_data_dir() {
return data_dir.join("vector_downloads");
}
PathBuf::from("/tmp/vector_downloads")
}
static CURRENT_ACCOUNT: LazyLock<Arc<RwLock<Option<String>>>> = LazyLock::new(|| Arc::new(RwLock::new(None)));
const ACTIVE_ACCOUNT_FILE: &str = "active_account";
fn is_valid_npub(s: &str) -> bool {
if s.len() != 63 || !s.starts_with("npub1") {
return false;
}
s.bytes().skip(5).all(|c| matches!(c,
b'q' | b'p' | b'z' | b'r' | b'y' | b'9' | b'x' | b'8' |
b'g' | b'f' | b'2' | b't' | b'v' | b'd' | b'w' | b'0' |
b's' | b'3' | b'j' | b'n' | b'5' | b'4' | b'k' | b'h' |
b'c' | b'e' | b'6' | b'm' | b'u' | b'a' | b'7' | b'l'
))
}
pub fn get_current_account() -> Result<String, String> {
CURRENT_ACCOUNT.read().unwrap()
.as_ref().cloned()
.ok_or_else(|| "No active account".to_string())
}
pub fn set_current_account(npub: String) -> Result<(), String> {
*CURRENT_ACCOUNT.write().unwrap() = Some(npub.clone());
let _ = write_active_account_file(&npub);
Ok(())
}
pub fn clear_current_account_in_memory() {
*CURRENT_ACCOUNT.write().unwrap() = None;
}
pub fn read_active_account_file() -> Result<Option<String>, String> {
let app_data = match get_app_data_dir() {
Ok(p) => p,
Err(_) => return Ok(None),
};
read_active_account_file_in(app_data)
}
pub fn write_active_account_file(npub: &str) -> Result<(), String> {
let app_data = get_app_data_dir()?.clone();
write_active_account_file_in(&app_data, npub)
}
pub fn clear_active_account_file() -> Result<(), String> {
let app_data = get_app_data_dir()?;
clear_active_account_file_in(app_data)
}
pub fn list_account_npubs() -> Result<Vec<String>, String> {
let app_data = get_app_data_dir()?;
Ok(list_account_npubs_in(app_data))
}
const MARKER_MAX_BYTES: u64 = 256;
fn read_active_account_file_in(app_data: &std::path::Path) -> Result<Option<String>, String> {
use std::io::Read;
let path = app_data.join(ACTIVE_ACCOUNT_FILE);
if !path.exists() {
return Ok(None);
}
if let Ok(meta) = std::fs::metadata(&path) {
if meta.len() > MARKER_MAX_BYTES {
return Ok(None);
}
} else {
return Ok(None);
}
let mut buf = String::new();
let file = match std::fs::File::open(&path) {
Ok(f) => f,
Err(_) => return Ok(None),
};
if file.take(MARKER_MAX_BYTES).read_to_string(&mut buf).is_err() {
return Ok(None);
}
let npub = buf.trim().to_string();
if !is_valid_npub(&npub) {
return Ok(None);
}
match std::fs::symlink_metadata(app_data.join(&npub)) {
Ok(meta) if meta.file_type().is_dir() && !meta.file_type().is_symlink() => {}
_ => return Ok(None),
}
Ok(Some(npub))
}
fn write_active_account_file_in(app_data: &std::path::Path, npub: &str) -> Result<(), String> {
if !is_valid_npub(npub) {
return Err(format!("Invalid npub format: {}", npub));
}
if !app_data.exists() {
std::fs::create_dir_all(app_data)
.map_err(|e| format!("Failed to create app data dir: {}", e))?;
}
match std::fs::symlink_metadata(app_data.join(npub)) {
Ok(meta) if meta.file_type().is_dir() && !meta.file_type().is_symlink() => {}
_ => return Err(format!("Account directory missing or invalid: {}", npub)),
}
let tmp = app_data.join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE));
let final_path = app_data.join(ACTIVE_ACCOUNT_FILE);
let mut payload = String::with_capacity(npub.len() + 1);
payload.push_str(npub);
payload.push('\n');
if let Err(e) = std::fs::write(&tmp, payload.as_bytes()) {
let _ = std::fs::remove_file(&tmp);
return Err(format!("Failed to write active account temp file: {}", e));
}
let mut last_err = None;
for attempt in 0..3 {
match std::fs::rename(&tmp, &final_path) {
Ok(_) => return Ok(()),
Err(e) => {
last_err = Some(e);
if attempt < 2 {
std::thread::sleep(std::time::Duration::from_millis(50));
}
}
}
}
let _ = std::fs::remove_file(&tmp);
Err(format!(
"Failed to rename active account file: {}",
last_err.map(|e| e.to_string()).unwrap_or_default()
))
}
fn clear_active_account_file_in(app_data: &std::path::Path) -> Result<(), String> {
let path = app_data.join(ACTIVE_ACCOUNT_FILE);
if path.exists() {
std::fs::remove_file(&path)
.map_err(|e| format!("Failed to remove active account file: {}", e))?;
}
Ok(())
}
fn list_account_npubs_in(app_data: &std::path::Path) -> Vec<String> {
let mut out = Vec::new();
if let Ok(entries) = std::fs::read_dir(app_data) {
for entry in entries.flatten() {
if entry.file_type().map_or(false, |ft| ft.is_dir()) {
let name = entry.file_name().to_string_lossy().to_string();
if is_valid_npub(&name) {
out.push(name);
}
}
}
}
out
}
#[cfg(test)]
mod active_account_tests {
use super::*;
use std::fs;
use tempfile::TempDir;
const VALID_A: &str = "npub16ye7evyevwnl0fc9hujsxf9zym72e063awn0pvde0huvpyec5nyq4dg4wn";
const VALID_B: &str = "npub12w73tzcqgpr2pcy4el5x60d2emeud4cyeeayynzqgg2fefzgytaqm4ktz3";
fn touch_account_dir(base: &std::path::Path, npub: &str) {
fs::create_dir_all(base.join(npub)).unwrap();
}
#[test]
fn npub_validator_accepts_canonical_form() {
assert!(is_valid_npub(VALID_A));
assert!(is_valid_npub(VALID_B));
}
#[test]
fn npub_validator_rejects_wrong_length() {
assert!(!is_valid_npub("npub1abc"));
assert!(!is_valid_npub(&format!("{}x", VALID_A)));
assert!(!is_valid_npub(""));
}
#[test]
fn npub_validator_rejects_missing_prefix() {
let body = &VALID_A[5..];
assert!(!is_valid_npub(&format!("nsec1{}", body)));
assert!(!is_valid_npub(&format!("xxxx1{}", body)));
}
#[test]
fn npub_validator_rejects_non_bech32_chars() {
for bad in ['1', 'b', 'i', 'o', 'B', 'I', 'O', '!', '*', ' '] {
let mut s = String::from(VALID_A);
s.replace_range(10..11, &bad.to_string());
assert!(!is_valid_npub(&s), "should reject character {:?}", bad);
}
}
#[test]
fn write_then_read_round_trips() {
let tmp = TempDir::new().unwrap();
touch_account_dir(tmp.path(), VALID_A);
write_active_account_file_in(tmp.path(), VALID_A).unwrap();
assert_eq!(
read_active_account_file_in(tmp.path()).unwrap(),
Some(VALID_A.to_string())
);
}
#[test]
fn write_rejects_invalid_npub() {
let tmp = TempDir::new().unwrap();
let err = write_active_account_file_in(tmp.path(), "npub1nope").unwrap_err();
assert!(err.contains("Invalid"));
assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
}
#[test]
fn write_rejects_missing_account_dir() {
let tmp = TempDir::new().unwrap();
let err = write_active_account_file_in(tmp.path(), VALID_A).unwrap_err();
assert!(err.contains("missing or invalid"),
"expected account-dir-missing error, got: {}", err);
assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
}
#[test]
fn write_rejects_symlinked_account_dir() {
let tmp = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
let link = tmp.path().join(VALID_A);
#[cfg(unix)]
{
std::os::unix::fs::symlink(target.path(), &link).unwrap();
let err = write_active_account_file_in(tmp.path(), VALID_A).unwrap_err();
assert!(err.contains("missing or invalid"),
"expected symlink rejection, got: {}", err);
}
#[cfg(not(unix))]
let _ = (target, link);
}
#[test]
fn read_returns_none_when_marker_missing() {
let tmp = TempDir::new().unwrap();
assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
}
#[test]
fn read_returns_none_when_marker_is_garbage() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), b"not-an-npub\n").unwrap();
assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
}
#[test]
fn read_returns_none_when_account_dir_missing() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), VALID_A).unwrap();
assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
}
#[test]
fn read_returns_none_when_marker_oversized() {
let tmp = TempDir::new().unwrap();
let payload = vec![b'x'; (MARKER_MAX_BYTES + 1024) as usize];
fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), &payload).unwrap();
assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
}
#[test]
fn read_trims_whitespace() {
let tmp = TempDir::new().unwrap();
touch_account_dir(tmp.path(), VALID_A);
fs::write(
tmp.path().join(ACTIVE_ACCOUNT_FILE),
format!(" {}\n", VALID_A),
).unwrap();
assert_eq!(
read_active_account_file_in(tmp.path()).unwrap(),
Some(VALID_A.to_string())
);
}
#[test]
fn read_handles_crlf_line_endings() {
let tmp = TempDir::new().unwrap();
touch_account_dir(tmp.path(), VALID_A);
fs::write(
tmp.path().join(ACTIVE_ACCOUNT_FILE),
format!("{}\r\n", VALID_A),
).unwrap();
assert_eq!(
read_active_account_file_in(tmp.path()).unwrap(),
Some(VALID_A.to_string())
);
}
#[test]
fn npub_validator_rejects_uppercase_prefix() {
let upper = format!("NPUB1{}", &VALID_A[5..]);
assert!(!is_valid_npub(&upper));
}
#[test]
fn write_then_read_round_trips_with_newline() {
let tmp = TempDir::new().unwrap();
touch_account_dir(tmp.path(), VALID_A);
write_active_account_file_in(tmp.path(), VALID_A).unwrap();
let raw = fs::read_to_string(tmp.path().join(ACTIVE_ACCOUNT_FILE)).unwrap();
assert!(raw.ends_with('\n'));
assert_eq!(
read_active_account_file_in(tmp.path()).unwrap(),
Some(VALID_A.to_string())
);
}
#[test]
fn write_overwrites_previous_marker_atomically() {
let tmp = TempDir::new().unwrap();
touch_account_dir(tmp.path(), VALID_A);
touch_account_dir(tmp.path(), VALID_B);
write_active_account_file_in(tmp.path(), VALID_A).unwrap();
write_active_account_file_in(tmp.path(), VALID_B).unwrap();
assert_eq!(
read_active_account_file_in(tmp.path()).unwrap(),
Some(VALID_B.to_string())
);
assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
}
#[test]
fn clear_removes_marker_and_is_idempotent() {
let tmp = TempDir::new().unwrap();
touch_account_dir(tmp.path(), VALID_A);
write_active_account_file_in(tmp.path(), VALID_A).unwrap();
assert!(tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
clear_active_account_file_in(tmp.path()).unwrap();
assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
clear_active_account_file_in(tmp.path()).unwrap();
}
#[test]
fn list_npubs_finds_valid_dirs_only() {
let tmp = TempDir::new().unwrap();
touch_account_dir(tmp.path(), VALID_A);
touch_account_dir(tmp.path(), VALID_B);
fs::create_dir_all(tmp.path().join("npub1tooshort")).unwrap();
fs::create_dir_all(tmp.path().join("not-an-npub-dir")).unwrap();
fs::create_dir_all(tmp.path().join("tor")).unwrap();
fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), VALID_A).unwrap();
let mut found = list_account_npubs_in(tmp.path());
found.sort();
let mut expected = vec![VALID_A.to_string(), VALID_B.to_string()];
expected.sort();
assert_eq!(found, expected);
}
#[test]
fn list_npubs_skips_dirs_containing_invalid_chars() {
let tmp = TempDir::new().unwrap();
let mut bogus = String::from(VALID_A);
bogus.replace_range(10..11, "b");
fs::create_dir_all(tmp.path().join(&bogus)).unwrap();
let found = list_account_npubs_in(tmp.path());
assert!(found.is_empty(), "found unexpected entries: {:?}", found);
}
#[test]
fn write_creates_app_data_dir_if_missing() {
let tmp = TempDir::new().unwrap();
let nested = tmp.path().join("does/not/exist/yet");
std::fs::create_dir_all(&nested).unwrap();
touch_account_dir(&nested, VALID_A);
write_active_account_file_in(&nested, VALID_A).unwrap();
assert!(nested.join(ACTIVE_ACCOUNT_FILE).exists());
}
}
static DB_READ_POOL: LazyLock<Arc<Mutex<Vec<rusqlite::Connection>>>> =
LazyLock::new(|| Arc::new(Mutex::new(Vec::new())));
static DB_WRITE_CONN: LazyLock<Arc<Mutex<Option<rusqlite::Connection>>>> =
LazyLock::new(|| Arc::new(Mutex::new(None)));
static POOL_GENERATION: AtomicU64 = AtomicU64::new(0);
#[inline]
fn current_pool_generation() -> u64 {
POOL_GENERATION.load(Ordering::Acquire)
}
#[inline]
fn bump_pool_generation() -> u64 {
POOL_GENERATION.fetch_add(1, Ordering::AcqRel).wrapping_add(1)
}
pub struct ConnectionGuard {
conn: Option<rusqlite::Connection>,
generation: u64,
}
impl ConnectionGuard {
fn new(conn: rusqlite::Connection, generation: u64) -> Self {
Self { conn: Some(conn), generation }
}
}
impl Deref for ConnectionGuard {
type Target = rusqlite::Connection;
fn deref(&self) -> &Self::Target { self.conn.as_ref().expect("Connection already taken") }
}
impl DerefMut for ConnectionGuard {
fn deref_mut(&mut self) -> &mut Self::Target { self.conn.as_mut().expect("Connection already taken") }
}
impl Drop for ConnectionGuard {
fn drop(&mut self) {
if let Some(conn) = self.conn.take() {
if self.generation == current_pool_generation() {
if let Ok(mut pool) = DB_READ_POOL.lock() {
pool.push(conn);
}
}
}
}
}
pub struct WriteConnectionGuard {
conn: Option<rusqlite::Connection>,
generation: u64,
}
impl WriteConnectionGuard {
fn new(conn: rusqlite::Connection, generation: u64) -> Self {
Self { conn: Some(conn), generation }
}
}
impl Deref for WriteConnectionGuard {
type Target = rusqlite::Connection;
fn deref(&self) -> &Self::Target { self.conn.as_ref().expect("Write connection already taken") }
}
impl DerefMut for WriteConnectionGuard {
fn deref_mut(&mut self) -> &mut Self::Target { self.conn.as_mut().expect("Write connection already taken") }
}
impl Drop for WriteConnectionGuard {
fn drop(&mut self) {
if let Some(conn) = self.conn.take() {
if self.generation == current_pool_generation() {
if let Ok(mut slot) = DB_WRITE_CONN.lock() {
if slot.is_none() {
*slot = Some(conn);
}
}
}
}
}
}
pub fn account_dir(npub: &str) -> Result<PathBuf, String> {
if !is_valid_npub(npub) {
return Err(format!("Invalid npub format: {}", npub));
}
Ok(get_app_data_dir()?.join(npub))
}
fn get_current_db_path() -> Result<PathBuf, String> {
let npub = get_current_account()?;
Ok(account_dir(&npub)?.join("vector.db"))
}
fn create_connection(path: &PathBuf) -> Result<rusqlite::Connection, String> {
let conn = rusqlite::Connection::open(path)
.map_err(|e| format!("Failed to open database: {}", e))?;
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;")
.map_err(|e| format!("Failed to set pragmas: {}", e))?;
Ok(conn)
}
pub fn get_db_connection_guard_static() -> Result<ConnectionGuard, String> {
let generation = current_pool_generation();
if let Ok(mut pool) = DB_READ_POOL.lock() {
if let Some(conn) = pool.pop() {
return Ok(ConnectionGuard::new(conn, generation));
}
}
let path = get_current_db_path()?;
let conn = create_connection(&path)?;
Ok(ConnectionGuard::new(conn, generation))
}
#[cfg(test)]
pub(crate) static DB_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
pub fn get_write_connection_guard_static() -> Result<WriteConnectionGuard, String> {
let generation = current_pool_generation();
let mut write_slot = DB_WRITE_CONN.lock().unwrap();
if let Some(conn) = write_slot.take() {
return Ok(WriteConnectionGuard::new(conn, generation));
}
drop(write_slot);
let path = get_current_db_path()?;
let conn = create_connection(&path)?;
Ok(WriteConnectionGuard::new(conn, generation))
}
const LAST_APP_VERSION_KEY: &str = "last_app_version";
#[derive(Debug, Clone, serde::Serialize)]
pub struct DowngradeBlock {
pub db_schema: u32,
pub supported_schema: u32,
pub last_app_version: Option<String>,
}
impl std::fmt::Display for DowngradeBlock {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "This account was last opened by a newer version of Vector")?;
if let Some(version) = &self.last_app_version {
write!(f, " ({version})")?;
}
write!(
f,
". Its database is at schema {} and this build only understands {}. \
Opening it would corrupt your messages, so Vector has stopped. \
Reinstall the newer version to continue.",
self.db_schema, self.supported_schema
)
}
}
fn downgrade_block(conn: &rusqlite::Connection) -> Option<DowngradeBlock> {
let db_schema = schema::applied_migration_high_water(conn);
if db_schema <= schema::HIGHEST_MIGRATION_ID {
return None;
}
Some(DowngradeBlock {
db_schema,
supported_schema: schema::HIGHEST_MIGRATION_ID,
last_app_version: conn
.query_row(
"SELECT value FROM settings WHERE key = ?1",
rusqlite::params![LAST_APP_VERSION_KEY],
|row| row.get::<_, String>(0),
)
.ok(),
})
}
pub fn inspect_downgrade(npub: &str) -> Result<Option<DowngradeBlock>, String> {
let db_path = account_dir(npub)?.join("vector.db");
if !db_path.exists() {
return Ok(None);
}
let conn = create_connection(&db_path)?;
Ok(downgrade_block(&conn))
}
pub fn init_database(npub: &str) -> Result<(), String> {
let profile_dir = account_dir(npub)?;
if !profile_dir.exists() {
std::fs::create_dir_all(&profile_dir)
.map_err(|e| format!("Failed to create profile directory: {}", e))?;
}
let db_path = profile_dir.join("vector.db");
let mut conn = create_connection(&db_path)?;
if let Some(block) = downgrade_block(&conn) {
return Err(block.to_string());
}
conn.execute_batch(schema::SQL_SCHEMA)
.map_err(|e| format!("Failed to create schema: {}", e))?;
schema::run_migrations(&mut conn)?;
if let Some(version) = APP_VERSION.get() {
let _ = conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
rusqlite::params![LAST_APP_VERSION_KEY, version],
);
}
let _ = conn.execute_batch("PRAGMA optimize=0x10002;");
let mls_dir = profile_dir.join("mls");
if mls_dir.exists() {
match std::fs::remove_dir_all(&mls_dir) {
Ok(()) => crate::log_info!("[db] purged orphaned MLS store for account"),
Err(e) => crate::log_warn!("[db] could not purge orphaned MLS store: {}", e),
}
}
bump_pool_generation();
if let Ok(mut pool) = DB_READ_POOL.lock() {
pool.clear();
for _ in 0..4 {
if let Ok(c) = create_connection(&db_path) {
pool.push(c);
}
}
}
let write_conn = create_connection(&db_path)?;
*DB_WRITE_CONN.lock().unwrap() = Some(write_conn);
#[cfg(feature = "tor")]
{
let enabled = create_connection(&db_path)
.ok()
.and_then(|c| {
c.query_row(
"SELECT value FROM settings WHERE key = 'tor_enabled'",
[],
|row| row.get::<_, String>(0),
)
.ok()
})
.map(|v| v == "1" || v == "true")
.unwrap_or(false);
crate::tor::set_tor_enabled_pref(enabled);
}
Ok(())
}
pub fn close_database() {
bump_pool_generation();
if let Ok(mut pool) = DB_READ_POOL.lock() {
pool.clear();
}
*DB_WRITE_CONN.lock().unwrap() = None;
}
pub fn optimize_database() {
if let Ok(guard) = DB_WRITE_CONN.lock() {
if let Some(conn) = guard.as_ref() {
let _ = conn.execute_batch("PRAGMA optimize;");
}
}
}
pub fn get_accounts() -> Result<Vec<String>, String> {
let app_data = get_app_data_dir()?;
let mut accounts = Vec::new();
if let Ok(entries) = std::fs::read_dir(app_data) {
for entry in entries.flatten() {
if entry.file_type().map_or(false, |ft| ft.is_dir()) {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("npub1") {
if entry.path().join("vector.db").exists() {
accounts.push(name);
}
}
}
}
}
Ok(accounts)
}
pub fn get_profile_directory(npub: &str) -> Result<PathBuf, String> {
if !npub.starts_with("npub1") {
return Err(format!("Invalid npub format: {}", npub));
}
let dir = account_dir(npub)?;
if !dir.exists() {
std::fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create profile directory: {}", e))?;
}
Ok(dir)
}
pub fn get_database_path(npub: &str) -> Result<PathBuf, String> {
Ok(get_profile_directory(npub)?.join("vector.db"))
}
pub fn clear_id_caches() {
id_cache::clear_id_caches();
community::clear_banlist_cache();
community::clear_channel_community_cache();
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u8)]
pub enum SystemEventType {
MemberLeft = 0,
MemberJoined = 1,
MemberRemoved = 2,
WallpaperChanged = 3,
}
impl SystemEventType {
pub fn display_message(&self, display_name: &str) -> String {
match self {
SystemEventType::MemberLeft => format!("{} has left", display_name),
SystemEventType::MemberJoined => format!("{} has joined", display_name),
SystemEventType::MemberRemoved => format!("{} was removed", display_name),
SystemEventType::WallpaperChanged => format!("{} changed the wallpaper", display_name),
}
}
pub fn as_u8(&self) -> u8 { *self as u8 }
}
#[cfg(test)]
mod pool_generation_tests {
use super::*;
use tempfile::TempDir;
fn fake_conn() -> rusqlite::Connection {
rusqlite::Connection::open_in_memory().unwrap()
}
#[test]
fn close_database_bumps_generation() {
let before = current_pool_generation();
close_database();
let after = current_pool_generation();
assert!(after > before, "close_database must advance POOL_GENERATION");
}
#[test]
fn init_database_bumps_generation() {
let before = current_pool_generation();
let bumped = bump_pool_generation();
assert_eq!(bumped, before.wrapping_add(1));
assert_eq!(current_pool_generation(), bumped);
}
#[test]
fn stale_read_guard_does_not_return_to_pool_after_generation_bump() {
let _tmp = TempDir::new().unwrap();
let pool_size_before = DB_READ_POOL.lock().unwrap().len();
let stale_generation = current_pool_generation();
let guard = ConnectionGuard::new(fake_conn(), stale_generation);
bump_pool_generation();
drop(guard);
let pool_size_after = DB_READ_POOL.lock().unwrap().len();
assert_eq!(
pool_size_after, pool_size_before,
"stale read guard must not re-enter the pool"
);
}
#[test]
fn fresh_read_guard_returns_to_pool() {
let pool_size_before = DB_READ_POOL.lock().unwrap().len();
let generation = current_pool_generation();
let guard = ConnectionGuard::new(fake_conn(), generation);
drop(guard);
let pool_size_after = DB_READ_POOL.lock().unwrap().len();
assert_eq!(
pool_size_after,
pool_size_before + 1,
"fresh read guard should be returned to the pool"
);
DB_READ_POOL.lock().unwrap().pop();
}
#[test]
fn stale_write_guard_does_not_overwrite_fresh_slot() {
let stale_generation = current_pool_generation();
let stale_guard = WriteConnectionGuard::new(fake_conn(), stale_generation);
bump_pool_generation();
let fresh_conn = fake_conn();
*DB_WRITE_CONN.lock().unwrap() = Some(fresh_conn);
drop(stale_guard);
assert!(
DB_WRITE_CONN.lock().unwrap().is_some(),
"write slot must keep the freshly installed connection"
);
*DB_WRITE_CONN.lock().unwrap() = None;
}
#[test]
fn stale_write_guard_does_not_fill_empty_slot() {
let stale_generation = current_pool_generation();
let stale_guard = WriteConnectionGuard::new(fake_conn(), stale_generation);
bump_pool_generation();
*DB_WRITE_CONN.lock().unwrap() = None;
drop(stale_guard);
assert!(
DB_WRITE_CONN.lock().unwrap().is_none(),
"stale write guard must not fill an empty slot"
);
}
}
#[cfg(test)]
mod downgrade_tests {
use super::*;
fn test_account() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, String) {
let guard = DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
close_database();
clear_id_caches();
let tmp = tempfile::tempdir().unwrap();
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(61_000);
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
let mut acct = String::from("npub1");
let mut v = n as usize;
for _ in 0..58 {
acct.push(B[v % 32] as char);
v = v / 32 + 7;
}
set_app_data_dir(tmp.path().to_path_buf());
set_current_account(acct.clone()).unwrap();
(tmp, guard, acct)
}
#[test]
fn an_equal_schema_opens_normally() {
let (_tmp, _guard, acct) = test_account();
init_database(&acct).unwrap();
assert!(inspect_downgrade(&acct).unwrap().is_none());
init_database(&acct).unwrap();
assert!(inspect_downgrade(&acct).unwrap().is_none());
}
#[test]
fn a_missing_database_is_not_a_downgrade() {
let (_tmp, _guard, acct) = test_account();
assert!(inspect_downgrade(&acct).unwrap().is_none());
assert!(!account_dir(&acct).unwrap().join("vector.db").exists());
}
#[test]
fn a_newer_schema_blocks_the_open_and_names_the_build() {
let (_tmp, _guard, acct) = test_account();
init_database(&acct).unwrap();
let db_path = account_dir(&acct).unwrap().join("vector.db");
{
let conn = create_connection(&db_path).unwrap();
conn.execute(
"INSERT OR REPLACE INTO schema_migrations (id, applied_at) VALUES (?1, 0)",
rusqlite::params![schema::HIGHEST_MIGRATION_ID + 1],
)
.unwrap();
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
rusqlite::params![LAST_APP_VERSION_KEY, "9.9.9"],
)
.unwrap();
}
close_database();
let block = inspect_downgrade(&acct)
.unwrap()
.expect("a higher migration id must read as a downgrade");
assert_eq!(block.db_schema, schema::HIGHEST_MIGRATION_ID + 1);
assert_eq!(block.supported_schema, schema::HIGHEST_MIGRATION_ID);
assert_eq!(block.last_app_version.as_deref(), Some("9.9.9"));
let err = init_database(&acct).unwrap_err();
assert!(err.contains("9.9.9"), "must name the newer build: {err}");
}
#[test]
fn a_blocked_open_writes_nothing() {
let (_tmp, _guard, acct) = test_account();
init_database(&acct).unwrap();
let db_path = account_dir(&acct).unwrap().join("vector.db");
{
let conn = create_connection(&db_path).unwrap();
conn.execute(
"INSERT OR REPLACE INTO schema_migrations (id, applied_at) VALUES (?1, 0)",
rusqlite::params![schema::HIGHEST_MIGRATION_ID + 5],
)
.unwrap();
conn.execute("DROP TABLE IF EXISTS settings", []).unwrap();
}
close_database();
assert!(init_database(&acct).is_err());
let conn = create_connection(&db_path).unwrap();
let exists: bool = conn
.query_row(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='settings'",
[],
|_| Ok(true),
)
.unwrap_or(false);
assert!(!exists, "a blocked open must not write to the database");
}
}