2026-04-23 17:46:11 -04:00
|
|
|
//! Open-or-recreate: the sole entry point into the on-disk database.
|
|
|
|
|
//!
|
|
|
|
|
//! **Policy**: any schema mismatch — wrong `schema_info.version`, wrong
|
2026-08-09 16:25:43 -04:00
|
|
|
//! stored `tokenize` string, absent `schema_info` table — wipes the database
|
|
|
|
|
//! file and recreates it from scratch. There are **no** in-place migrations.
|
2026-04-23 17:46:11 -04:00
|
|
|
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
use rusqlite::{params, Connection, OpenFlags, OptionalExtension};
|
2026-04-23 17:46:11 -04:00
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
use super::schema::{
|
2026-08-05 19:17:11 -04:00
|
|
|
effective_tokenizer, fts_create_sql, PRAGMAS_FAST, PRAGMAS_INCREMENTAL, PRAGMAS_MAINTENANCE,
|
|
|
|
|
PRAGMAS_READONLY, PRAGMAS_SEARCH, PRAGMAS_WALK_READER, SCHEMA_CURRENT,
|
2026-08-02 19:04:30 -04:00
|
|
|
};
|
2026-08-02 20:21:19 -04:00
|
|
|
use crate::security::IndexKey;
|
|
|
|
|
|
|
|
|
|
/// Prefix tagging every "the key doesn't fit this file" error. Callers use
|
|
|
|
|
/// it to tell a wrong password apart from real corruption or schema drift:
|
|
|
|
|
/// the GUI re-prompts, the CLI retries, and — critically — nothing treats
|
|
|
|
|
/// it as a reason to wipe or "recover" the database.
|
|
|
|
|
pub const KEY_MISMATCH_PREFIX: &str = "KEY_MISMATCH: ";
|
2026-04-23 17:46:11 -04:00
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Bump this whenever [`SCHEMA_CURRENT`] or [`fts_create_sql`] changes in a
|
|
|
|
|
/// way that makes an old DB unreadable — or when stored, classifier-derived
|
|
|
|
|
/// values go stale: `files.mime`, `files.type` and `content_state` are
|
|
|
|
|
/// computed at walk time and never re-derived for unchanged files, so a
|
|
|
|
|
/// classification change needs the wipe to apply everywhere.
|
2026-08-20 02:34:08 -04:00
|
|
|
pub const CURRENT_SCHEMA_VERSION: u32 = 8;
|
2026-04-23 17:46:11 -04:00
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Open `db_path` and ensure the on-disk schema matches this build; if it
|
|
|
|
|
/// doesn't (including a changed `tokenizer`), delete the file and recreate it
|
|
|
|
|
/// empty — callers will need to re-index.
|
2026-04-23 17:46:11 -04:00
|
|
|
pub fn open_or_recreate(db_path: &str, tokenizer: &str) -> Result<Connection, String> {
|
2026-08-02 20:21:19 -04:00
|
|
|
open_or_recreate_keyed(db_path, tokenizer, super::key::process_key().as_ref())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) fn open_or_recreate_keyed(
|
|
|
|
|
db_path: &str,
|
|
|
|
|
tokenizer: &str,
|
|
|
|
|
key: Option<&IndexKey>,
|
|
|
|
|
) -> Result<Connection, String> {
|
2026-04-23 17:46:11 -04:00
|
|
|
let path = Path::new(db_path).to_path_buf();
|
2026-08-02 19:04:30 -04:00
|
|
|
if let Some(dir) = path.parent() {
|
|
|
|
|
if !dir.as_os_str().is_empty() {
|
2026-08-20 00:13:10 -04:00
|
|
|
crate::platform::create_dir_private(dir)
|
2026-08-02 19:04:30 -04:00
|
|
|
.map_err(|e| format!("Failed to create database dir {}: {}", dir.display(), e))?;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-23 17:46:11 -04:00
|
|
|
let conn = Connection::open(db_path)
|
|
|
|
|
.map_err(|e| format!("Failed to open database at {}: {}", db_path, e))?;
|
2026-08-20 00:13:10 -04:00
|
|
|
// Before a single row is written. SQLite creates the file 0644 and hands
|
|
|
|
|
// that mode on to `-wal` and `-shm`, so on a default umask every other
|
|
|
|
|
// user on the machine could read the index — which holds the names and
|
|
|
|
|
// full text of everything under the configured roots, including files
|
|
|
|
|
// whose own permissions are 0600.
|
|
|
|
|
crate::platform::restrict_to_owner(&path);
|
2026-08-02 20:21:19 -04:00
|
|
|
key_and_probe(&conn, db_path, key)?;
|
2026-04-23 17:46:11 -04:00
|
|
|
conn.execute_batch(PRAGMAS_FAST)
|
|
|
|
|
.map_err(|e| format!("Failed to apply pragmas: {}", e))?;
|
|
|
|
|
|
|
|
|
|
if db_matches_current(&conn, tokenizer)? {
|
|
|
|
|
return Ok(conn);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
crate::log_warn!(
|
|
|
|
|
"database at {} does not match current schema; rebuilding. \
|
2026-04-23 17:46:11 -04:00
|
|
|
Existing rows will be re-scanned on next indexing run.",
|
|
|
|
|
db_path
|
|
|
|
|
);
|
2026-08-02 20:21:19 -04:00
|
|
|
let conn = wipe_and_reopen(conn, &path, key)?;
|
2026-04-23 17:46:11 -04:00
|
|
|
apply_current_schema(&conn, tokenizer)?;
|
|
|
|
|
Ok(conn)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Open an *existing* index without ever recreating it: no
|
|
|
|
|
/// `SQLITE_OPEN_CREATE`, and any schema mismatch is an error instead of a
|
|
|
|
|
/// wipe. The on-disk FTS tokenizer is used as-is. Every *consumer* (search,
|
|
|
|
|
/// status, size, `clear`) uses this; only the indexer's own write path uses
|
|
|
|
|
/// [`open_or_recreate`].
|
2026-08-02 19:04:30 -04:00
|
|
|
pub fn open_existing(db_path: &str, write: bool) -> Result<Connection, String> {
|
2026-08-02 20:21:19 -04:00
|
|
|
open_existing_keyed(db_path, write, super::key::process_key().as_ref())
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// [`open_existing`] with an explicit pragma profile, on the process key.
|
|
|
|
|
fn open_profiled(db_path: &str, write: bool, pragmas: &str) -> Result<Connection, String> {
|
|
|
|
|
open_keyed_with_pragmas(db_path, write, super::key::process_key().as_ref(), pragmas)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A read-only connection for one walk's row prefetcher; pragma profile
|
|
|
|
|
/// [`PRAGMAS_WALK_READER`].
|
2026-08-02 22:21:39 -04:00
|
|
|
pub fn open_walk_reader(db_path: &str) -> Result<Connection, String> {
|
2026-08-09 16:25:43 -04:00
|
|
|
open_profiled(db_path, false, PRAGMAS_WALK_READER)
|
2026-08-02 22:21:39 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// The search worker's connection, held across requests; pragma profile
|
|
|
|
|
/// [`PRAGMAS_SEARCH`].
|
2026-08-05 19:17:11 -04:00
|
|
|
pub fn open_search_reader(db_path: &str) -> Result<Connection, String> {
|
2026-08-09 16:25:43 -04:00
|
|
|
open_profiled(db_path, false, PRAGMAS_SEARCH)
|
2026-08-05 19:17:11 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// The coordinator's write connection for watcher events and reconciles;
|
|
|
|
|
/// pragma profile [`PRAGMAS_INCREMENTAL`].
|
2026-08-05 19:17:11 -04:00
|
|
|
pub fn open_incremental_writer(db_path: &str) -> Result<Connection, String> {
|
2026-08-09 16:25:43 -04:00
|
|
|
open_profiled(db_path, true, PRAGMAS_INCREMENTAL)
|
2026-08-05 19:17:11 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-03 03:06:19 -04:00
|
|
|
/// A writable connection for post-run compaction, and the only one that may
|
2026-08-09 16:25:43 -04:00
|
|
|
/// VACUUM; pragma profile [`PRAGMAS_MAINTENANCE`].
|
2026-08-03 03:06:19 -04:00
|
|
|
pub fn open_maintenance(db_path: &str) -> Result<Connection, String> {
|
2026-08-09 16:25:43 -04:00
|
|
|
open_profiled(db_path, true, PRAGMAS_MAINTENANCE)
|
2026-08-03 03:06:19 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-02 20:21:19 -04:00
|
|
|
pub(crate) fn open_existing_keyed(
|
|
|
|
|
db_path: &str,
|
|
|
|
|
write: bool,
|
|
|
|
|
key: Option<&IndexKey>,
|
2026-08-02 22:21:39 -04:00
|
|
|
) -> Result<Connection, String> {
|
2026-08-04 03:27:05 -04:00
|
|
|
let pragmas = if write {
|
|
|
|
|
PRAGMAS_FAST
|
|
|
|
|
} else {
|
|
|
|
|
PRAGMAS_READONLY
|
|
|
|
|
};
|
2026-08-02 22:21:39 -04:00
|
|
|
open_keyed_with_pragmas(db_path, write, key, pragmas)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn open_keyed_with_pragmas(
|
|
|
|
|
db_path: &str,
|
|
|
|
|
write: bool,
|
|
|
|
|
key: Option<&IndexKey>,
|
|
|
|
|
pragmas: &str,
|
2026-08-02 20:21:19 -04:00
|
|
|
) -> Result<Connection, String> {
|
2026-08-02 19:04:30 -04:00
|
|
|
let flags = OpenFlags::SQLITE_OPEN_NO_MUTEX
|
|
|
|
|
| if write {
|
|
|
|
|
OpenFlags::SQLITE_OPEN_READ_WRITE
|
|
|
|
|
} else {
|
|
|
|
|
OpenFlags::SQLITE_OPEN_READ_ONLY
|
|
|
|
|
};
|
|
|
|
|
let conn = Connection::open_with_flags(db_path, flags)
|
|
|
|
|
.map_err(|e| format!("Failed to open database at {}: {}", db_path, e))?;
|
2026-08-02 20:21:19 -04:00
|
|
|
key_and_probe(&conn, db_path, key)?;
|
2026-08-02 19:04:30 -04:00
|
|
|
conn.execute_batch(pragmas)
|
|
|
|
|
.map_err(|e| format!("Failed to apply pragmas: {}", e))?;
|
|
|
|
|
|
|
|
|
|
if !schema_version_current(&conn)? {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"index at {} is not a compatible QuickSearch index (schema v{} expected); \
|
|
|
|
|
refusing to modify it. Re-index to rebuild.",
|
|
|
|
|
db_path, CURRENT_SCHEMA_VERSION
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(conn)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 20:21:19 -04:00
|
|
|
/// Cheaply check that the process key (or its absence) actually opens the
|
2026-08-09 16:25:43 -04:00
|
|
|
/// index; a wrong password errors with [`KEY_MISMATCH_PREFIX`].
|
2026-08-03 03:06:19 -04:00
|
|
|
///
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Answers **only** the key question — not [`open_existing`]'s schema check.
|
|
|
|
|
/// Conflating the two made every schema bump present itself to password users
|
|
|
|
|
/// as an unlock failure with no way past the gate.
|
2026-08-02 20:21:19 -04:00
|
|
|
pub fn verify_process_key(db_path: &str) -> Result<(), String> {
|
2026-08-03 03:06:19 -04:00
|
|
|
verify_key(db_path, super::key::process_key().as_ref())
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Whether the next indexing run will discard and rebuild an existing index
|
|
|
|
|
/// written under a different schema version.
|
2026-08-03 03:06:19 -04:00
|
|
|
///
|
2026-08-09 16:25:43 -04:00
|
|
|
/// `false` for anything this cannot positively establish (no file, a key that
|
|
|
|
|
/// does not open it, an unqueryable database): announcing a reset that is not
|
|
|
|
|
/// happening would be worse than saying nothing.
|
2026-08-03 03:06:19 -04:00
|
|
|
pub fn index_needs_rebuild(db_path: &str) -> bool {
|
|
|
|
|
let Ok(conn) = Connection::open_with_flags(
|
|
|
|
|
db_path,
|
|
|
|
|
OpenFlags::SQLITE_OPEN_NO_MUTEX | OpenFlags::SQLITE_OPEN_READ_ONLY,
|
|
|
|
|
) else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
if key_and_probe(&conn, db_path, super::key::process_key().as_ref()).is_err() {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-08-09 16:25:43 -04:00
|
|
|
// Only `Ok(false)`: an `Err` means we could not tell.
|
2026-08-03 03:06:19 -04:00
|
|
|
matches!(schema_version_current(&conn), Ok(false))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) fn verify_key(db_path: &str, key: Option<&IndexKey>) -> Result<(), String> {
|
|
|
|
|
// Read-only and no CREATE: verifying a key must never bring a database
|
|
|
|
|
// into existence, and must never modify one.
|
|
|
|
|
let conn = Connection::open_with_flags(
|
|
|
|
|
db_path,
|
|
|
|
|
OpenFlags::SQLITE_OPEN_NO_MUTEX | OpenFlags::SQLITE_OPEN_READ_ONLY,
|
|
|
|
|
)
|
|
|
|
|
.map_err(|e| format!("Failed to open database at {}: {}", db_path, e))?;
|
|
|
|
|
key_and_probe(&conn, db_path, key)
|
2026-08-02 20:21:19 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Apply the SQLCipher key (if any) and force the first page off disk.
|
|
|
|
|
///
|
|
|
|
|
/// Ordering is load-bearing twice over: SQLCipher requires `PRAGMA key`
|
|
|
|
|
/// before anything else touches the file (our fast-path pragmas include
|
|
|
|
|
/// `journal_mode = WAL`, which reads the header), and the probe must run
|
|
|
|
|
/// before any schema comparison so that a wrong or missing key surfaces as
|
|
|
|
|
/// a tagged [`KEY_MISMATCH_PREFIX`] error — never as a "schema mismatch"
|
|
|
|
|
/// that [`open_or_recreate`] would answer by wiping the file.
|
|
|
|
|
///
|
|
|
|
|
/// The raw-key `x'…'` form bypasses SQLCipher's per-connection PBKDF2
|
2026-08-09 16:25:43 -04:00
|
|
|
/// (hundreds of ms), so the expensive KDF happens once at unlock, not per
|
2026-08-02 20:21:19 -04:00
|
|
|
/// open.
|
|
|
|
|
fn key_and_probe(conn: &Connection, db_path: &str, key: Option<&IndexKey>) -> Result<(), String> {
|
|
|
|
|
if let Some(key) = key {
|
2026-08-09 16:25:43 -04:00
|
|
|
// `cipher_log_level = NONE` mutes SQLCipher's stderr HMAC-failure
|
|
|
|
|
// trace on every wrong-password attempt; the condition still surfaces
|
|
|
|
|
// as SQLITE_NOTADB. It must follow `PRAGMA key`, which has to be the
|
|
|
|
|
// first statement on the connection.
|
2026-08-02 20:21:19 -04:00
|
|
|
conn.execute_batch(&format!(
|
|
|
|
|
"PRAGMA key = \"x'{}'\"; PRAGMA cipher_log_level = NONE;",
|
|
|
|
|
key.to_hex()
|
|
|
|
|
))
|
|
|
|
|
.map_err(|e| format!("Failed to apply encryption key: {}", e))?;
|
|
|
|
|
}
|
|
|
|
|
match conn.query_row("SELECT count(*) FROM sqlite_master", [], |r| {
|
|
|
|
|
r.get::<_, i64>(0)
|
|
|
|
|
}) {
|
|
|
|
|
Ok(_) => Ok(()),
|
|
|
|
|
Err(e) if is_notadb(&e) => Err(key_mismatch_message(db_path, key.is_some())),
|
|
|
|
|
Err(e) => Err(format!("Failed to read database at {}: {}", db_path, e)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// SQLITE_NOTADB is what an undecryptable first page looks like: with the
|
|
|
|
|
/// wrong key (or none) the decrypted header bytes are noise, and SQLite
|
|
|
|
|
/// reports "file is not a database".
|
|
|
|
|
fn is_notadb(e: &rusqlite::Error) -> bool {
|
|
|
|
|
matches!(
|
|
|
|
|
e,
|
|
|
|
|
rusqlite::Error::SqliteFailure(
|
|
|
|
|
rusqlite::ffi::Error {
|
|
|
|
|
code: rusqlite::ErrorCode::NotADatabase,
|
|
|
|
|
..
|
|
|
|
|
},
|
|
|
|
|
_,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 02:34:08 -04:00
|
|
|
/// Why a keyed open failed, as something the caller can branch on.
|
|
|
|
|
///
|
|
|
|
|
/// The three cases want three different things from a user — retype the
|
|
|
|
|
/// password, rebuild the index, supply a password at all — and only one of
|
|
|
|
|
/// them is "wrong password". They used to be distinguishable only by reading
|
|
|
|
|
/// the English in the message, which breaks the moment a database path
|
|
|
|
|
/// happens to contain that English.
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub enum KeyMismatch {
|
|
|
|
|
/// A key was applied and the file did not accept it.
|
|
|
|
|
WrongPassword,
|
|
|
|
|
/// A key was applied but the file on disk is not encrypted at all —
|
|
|
|
|
/// protection was enabled and the rebuild that would encrypt it did not
|
|
|
|
|
/// finish.
|
|
|
|
|
NotEncrypted,
|
|
|
|
|
/// No key was applied and the file wants one.
|
|
|
|
|
PasswordRequired,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl KeyMismatch {
|
|
|
|
|
/// The machine-readable token carried in the message, between
|
|
|
|
|
/// [`KEY_MISMATCH_PREFIX`] and the human detail.
|
|
|
|
|
fn token(self) -> &'static str {
|
|
|
|
|
match self {
|
|
|
|
|
KeyMismatch::WrongPassword => "wrong-password",
|
|
|
|
|
KeyMismatch::NotEncrypted => "not-encrypted",
|
|
|
|
|
KeyMismatch::PasswordRequired => "password-required",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn from_token(token: &str) -> Option<KeyMismatch> {
|
|
|
|
|
match token {
|
|
|
|
|
"wrong-password" => Some(KeyMismatch::WrongPassword),
|
|
|
|
|
"not-encrypted" => Some(KeyMismatch::NotEncrypted),
|
|
|
|
|
"password-required" => Some(KeyMismatch::PasswordRequired),
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Split a tagged mismatch message into its cause and the human detail.
|
|
|
|
|
///
|
|
|
|
|
/// `None` for any message that is not one — including a `KEY_MISMATCH_PREFIX`
|
|
|
|
|
/// message from an older build, which callers should treat as they always did.
|
|
|
|
|
pub fn key_mismatch_parts(message: &str) -> Option<(KeyMismatch, &str)> {
|
|
|
|
|
let rest = message.strip_prefix(KEY_MISMATCH_PREFIX)?;
|
|
|
|
|
let (token, detail) = rest.split_once(' ')?;
|
|
|
|
|
let token = token.strip_suffix(':')?;
|
|
|
|
|
Some((KeyMismatch::from_token(token)?, detail))
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 20:21:19 -04:00
|
|
|
fn key_mismatch_message(db_path: &str, had_key: bool) -> String {
|
|
|
|
|
// An unencrypted SQLite file still has its plaintext magic; sniffing it
|
|
|
|
|
// distinguishes "wrong password" from "protection is enabled but the
|
|
|
|
|
// index was never encrypted" (e.g. a crash between saving the config
|
|
|
|
|
// and rebuilding the index).
|
|
|
|
|
let plaintext = std::fs::File::open(db_path)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|mut f| {
|
|
|
|
|
use std::io::Read;
|
|
|
|
|
let mut magic = [0u8; 16];
|
|
|
|
|
f.read_exact(&mut magic).ok()?;
|
|
|
|
|
Some(&magic == b"SQLite format 3\0")
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or(false);
|
2026-08-20 02:34:08 -04:00
|
|
|
let (cause, detail) = match (had_key, plaintext) {
|
|
|
|
|
(true, true) => (
|
|
|
|
|
KeyMismatch::NotEncrypted,
|
2026-08-04 03:27:05 -04:00
|
|
|
"password protection is enabled but the index is not encrypted; \
|
2026-08-20 02:34:08 -04:00
|
|
|
rebuild the index to encrypt it",
|
|
|
|
|
),
|
|
|
|
|
(true, false) => (
|
|
|
|
|
KeyMismatch::WrongPassword,
|
|
|
|
|
"wrong password (or the file is not a QuickSearch index)",
|
|
|
|
|
),
|
|
|
|
|
(false, _) => (
|
|
|
|
|
KeyMismatch::PasswordRequired,
|
|
|
|
|
"the index is password-protected; a password is required",
|
|
|
|
|
),
|
2026-08-02 20:21:19 -04:00
|
|
|
};
|
2026-08-20 02:34:08 -04:00
|
|
|
// The token sits between the prefix and the detail so that every existing
|
|
|
|
|
// `starts_with(KEY_MISMATCH_PREFIX)` test still holds, while a caller that
|
|
|
|
|
// needs the cause can have it without reading prose.
|
|
|
|
|
format!(
|
|
|
|
|
"{}{}: index at {}: {}",
|
|
|
|
|
KEY_MISMATCH_PREFIX,
|
|
|
|
|
cause.token(),
|
|
|
|
|
db_path,
|
|
|
|
|
detail
|
|
|
|
|
)
|
2026-08-02 20:21:19 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
/// True iff the DB has a `schema_info` table whose `version` equals
|
2026-08-09 16:25:43 -04:00
|
|
|
/// [`CURRENT_SCHEMA_VERSION`]. Ignores the tokenizer — that's only the
|
|
|
|
|
/// owner's concern.
|
2026-08-20 18:58:25 -04:00
|
|
|
/// Prefix tagging the "this file is not a QuickSearch index" refusal, so a
|
|
|
|
|
/// caller can tell it from the schema drift that legitimately rebuilds.
|
|
|
|
|
pub const FOREIGN_DB_PREFIX: &str = "FOREIGN_DB: ";
|
|
|
|
|
|
|
|
|
|
/// Tables left behind by the pre-`schema_info` layout, which is the only kind
|
|
|
|
|
/// of index of ours that [`has_our_schema_info`] cannot recognise.
|
|
|
|
|
///
|
|
|
|
|
/// `files` is the only one guaranteed present across those layouts, and it is
|
|
|
|
|
/// the loose end here: another application's database with a table called
|
|
|
|
|
/// `files` would still be taken for an ancient index of ours and wiped.
|
|
|
|
|
/// Refusing a genuine legacy index is the worse failure of the two, so it
|
|
|
|
|
/// stays — narrowed by the fact that anything with a `schema_info` of our
|
|
|
|
|
/// shape is already decided before this list is consulted.
|
|
|
|
|
const LEGACY_TABLES: &[&str] = &["files", "files_fts", "documents_text", "failed_files"];
|
|
|
|
|
|
|
|
|
|
/// Whether `schema_info` exists *and* is shaped like ours.
|
|
|
|
|
///
|
|
|
|
|
/// The shape, not the contents: preparing the statement succeeds only if the
|
|
|
|
|
/// table has both columns, and an index whose creation was interrupted before
|
|
|
|
|
/// the version row landed is still ours. A foreign database that happens to
|
|
|
|
|
/// use the name for something else is not.
|
|
|
|
|
fn has_our_schema_info(conn: &Connection) -> bool {
|
|
|
|
|
conn.prepare("SELECT key, value FROM schema_info").is_ok()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Whether the file is one of ours, or empty enough to become one.
|
|
|
|
|
///
|
|
|
|
|
/// `sqlite_master` is empty for a file SQLite has just created and for a
|
|
|
|
|
/// zero-length one, which is the "ours to create" case. Internal `sqlite_%`
|
|
|
|
|
/// names are excluded so an autoindex or a stat table cannot make an
|
|
|
|
|
/// otherwise-empty file look occupied.
|
|
|
|
|
fn is_ours_or_empty(conn: &Connection) -> Result<bool, String> {
|
|
|
|
|
if has_our_schema_info(conn) {
|
|
|
|
|
return Ok(true);
|
|
|
|
|
}
|
|
|
|
|
let mut stmt = conn
|
|
|
|
|
.prepare("SELECT name FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'")
|
|
|
|
|
.map_err(|e| format!("read sqlite_master: {}", e))?;
|
|
|
|
|
let mut any = false;
|
|
|
|
|
let names = stmt
|
|
|
|
|
.query_map([], |r| r.get::<_, String>(0))
|
|
|
|
|
.map_err(|e| format!("read sqlite_master: {}", e))?;
|
|
|
|
|
for name in names {
|
|
|
|
|
let name = name.map_err(|e| format!("read sqlite_master: {}", e))?;
|
|
|
|
|
any = true;
|
|
|
|
|
if LEGACY_TABLES.contains(&name.as_str()) {
|
|
|
|
|
return Ok(true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(!any)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The refusal message, naming a few of the tables that are in the way so the
|
|
|
|
|
/// user can recognise whose file they pointed at.
|
|
|
|
|
fn foreign_database_message(conn: &Connection) -> Result<String, String> {
|
|
|
|
|
let mut stmt = conn
|
|
|
|
|
.prepare(
|
|
|
|
|
"SELECT name FROM sqlite_master \
|
|
|
|
|
WHERE type = 'table' AND name NOT LIKE 'sqlite_%' \
|
|
|
|
|
ORDER BY name LIMIT 4",
|
2026-04-23 17:46:11 -04:00
|
|
|
)
|
2026-08-20 18:58:25 -04:00
|
|
|
.map_err(|e| format!("read sqlite_master: {}", e))?;
|
|
|
|
|
let names: Vec<String> = stmt
|
|
|
|
|
.query_map([], |r| r.get::<_, String>(0))
|
|
|
|
|
.map_err(|e| format!("read sqlite_master: {}", e))?
|
|
|
|
|
.filter_map(Result::ok)
|
|
|
|
|
.collect();
|
|
|
|
|
Ok(format!(
|
|
|
|
|
"{}the file is a SQLite database, but not a QuickSearch index \
|
|
|
|
|
(it holds {}). Refusing to replace it — point [paths] database_path \
|
|
|
|
|
somewhere else, or move that file away first.",
|
|
|
|
|
FOREIGN_DB_PREFIX,
|
|
|
|
|
if names.is_empty() {
|
|
|
|
|
"tables this program does not recognise".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
names.join(", ")
|
|
|
|
|
}
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn schema_version_current(conn: &Connection) -> Result<bool, String> {
|
|
|
|
|
// The shape check rather than a name lookup: a table called `schema_info`
|
|
|
|
|
// with other columns belongs to some other program, and reading `value`
|
|
|
|
|
// out of it would fail the open with a SQL error instead of the refusal
|
|
|
|
|
// the caller can act on.
|
|
|
|
|
if !has_our_schema_info(conn) {
|
2026-04-23 17:46:11 -04:00
|
|
|
return Ok(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let version: Option<String> = conn
|
|
|
|
|
.query_row(
|
|
|
|
|
"SELECT value FROM schema_info WHERE key = 'version'",
|
|
|
|
|
[],
|
|
|
|
|
|r| r.get(0),
|
|
|
|
|
)
|
|
|
|
|
.optional()
|
|
|
|
|
.map_err(|e| format!("read schema_info.version: {}", e))?;
|
2026-08-02 19:04:30 -04:00
|
|
|
Ok(version.as_deref() == Some(&CURRENT_SCHEMA_VERSION.to_string()))
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// True iff the DB has the current schema version *and* the
|
|
|
|
|
/// effective-tokenizer string this caller asked for.
|
2026-08-02 19:04:30 -04:00
|
|
|
fn db_matches_current(conn: &Connection, tokenizer: &str) -> Result<bool, String> {
|
|
|
|
|
if !schema_version_current(conn)? {
|
2026-08-20 18:58:25 -04:00
|
|
|
// Refuse rather than wipe unless the file is recognisably ours. The
|
|
|
|
|
// wipe policy is about replacing an index this program wrote under an
|
|
|
|
|
// older layout, and `database_path` is a free-text field with no
|
|
|
|
|
// picker and no confirmation — a typo naming some other
|
|
|
|
|
// application's SQLite file would otherwise delete it, and its `-wal`
|
|
|
|
|
// and `-shm` with it, on the next indexing run. An older layout of
|
|
|
|
|
// ours still wipes, and so does a file with no tables at all, which
|
|
|
|
|
// is ours to create.
|
|
|
|
|
if !is_ours_or_empty(conn)? {
|
|
|
|
|
return Err(foreign_database_message(conn)?);
|
|
|
|
|
}
|
2026-04-23 17:46:11 -04:00
|
|
|
return Ok(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let stored_tokenize: Option<String> = conn
|
|
|
|
|
.query_row(
|
|
|
|
|
"SELECT value FROM schema_info WHERE key = 'tokenize'",
|
|
|
|
|
[],
|
|
|
|
|
|r| r.get(0),
|
|
|
|
|
)
|
|
|
|
|
.optional()
|
|
|
|
|
.map_err(|e| format!("read schema_info.tokenize: {}", e))?;
|
|
|
|
|
let want_tokenize = effective_tokenizer(tokenizer);
|
|
|
|
|
Ok(stored_tokenize.as_deref() == Some(&*want_tokenize))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Drop the current connection, delete the DB file + its WAL/SHM/journal
|
2026-08-02 20:21:19 -04:00
|
|
|
/// sidecars, reopen a fresh file, re-apply key and pragmas. Re-keying here
|
|
|
|
|
/// is essential: a rebuild of a protected index must come back encrypted,
|
|
|
|
|
/// never silently plaintext.
|
|
|
|
|
fn wipe_and_reopen(
|
|
|
|
|
conn: Connection,
|
|
|
|
|
path: &Path,
|
|
|
|
|
key: Option<&IndexKey>,
|
|
|
|
|
) -> Result<Connection, String> {
|
2026-04-23 17:46:11 -04:00
|
|
|
drop(conn);
|
2026-08-09 16:25:43 -04:00
|
|
|
// Before the delete, and even if the removal below fails partway: see
|
|
|
|
|
// [`super::bump_index_epoch`].
|
2026-08-05 19:17:11 -04:00
|
|
|
super::bump_index_epoch();
|
2026-08-02 19:04:30 -04:00
|
|
|
// `remove_file_retrying` matters on Windows, where a delete fails while
|
|
|
|
|
// *any* handle is open — most often an antivirus scanner reading the file
|
2026-08-09 16:25:43 -04:00
|
|
|
// in the moment after we closed it.
|
2026-08-02 19:04:30 -04:00
|
|
|
match crate::platform::remove_file_retrying(path) {
|
2026-04-23 17:46:11 -04:00
|
|
|
Ok(()) => {}
|
|
|
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
2026-08-02 19:04:30 -04:00
|
|
|
Err(e) => {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"Failed to remove old database at {}: {}. \
|
|
|
|
|
Another QuickSearch instance may have the index open.",
|
|
|
|
|
path.display(),
|
|
|
|
|
e
|
|
|
|
|
))
|
|
|
|
|
}
|
2026-04-23 17:46:11 -04:00
|
|
|
}
|
|
|
|
|
for suffix in ["-wal", "-shm", "-journal"] {
|
|
|
|
|
let sidecar = path.with_file_name(format!(
|
|
|
|
|
"{}{}",
|
|
|
|
|
path.file_name().and_then(|s| s.to_str()).unwrap_or(""),
|
|
|
|
|
suffix
|
|
|
|
|
));
|
2026-08-02 19:04:30 -04:00
|
|
|
let _ = crate::platform::remove_file_retrying(&sidecar);
|
2026-04-23 17:46:11 -04:00
|
|
|
}
|
|
|
|
|
let conn = Connection::open(path)
|
|
|
|
|
.map_err(|e| format!("Failed to reopen database after rebuild: {}", e))?;
|
2026-08-20 00:13:10 -04:00
|
|
|
// A rebuild creates the file afresh, so it needs narrowing again for the
|
|
|
|
|
// same reason the first open does.
|
|
|
|
|
crate::platform::restrict_to_owner(path);
|
2026-08-02 20:21:19 -04:00
|
|
|
key_and_probe(&conn, &path.to_string_lossy(), key)?;
|
2026-04-23 17:46:11 -04:00
|
|
|
conn.execute_batch(PRAGMAS_FAST)
|
|
|
|
|
.map_err(|e| format!("Failed to apply pragmas after rebuild: {}", e))?;
|
|
|
|
|
Ok(conn)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn apply_current_schema(conn: &Connection, tokenizer: &str) -> Result<(), String> {
|
|
|
|
|
conn.execute_batch(SCHEMA_CURRENT)
|
|
|
|
|
.map_err(|e| format!("Failed to create current schema tables: {}", e))?;
|
|
|
|
|
let fts = fts_create_sql(tokenizer);
|
|
|
|
|
conn.execute_batch(&fts)
|
|
|
|
|
.map_err(|e| format!("Failed to create searchabletext: {}", e))?;
|
|
|
|
|
|
2026-08-05 18:05:04 -04:00
|
|
|
let now = crate::log::now_unix();
|
2026-04-23 17:46:11 -04:00
|
|
|
let effective = effective_tokenizer(tokenizer);
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO schema_info(key, value) VALUES ('version', ?1), ('created_at', ?2), ('tokenize', ?3)",
|
|
|
|
|
params![
|
|
|
|
|
CURRENT_SCHEMA_VERSION.to_string(),
|
|
|
|
|
now.to_string(),
|
|
|
|
|
effective
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
.map_err(|e| format!("Failed to seed schema_info: {}", e))?;
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
2026-08-09 16:25:43 -04:00
|
|
|
#[path = "open_tests.rs"]
|
|
|
|
|
mod tests;
|