Skip to main content

rightkit_db/
lib.rs

1// Unsafe is confined to the host-name FFI in `host`.
2#![deny(unsafe_code)]
3
4#[cfg(all(feature = "sqlcipher", feature = "sqlcipher-system"))]
5compile_error!("rightkit-db features `sqlcipher` and `sqlcipher-system` are mutually exclusive; enable exactly one");
6
7use rusqlite::{Connection, OpenFlags, Transaction, TransactionBehavior};
8use std::ffi::OsString;
9use std::ops::Deref;
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, Condvar, Mutex, MutexGuard, Weak};
12use std::time::{Duration, Instant};
13/// The zeroizing wrapper that the SQLCipher key fields and key candidates use, re-exported so
14/// callers build keys without declaring their own `zeroize` dependency.
15pub use zeroize::Zeroizing;
16
17mod backup;
18mod checkpoint_job;
19#[allow(unsafe_code)]
20mod host;
21pub use host::per_host_path;
22/// The exact `rusqlite` this crate links (`links = "sqlite3"` allows one per build). Apps name
23/// `Connection`, `params!`, `Row`, `OptionalExtension`, `TransactionBehavior` through this path
24/// instead of declaring their own `rusqlite` dependency.
25pub use rusqlite;
26mod migrate;
27mod recover;
28mod restore;
29mod unreadable;
30
31pub use backup::{
32    online_backup_to, remove_database_files, replace_database, replace_database_locked,
33    restore_from, store_lock_path, BackupProgress, StoreLock,
34};
35pub use checkpoint_job::{
36    WalCheckpointJob, WalCheckpointMode, WalCheckpointPolicy, WalCheckpointRun,
37};
38pub use migrate::{
39    add_column_if_missing, column_exists, migrate, migration_version, pending_marker,
40    pending_marker_started_at, rollback_to, rollback_to_with_storage, stored_version_is_newer,
41    user_version, write_pending_marker, Degraded, LegacyVersionTable, MigrateOptions, Migration,
42    MigrationReport, OpenOrDegrade, PendingMarker, Step, VersionStorage,
43};
44#[cfg(feature = "cipher")]
45pub use recover::rekey;
46pub use recover::{
47    quarantine_database, quarantine_database_opts, quarantine_database_with, MoveMode, OpenOutcome,
48    QuarantineOptions, QuarantineStamp, RecoveryPolicy, SidecarPolicy,
49};
50
51pub use restore::{
52    recover_interrupted_restore, recover_interrupted_restore_locked, restore_database,
53    restore_database_locked, InterruptedRestore, RestoreOptions, RestoreReceipt, RestoreVerify,
54};
55
56pub use unreadable::{
57    classify_open_failure, unreadable_counter_path, RecoveryAction, StorageProblem,
58    UnreadableCause, UnreadablePolicy, UnreadableRecovery,
59};
60
61const BUSY_TIMEOUT_MS: u64 = 5_000;
62const JOURNAL_SIZE_LIMIT: i64 = 67_108_864;
63
64pub type Result<T> = std::result::Result<T, DbError>;
65
66#[derive(Debug, thiserror::Error)]
67#[non_exhaustive]
68pub enum DbError {
69    #[error("SQLite error: {0}")]
70    Sqlite(#[from] rusqlite::Error),
71    #[error("a SQLCipher key requires the rightkit-db `sqlcipher` or `sqlcipher-system` feature")]
72    SqlcipherFeatureRequired,
73    #[error("SQLCipher key contains characters that are unsafe for PRAGMA interpolation")]
74    InvalidSqlcipherKey,
75    /// An open-time `quick_check` or `integrity_check` failed. Open-time recovery treats it as an
76    /// unreadable database (see `DbOptions::open_writer_recovering`).
77    #[error("database integrity check failed: {0}")]
78    Integrity(String),
79    #[error("quarantine suffix {0:?} must be non-empty and contain no path separator or NUL")]
80    InvalidQuarantineSuffix(String),
81    #[error("read-pool capacity must be greater than zero")]
82    InvalidPoolCapacity,
83    #[error("reader cache size must be between 1 and 2147483647 KiB, got {0}")]
84    InvalidReaderCacheSize(u32),
85    /// Checkout after [`ReadPool::close`] or [`ReadPool::close_and_wait`].
86    #[error("read pool is closed")]
87    ReadPoolClosed,
88    /// [`ReadPool::close_and_wait`] timed out. `outstanding` counts readers still checked out or
89    /// opening.
90    #[error("read pool close timed out after {timeout:?} with {outstanding} reader(s) still out")]
91    ReadPoolCloseTimeout {
92        outstanding: usize,
93        timeout: Duration,
94    },
95    /// A reader did not come up read-only: `PRAGMA query_only` did not stay on, or a read-only
96    /// handle reported a writable main database.
97    #[error("reader connection is not read-only: {0}")]
98    ReadOnlyNotEffective(String),
99    #[error("database path has no file name")]
100    InvalidDatabasePath,
101    #[error("database is at schema v{current}, newer than this build understands (v{max}) and not forward compatible")]
102    NewerDatabase { current: i64, max: i64 },
103    #[error("invalid migration list: {0}")]
104    InvalidMigrations(String),
105    #[error("migration v{version} ({name}) failed and was rolled back: {source}")]
106    MigrationFailed {
107        version: i64,
108        name: &'static str,
109        #[source]
110        source: rusqlite::Error,
111    },
112    #[error("cannot roll back: {0}")]
113    Rollback(String),
114    #[error("database backup failed: {0}")]
115    Backup(String),
116    /// [`restore_database`] failed. `previous` is where the previous store is kept when it is not
117    /// at the live path (never deleted); `failed_copy` is where the restored copy was set aside.
118    /// `rolled_back` is true when the live path holds the previous store again (or was never
119    /// touched); false means the rollback was incomplete, a restore journal remains, and
120    /// [`recover_interrupted_restore`] finishes it.
121    #[error("database restore failed: {message}")]
122    RestoreFailed {
123        message: String,
124        previous: Option<PathBuf>,
125        failed_copy: Option<PathBuf>,
126        rolled_back: bool,
127    },
128    #[error("no candidate key opened the database")]
129    NoKeyOpened,
130    #[error(transparent)]
131    Fs(#[from] rightkit_fs::FsError),
132    /// The database file could not be read (permission or I/O failure) or its storage is
133    /// unavailable. Not corruption: the file was not touched unless `quarantine_after` was
134    /// reached, in which case recovery succeeds instead of returning this. See
135    /// [`UnreadablePolicy`].
136    #[error(
137        "database {} cannot be read: {cause} ({consecutive_failures} consecutive failed open(s)): {source}",
138        .path.display()
139    )]
140    Unreadable {
141        path: PathBuf,
142        cause: UnreadableCause,
143        /// Consecutive failed opens recorded so far (0 under [`UnreadablePolicy::Fail`] and for
144        /// storage problems, which are never counted).
145        consecutive_failures: u32,
146        /// The count at which [`UnreadablePolicy::QuarantineAfter`] would set the file aside.
147        quarantine_after: Option<u32>,
148        #[source]
149        source: Box<DbError>,
150    },
151}
152
153#[derive(Clone, Copy, Debug, Eq, PartialEq)]
154pub enum CacheProfile {
155    Default,
156    LargeWorkingSet,
157}
158
159#[derive(Clone, Copy, Debug, Eq, PartialEq)]
160pub enum DurabilityProfile {
161    Full,
162    Normal,
163}
164
165/// How the journal mode is chosen. WAL needs working shared-memory locking, which network
166/// filesystems often lack and which SQLite will silently downgrade; the default therefore
167/// *verifies* the mode that took effect and falls back to a rollback journal.
168#[derive(Clone, Copy, Debug, Eq, PartialEq)]
169#[non_exhaustive]
170pub enum JournalSelect {
171    /// WAL when the filesystem honors it and is not a detected network mount; else a
172    /// rollback journal (TRUNCATE with [`DbOptions::per_host_fallback`], else DELETE).
173    WalWithFallback,
174    /// WAL or fail (no fallback).
175    WalStrict,
176    /// Rollback journal, deleted after each transaction.
177    Delete,
178    /// Rollback journal, truncated after each transaction.
179    Truncate,
180}
181
182impl JournalSelect {
183    fn is_rollback(self) -> bool {
184        matches!(self, JournalSelect::Delete | JournalSelect::Truncate)
185    }
186}
187
188/// Environment kill-switch (`wal` | `delete` | `truncate`) for a misclassified mount in the
189/// field. An app's own variable ([`DbOptions::journal_env`]) is checked first.
190pub const JOURNAL_ENV: &str = "RIGHTKIT_SQLITE_JOURNAL_MODE";
191
192/// Post-key, pre-pragma per-connection initializer (register functions, collations, ...).
193/// Open-time integrity check, selected by [`DbOptions::integrity`].
194#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
195pub enum IntegrityCheck {
196    /// No check at open (default).
197    #[default]
198    Off,
199    /// `PRAGMA quick_check`: structural page checks, without the index-to-table cross-check. It
200    /// costs far less than `Full` on large databases.
201    Quick,
202    /// `PRAGMA integrity_check`, or `PRAGMA cipher_integrity_check` for a keyed database.
203    Full,
204}
205
206pub type ConnInit = Arc<dyn Fn(&Connection) -> rusqlite::Result<()> + Send + Sync>;
207
208#[derive(Clone)]
209pub struct DbOptions {
210    pub path: PathBuf,
211    /// SQLite busy wait, installed before key verification or any bootstrap statement.
212    /// Default: five seconds.
213    pub busy_timeout: Duration,
214    /// SQLCipher passphrase-style key (base64-ish charset). See also [`DbOptions::raw_key`].
215    pub key: Option<Zeroizing<String>>,
216    /// SQLCipher raw 256-bit key, applied as `x'<hex>'` (no KDF). Takes precedence over `key`.
217    pub raw_key: Option<Zeroizing<[u8; 32]>>,
218    pub profile: CacheProfile,
219    pub durability: DurabilityProfile,
220    pub foreign_keys: bool,
221    /// Legacy switch: `true` runs a full check on every open. Ignored when `integrity` is not `Off`.
222    pub integrity_check_on_open: bool,
223    /// Open-time integrity check (default `Off`). A failed check is [`DbError::Integrity`], which
224    /// `open_writer_recovering` treats as unreadable.
225    pub integrity: IntegrityCheck,
226    pub journal: JournalSelect,
227    /// Narrow the database and its sidecars to owner-only (0600) on unix. Default on. Set it to
228    /// `false` when sibling processes must read the file (for example a helper running as another
229    /// user); that is the intended switch, not a bug.
230    pub owner_only: bool,
231    pub init: Option<ConnInit>,
232    /// The app's own kill-switch variable (e.g. `CODERIGHT_SQLITE_JOURNAL_MODE`), checked
233    /// before [`JOURNAL_ENV`].
234    pub journal_env: Option<String>,
235    /// When a rollback journal is in force (network mount, kill switch, or WAL refused),
236    /// open the per-host sibling [`per_host_path`] instead of the requested file, so hosts
237    /// sharing a network directory never share one rollback-journaled database. The
238    /// requested file is not created. Default off.
239    pub per_host_fallback: bool,
240    /// Sets `PRAGMA secure_delete` on every connection at open: `Some(true)` overwrites freed
241    /// pages, `Some(false)` does not, `None` (default) leaves SQLite's compile-time default.
242    pub secure_delete: Option<bool>,
243    /// Run `PRAGMA optimize` on each writer connection at open (default `true`). Set it to `false`
244    /// to leave planner statistics alone at open, for example when the file is opened only to
245    /// inspect it. Readers never run it, whatever this is set to.
246    pub optimize_on_open: bool,
247}
248
249impl DbOptions {
250    pub fn new(path: impl Into<PathBuf>) -> Self {
251        Self {
252            path: path.into(),
253            busy_timeout: Duration::from_millis(BUSY_TIMEOUT_MS),
254            key: None,
255            raw_key: None,
256            profile: CacheProfile::Default,
257            durability: DurabilityProfile::Normal,
258            foreign_keys: false,
259            integrity_check_on_open: false,
260            integrity: IntegrityCheck::Off,
261            journal: JournalSelect::WalWithFallback,
262            owner_only: true,
263            init: None,
264            journal_env: None,
265            per_host_fallback: false,
266            secure_delete: None,
267            optimize_on_open: true,
268        }
269    }
270
271    pub fn with_journal_env(mut self, name: impl Into<String>) -> Self {
272        self.journal_env = Some(name.into());
273        self
274    }
275
276    pub fn with_per_host_fallback(mut self, on: bool) -> Self {
277        self.per_host_fallback = on;
278        self
279    }
280
281    /// Turn `PRAGMA optimize` off at writer open (see [`DbOptions::optimize_on_open`]).
282    pub fn without_optimize_on_open(mut self) -> Self {
283        self.optimize_on_open = false;
284        self
285    }
286
287    /// The file connections actually open: the requested path, or its per-host sibling when
288    /// [`Self::per_host_fallback`] applies (a rollback journal is in force, or a writer
289    /// already fell back there).
290    pub fn effective_path(&self) -> PathBuf {
291        if !self.per_host_fallback {
292            return self.path.clone();
293        }
294        let sibling = per_host_path(&self.path);
295        if effective_journal(self).is_rollback() || (sibling.exists() && !self.path.exists()) {
296            sibling
297        } else {
298            self.path.clone()
299        }
300    }
301
302    pub fn with_passphrase(mut self, key: impl Into<String>) -> Self {
303        self.key = Some(Zeroizing::new(key.into()));
304        self.raw_key = None;
305        self
306    }
307
308    pub fn with_raw_key(mut self, key: [u8; 32]) -> Self {
309        self.raw_key = Some(Zeroizing::new(key));
310        self.key = None;
311        self
312    }
313
314    pub fn is_keyed(&self) -> bool {
315        self.key.is_some() || self.raw_key.is_some()
316    }
317
318    pub fn open_writer(&self) -> Result<Connection> {
319        self.open(ConnectionKind::Writer)
320    }
321
322    pub fn open_reader(&self) -> Result<Connection> {
323        self.open(ConnectionKind::Reader { cache_kib: None })
324    }
325
326    /// Open exactly `path` read-only, without creating it or running mutating bootstrap.
327    /// URI parameters are passed to SQLite unchanged. Journal fallback, permissions,
328    /// `init`, optimization, and migrations are skipped; key and timeout still apply.
329    pub fn open_read_only(&self) -> Result<Connection> {
330        self.open_existing_at(&self.path, OpenFlags::SQLITE_OPEN_READ_ONLY)
331    }
332
333    /// Open exactly `path` read-write without CREATE or mutating bootstrap.
334    /// Uses the same minimal setup as [`Self::open_read_only`].
335    pub fn open_existing_writer(&self) -> Result<Connection> {
336        self.open_existing_at(&self.path, OpenFlags::SQLITE_OPEN_READ_WRITE)
337    }
338
339    /// Open a private, ephemeral database. `path`, filesystem permissions, journal
340    /// selection/fallback, and mmap are ignored; other bootstrap options still apply.
341    pub fn open_in_memory(&self) -> Result<Connection> {
342        let key = self.key_pragma_value()?;
343        let conn = Connection::open_in_memory()?;
344        self.initialize(&conn, key.as_ref().map(|k| k.as_str()))?;
345        configure_connection(&conn, self, ConnectionKind::Memory, None)?;
346        self.check_on_open(&conn)?;
347        Ok(conn)
348    }
349
350    fn open_existing_at(&self, path: &Path, access: OpenFlags) -> Result<Connection> {
351        let key = self.key_pragma_value()?;
352        let conn = Connection::open_with_flags(
353            path,
354            access | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX,
355        )?;
356        conn.busy_timeout(self.busy_timeout)?;
357        apply_key_first(&conn, key.as_ref().map(|k| k.as_str()))?;
358        verify_key(&conn)?;
359        self.check_on_open(&conn)?;
360        Ok(conn)
361    }
362
363    fn initialize(&self, conn: &Connection, key: Option<&str>) -> Result<()> {
364        conn.busy_timeout(self.busy_timeout)?;
365        apply_key_first(conn, key)?;
366        verify_key(conn)?;
367        if let Some(init) = &self.init {
368            init(conn)?;
369        }
370        Ok(())
371    }
372
373    /// A read pool with [`ReadPoolOptions::new`]: read-write file handles, with `query_only` set
374    /// and verified on every reader. Same as `open_read_pool_with(cap, ReadPoolOptions::new())`.
375    pub fn open_read_pool(&self, cap: usize) -> Result<ReadPool> {
376        ReadPool::open(self.clone(), cap, ReadPoolOptions::new())
377    }
378
379    /// A read pool configured by [`ReadPoolOptions`]. A read-only pool needs the database to exist.
380    pub fn open_read_pool_with(&self, cap: usize, pool: ReadPoolOptions) -> Result<ReadPool> {
381        ReadPool::open(self.clone(), cap, pool)
382    }
383
384    fn open_pool_reader(&self, pool: ReadPoolOptions) -> Result<Connection> {
385        if pool.read_only {
386            self.open_pool_read_only(pool.cache_kib)
387        } else {
388            self.open(ConnectionKind::Reader {
389                cache_kib: pool.cache_kib,
390            })
391        }
392    }
393
394    /// A pool reader opened with `SQLITE_OPEN_READ_ONLY`, so the file refuses writes even after
395    /// `query_only` is turned off. It never creates the file, changes the journal mode, narrows
396    /// permissions, or runs `optimize`; the writer owns those. `init` runs on each reader.
397    fn open_pool_read_only(&self, cache_kib: Option<u32>) -> Result<Connection> {
398        let path = self.effective_path();
399        let key = self.key_pragma_value()?;
400        let conn = Connection::open_with_flags(
401            &path,
402            OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
403        )?;
404        conn.busy_timeout(self.busy_timeout)?;
405        apply_key_first(&conn, key.as_ref().map(|k| k.as_str()))?;
406        verify_key(&conn)?;
407        conn.pragma_update(None, "temp_store", "MEMORY")?;
408        if self.foreign_keys {
409            conn.pragma_update(None, "foreign_keys", true)?;
410        }
411        apply_cache_settings(&conn, self, ConnectionKind::Reader { cache_kib })?;
412        // Per-connection setup (`init`) runs on every reader, read-only ones included, as it does
413        // on the other connection kinds. An `init` that writes fails here with the SQLite error.
414        if let Some(init) = &self.init {
415            init(&conn)?;
416        }
417        enforce_query_only(&conn)?;
418        if !conn.is_readonly("main")? {
419            return Err(DbError::ReadOnlyNotEffective(
420                "SQLITE_OPEN_READ_ONLY handle reports a writable main database".into(),
421            ));
422        }
423        self.check_on_open(&conn)?;
424        Ok(conn)
425    }
426
427    fn open(&self, kind: ConnectionKind) -> Result<Connection> {
428        let path = self.effective_path();
429        // The per-host sibling is always rollback-journaled, whoever opens it.
430        let forced = (path != self.path).then(|| match effective_journal(self) {
431            mode if mode.is_rollback() => mode,
432            _ => fallback_mode(self),
433        });
434        let (conn, journal) = self.open_at(&path, kind, forced)?;
435        // WAL refused at runtime: with per-host fallback, the rollback journal belongs on the
436        // per-host sibling. The main file this open touched stays where it is, with every
437        // `-wal`/`-shm`/`-journal` beside it. Deleting it is never safe: file identity proves
438        // origin, not exclusive lifetime, and another connection may have committed
439        // rollback-journal data into that same file (and closed) before this point, so any
440        // check-then-remove can destroy a committed write. A created-empty main is harmless
441        // (it never holds data this open wrote, and a later open falls back again).
442        if journal == Opened::FellBack && self.per_host_fallback && path == self.path {
443            drop(conn);
444            let sibling = per_host_path(&self.path);
445            return Ok(self
446                .open_at(&sibling, kind, Some(JournalSelect::Truncate))?
447                .0);
448        }
449        Ok(conn)
450    }
451
452    fn open_at(
453        &self,
454        path: &Path,
455        kind: ConnectionKind,
456        forced: Option<JournalSelect>,
457    ) -> Result<(Connection, Opened)> {
458        let key = self.key_pragma_value()?;
459        let flags = OpenFlags::SQLITE_OPEN_READ_WRITE
460            | OpenFlags::SQLITE_OPEN_CREATE
461            | OpenFlags::SQLITE_OPEN_NO_MUTEX;
462        let conn = Connection::open_with_flags(path, flags)?;
463        // Narrow the main file before any write so WAL/SHM sidecars inherit owner-only.
464        if self.owner_only {
465            rightkit_fs::restrict_owner_only(path)?;
466        }
467        self.initialize(&conn, key.as_ref().map(|k| k.as_str()))?;
468        if let Some(on) = self.secure_delete {
469            conn.pragma_update(None, "secure_delete", if on { "ON" } else { "OFF" })?;
470        }
471        let journal = configure_connection(&conn, self, kind, forced)?;
472        self.check_on_open(&conn)?;
473        if self.owner_only {
474            harden_sidecars(path)?;
475        }
476        Ok((conn, journal))
477    }
478
479    /// Run the open-time check selected by `integrity`; `integrity_check_on_open` alone means `Full`.
480    fn check_on_open(&self, conn: &Connection) -> Result<()> {
481        let mode = match self.integrity {
482            IntegrityCheck::Off if self.integrity_check_on_open => IntegrityCheck::Full,
483            mode => mode,
484        };
485        match (mode, self.is_keyed()) {
486            (IntegrityCheck::Off, _) => Ok(()),
487            (IntegrityCheck::Quick, _) => run_integrity_pragma(conn, "PRAGMA quick_check"),
488            (IntegrityCheck::Full, true) => {
489                run_integrity_pragma(conn, "PRAGMA cipher_integrity_check")
490            }
491            (IntegrityCheck::Full, false) => run_integrity_pragma(conn, "PRAGMA integrity_check"),
492        }
493    }
494
495    /// The `PRAGMA key` right-hand side (already quoted), validated against injection.
496    fn key_pragma_value(&self) -> Result<Option<Zeroizing<String>>> {
497        if let Some(raw) = &self.raw_key {
498            #[cfg(not(feature = "cipher"))]
499            {
500                let _ = raw;
501                return Err(DbError::SqlcipherFeatureRequired);
502            }
503            #[cfg(feature = "cipher")]
504            {
505                let hex: String = raw.iter().map(|b| format!("{b:02x}")).collect();
506                return Ok(Some(Zeroizing::new(format!("\"x'{hex}'\""))));
507            }
508        }
509        let key = self.key.as_ref().map(|key| key.as_str());
510        validate_key(key)?;
511        Ok(key.map(|k| Zeroizing::new(format!("'{k}'"))))
512    }
513}
514
515/// Default-options read-only open; never creates a missing database.
516pub fn open_read_only(path: impl AsRef<Path>) -> Result<Connection> {
517    DbOptions::new(path.as_ref()).open_read_only()
518}
519
520/// Default-options read-write open; never creates a missing database.
521pub fn open_existing_writer(path: impl AsRef<Path>) -> Result<Connection> {
522    DbOptions::new(path.as_ref()).open_existing_writer()
523}
524
525/// Default-options private in-memory database.
526pub fn open_in_memory() -> Result<Connection> {
527    DbOptions::new(":memory:").open_in_memory()
528}
529
530fn harden_sidecars(db: &Path) -> Result<()> {
531    for suffix in ["-wal", "-shm"] {
532        let mut name = OsString::from(db.file_name().ok_or(DbError::InvalidDatabasePath)?);
533        name.push(suffix);
534        rightkit_fs::restrict_owner_only(&db.with_file_name(name))?;
535    }
536    Ok(())
537}
538
539#[derive(Clone, Copy)]
540enum ConnectionKind {
541    Writer,
542    /// `cache_kib` overrides the reader cache size; see [`ReadPoolOptions::with_reader_cache_kib`].
543    Reader {
544        cache_kib: Option<u32>,
545    },
546    Memory,
547}
548
549impl ConnectionKind {
550    fn reader_cache_kib(self) -> Option<u32> {
551        match self {
552            ConnectionKind::Reader { cache_kib } => cache_kib,
553            ConnectionKind::Writer | ConnectionKind::Memory => None,
554        }
555    }
556}
557
558/// What journal setup did.
559#[derive(Clone, Copy, Debug, Eq, PartialEq)]
560enum Opened {
561    AsRequested,
562    /// WAL was wanted but not honored; a rollback journal is in force.
563    FellBack,
564}
565
566fn configure_connection(
567    conn: &Connection,
568    options: &DbOptions,
569    kind: ConnectionKind,
570    forced: Option<JournalSelect>,
571) -> Result<Opened> {
572    let opened = if matches!(kind, ConnectionKind::Memory) {
573        Opened::AsRequested
574    } else {
575        configure_journal(conn, options, kind, forced)?
576    };
577    let synchronous = match options.durability {
578        DurabilityProfile::Full => "FULL",
579        DurabilityProfile::Normal => "NORMAL",
580    };
581    conn.pragma_update(None, "synchronous", synchronous)?;
582    conn.pragma_update(None, "journal_size_limit", JOURNAL_SIZE_LIMIT)?;
583    conn.pragma_update(None, "temp_store", "MEMORY")?;
584    if options.foreign_keys {
585        conn.pragma_update(None, "foreign_keys", true)?;
586    }
587    apply_cache_settings(conn, options, kind)?;
588    if matches!(kind, ConnectionKind::Reader { .. }) {
589        // Readers never run `PRAGMA optimize`: it can write statistics, and the writer runs it
590        // when it opens. `query_only` is set after every other reader setup statement.
591        enforce_query_only(conn)?;
592    } else if options.optimize_on_open {
593        optimize_on_open(conn)?;
594    }
595    Ok(opened)
596}
597
598/// Profile cache and mmap sizes, then the reader override, which wins over the profile on readers
599/// only. Writers and in-memory connections keep the profile size.
600fn apply_cache_settings(
601    conn: &Connection,
602    options: &DbOptions,
603    kind: ConnectionKind,
604) -> Result<()> {
605    if options.profile == CacheProfile::LargeWorkingSet {
606        conn.pragma_update(None, "cache_size", -65_536_i64)?;
607        if !matches!(kind, ConnectionKind::Memory) {
608            conn.pragma_update(None, "mmap_size", 268_435_456_i64)?;
609        }
610    }
611    if let Some(kib) = kind.reader_cache_kib() {
612        conn.pragma_update(None, "cache_size", -i64::from(kib))?;
613    }
614    Ok(())
615}
616
617/// Sets `PRAGMA query_only = ON` on a reader and reads it back, so a reader that did not come up
618/// query-only is never handed out.
619fn enforce_query_only(conn: &Connection) -> Result<()> {
620    conn.pragma_update(None, "query_only", true)?;
621    let on: i64 = conn.query_row("PRAGMA query_only", [], |row| row.get(0))?;
622    if on != 1 {
623        return Err(DbError::ReadOnlyNotEffective(
624            "PRAGMA query_only did not stay ON".into(),
625        ));
626    }
627    Ok(())
628}
629
630fn set_rollback(conn: &Connection, mode: JournalSelect) -> Result<()> {
631    let pragma = if mode == JournalSelect::Truncate {
632        "PRAGMA journal_mode=TRUNCATE"
633    } else {
634        "PRAGMA journal_mode=DELETE"
635    };
636    conn.query_row(pragma, [], |row| row.get::<_, String>(0))?;
637    Ok(())
638}
639
640fn configure_journal(
641    conn: &Connection,
642    options: &DbOptions,
643    kind: ConnectionKind,
644    forced: Option<JournalSelect>,
645) -> Result<Opened> {
646    let wanted = forced.unwrap_or_else(|| effective_journal(options));
647    if wanted.is_rollback() {
648        // Rollback modes are per connection: a reader left in the default DELETE mode would
649        // try to delete a TRUNCATE writer's open journal (IOERR_DELETE on Windows).
650        set_rollback(conn, wanted)?;
651        return Ok(Opened::AsRequested);
652    }
653    let honored = conn
654        .query_row("PRAGMA journal_mode=WAL", [], |row| row.get::<_, String>(0))
655        .map(|mode| mode.eq_ignore_ascii_case("wal"))
656        .unwrap_or(false)
657        // `journal_mode=WAL` can report success before the -shm/-wal sidecars are usable;
658        // one empty write transaction proves the sidecars work before anything depends on them.
659        && (matches!(kind, ConnectionKind::Reader { .. })
660            || conn.execute_batch("BEGIN IMMEDIATE; ROLLBACK;").is_ok());
661    if honored {
662        return Ok(Opened::AsRequested);
663    }
664    if wanted == JournalSelect::WalStrict {
665        return Err(DbError::Sqlite(rusqlite::Error::SqliteFailure(
666            rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_IOERR),
667            Some("WAL journal mode was not honored by this filesystem".into()),
668        )));
669    }
670    let _ = conn.execute_batch("ROLLBACK;");
671    set_rollback(conn, fallback_mode(options))?;
672    Ok(Opened::FellBack)
673}
674
675/// The rollback journal used when WAL is unavailable: TRUNCATE with per-host fallback
676/// (CodeRight's network mode), DELETE otherwise.
677fn fallback_mode(options: &DbOptions) -> JournalSelect {
678    if options.per_host_fallback {
679        JournalSelect::Truncate
680    } else {
681        JournalSelect::Delete
682    }
683}
684
685/// Kill switches (the app's own variable first, then [`JOURNAL_ENV`]); set-but-empty or
686/// unrecognized values are ignored.
687fn env_journal(options: &DbOptions) -> Option<JournalSelect> {
688    options
689        .journal_env
690        .iter()
691        .map(String::as_str)
692        .chain([JOURNAL_ENV])
693        .find_map(|name| {
694            match std::env::var(name)
695                .ok()?
696                .trim()
697                .to_ascii_lowercase()
698                .as_str()
699            {
700                "wal" => Some(JournalSelect::WalStrict),
701                "delete" => Some(JournalSelect::Delete),
702                "truncate" => Some(JournalSelect::Truncate),
703                _ => None,
704            }
705        })
706}
707
708fn effective_journal(options: &DbOptions) -> JournalSelect {
709    if let Some(mode) = env_journal(options) {
710        return mode;
711    }
712    if options.journal == JournalSelect::WalWithFallback
713        && rightkit_fs::is_network_filesystem(&options.path)
714    {
715        return fallback_mode(options);
716    }
717    options.journal
718}
719
720/// The journal mode SQLite is actually running (`wal`, `delete`, ...).
721pub fn journal_mode(conn: &Connection) -> Result<String> {
722    Ok(conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))?)
723}
724
725/// Start a transaction that reserves SQLite's single writer slot up front.
726/// This keeps read-then-write work out of the unretryable BUSY_SNAPSHOT path.
727pub fn write_txn(conn: &mut Connection) -> Result<Transaction<'_>> {
728    conn.transaction_with_behavior(TransactionBehavior::Immediate)
729        .map_err(Into::into)
730}
731
732/// [`write_txn`] for a caller that holds only `&Connection`, such as a closure-style helper.
733/// It runs `BEGIN IMMEDIATE` through `Transaction::new_unchecked`, so the reserved writer slot is
734/// the same. The borrow checker does not stop a nested transaction here: SQLite refuses the
735/// `BEGIN` instead. The transaction rolls back when dropped unless it is committed.
736pub fn write_txn_unchecked(conn: &Connection) -> Result<Transaction<'_>> {
737    Transaction::new_unchecked(conn, TransactionBehavior::Immediate).map_err(Into::into)
738}
739
740#[cfg(feature = "cipher")]
741fn apply_key_first(conn: &Connection, key: Option<&str>) -> Result<()> {
742    if let Some(key) = key {
743        let statement = Zeroizing::new(format!("PRAGMA key = {key};"));
744        conn.execute_batch(statement.as_str())?;
745    }
746    Ok(())
747}
748
749#[cfg(not(feature = "cipher"))]
750fn apply_key_first(_conn: &Connection, key: Option<&str>) -> Result<()> {
751    if key.is_some() {
752        return Err(DbError::SqlcipherFeatureRequired);
753    }
754    Ok(())
755}
756
757fn validate_key(key: Option<&str>) -> Result<()> {
758    #[cfg(not(feature = "cipher"))]
759    if key.is_some() {
760        return Err(DbError::SqlcipherFeatureRequired);
761    }
762    if let Some(key) = key {
763        let safe = !key.is_empty()
764            && key.len() <= 4_096
765            && key.bytes().all(|byte| {
766                byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=' | b'-' | b'_')
767            });
768        if !safe {
769            return Err(DbError::InvalidSqlcipherKey);
770        }
771    }
772    Ok(())
773}
774
775fn verify_key(conn: &Connection) -> Result<()> {
776    conn.query_row("SELECT count(*) FROM sqlite_master", [], |row| {
777        row.get::<_, i64>(0)
778    })?;
779    Ok(())
780}
781
782fn run_integrity_pragma(conn: &Connection, pragma: &str) -> Result<()> {
783    let mut statement = conn.prepare(pragma)?;
784    let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
785    let messages = rows.collect::<std::result::Result<Vec<_>, _>>()?;
786    if messages.is_empty()
787        || messages
788            .iter()
789            .all(|message| message.eq_ignore_ascii_case("ok"))
790    {
791        return Ok(());
792    }
793    Err(DbError::Integrity(messages.join("; ")))
794}
795
796/// Options for a [`ReadPool`]. The default ([`ReadPoolOptions::new`]) matches
797/// [`DbOptions::open_read_pool`]: read-write file handles, with `query_only` set and verified on
798/// every reader. Build with the `with_*` methods; the fields are private.
799#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
800pub struct ReadPoolOptions {
801    read_only: bool,
802    cache_kib: Option<u32>,
803}
804
805impl ReadPoolOptions {
806    pub fn new() -> Self {
807        Self::default()
808    }
809
810    /// Opens each reader with `SQLITE_OPEN_READ_ONLY`, so writes fail even if `query_only` is later
811    /// turned off. The database must already exist: the pool never creates it, changes its journal
812    /// mode, narrows its permissions, or runs `optimize`. Each reader still runs the `init` hook,
813    /// as on the other connections. A rollback journal left by a crash is recovered by a writer,
814    /// not by a read-only reader. Default off.
815    pub fn with_read_only(mut self, on: bool) -> Self {
816        self.read_only = on;
817        self
818    }
819
820    /// Page cache size for each reader connection, in KiB. It is applied as `PRAGMA cache_size`
821    /// set to `-kib`, so the size does not depend on the page size. Valid values are 1 to
822    /// 2147483647; the pool checks this when it opens. It overrides
823    /// [`CacheProfile::LargeWorkingSet`] on readers only. Writers and [`DbOptions::open_reader`]
824    /// keep the profile size. Default: the profile's size.
825    pub fn with_reader_cache_kib(mut self, kib: u32) -> Self {
826        self.cache_kib = Some(kib);
827        self
828    }
829}
830
831/// A bounded pool of reader connections. Clones share one pool.
832#[derive(Clone)]
833pub struct ReadPool {
834    inner: Arc<ReadPoolInner>,
835}
836
837/// Opens one reader connection. A pool built from [`DbOptions`] uses that database's reader
838/// setup; [`ReadPool::from_opener`] supplies its own.
839type ReaderOpener = Box<dyn Fn() -> Result<Connection> + Send + Sync>;
840
841struct ReadPoolInner {
842    open: ReaderOpener,
843    cap: usize,
844    state: Mutex<ReadPoolState>,
845    /// Wakes a `get` waiting for capacity, and every waiter when the pool closes.
846    available: Condvar,
847    /// Wakes `close_and_wait` when a connection is released after the pool closed.
848    drained: Condvar,
849}
850
851#[derive(Default)]
852struct ReadPoolState {
853    idle: Vec<Connection>,
854    /// Idle, checked-out, and still-opening connections.
855    total: usize,
856    closed: bool,
857}
858
859impl ReadPoolInner {
860    fn open_reader(&self) -> Result<Connection> {
861        (self.open)()
862    }
863}
864
865pub struct PooledReadConnection {
866    conn: Option<Connection>,
867    pool: Weak<ReadPoolInner>,
868}
869
870impl Deref for PooledReadConnection {
871    type Target = Connection;
872
873    fn deref(&self) -> &Self::Target {
874        self.conn.as_ref().expect("pooled connection is present")
875    }
876}
877
878impl Drop for PooledReadConnection {
879    fn drop(&mut self) {
880        let Some(conn) = self.conn.take() else {
881            return;
882        };
883        let Some(pool) = self.pool.upgrade() else {
884            return;
885        };
886        let mut state = lock_recover(&pool.state);
887        if state.closed {
888            // Close the file before the count drops, so `close_and_wait` returns only once it is
889            // released. Checked under the same lock as `close`, so no connection is parked idle
890            // after the pool has closed.
891            drop(conn);
892            state.total -= 1;
893            pool.drained.notify_all();
894        } else {
895            state.idle.push(conn);
896            pool.available.notify_one();
897        }
898    }
899}
900
901impl ReadPool {
902    fn open(options: DbOptions, cap: usize, pool_options: ReadPoolOptions) -> Result<Self> {
903        if cap == 0 {
904            return Err(DbError::InvalidPoolCapacity);
905        }
906        if let Some(kib) = pool_options.cache_kib {
907            if kib == 0 || kib > i32::MAX.unsigned_abs() {
908                return Err(DbError::InvalidReaderCacheSize(kib));
909            }
910        }
911        Self::with_opener(
912            cap,
913            Box::new(move || options.open_pool_reader(pool_options)),
914        )
915    }
916
917    /// A pool whose readers come from `open`, called once now to check it works and again for
918    /// each new reader. The pool enforces `cap` and the closed and wait rules; `query_only` and
919    /// other reader setup are the opener's job. Intended for tests that need a pool over a
920    /// connection they build themselves. Use [`DbOptions::open_read_pool`] for a real database.
921    pub fn from_opener(
922        cap: usize,
923        open: impl Fn() -> Result<Connection> + Send + Sync + 'static,
924    ) -> Result<Self> {
925        Self::with_opener(cap, Box::new(open))
926    }
927
928    fn with_opener(cap: usize, open: ReaderOpener) -> Result<Self> {
929        if cap == 0 {
930            return Err(DbError::InvalidPoolCapacity);
931        }
932        let pool = Self {
933            inner: Arc::new(ReadPoolInner {
934                open,
935                cap,
936                state: Mutex::new(ReadPoolState::default()),
937                available: Condvar::new(),
938                drained: Condvar::new(),
939            }),
940        };
941        let conn = pool.inner.open_reader()?;
942        let mut state = lock_recover(&pool.inner.state);
943        state.total = 1;
944        state.idle.push(conn);
945        drop(state);
946        Ok(pool)
947    }
948
949    /// Checks out an idle reader, opens a new one while under the cap, or waits for a return.
950    /// Fails with [`DbError::ReadPoolClosed`] once the pool is closed, including while waiting.
951    /// The wait has no bound; use [`Self::get_timeout`] to give up after a deadline.
952    pub fn get(&self) -> Result<PooledReadConnection> {
953        match self.checkout(None)? {
954            Some(reader) => Ok(reader),
955            // Unreachable: an unbounded wait ends only with a reader or an error.
956            None => Err(DbError::ReadPoolClosed),
957        }
958    }
959
960    /// [`Self::get`] with a bounded wait. Returns `Ok(Some(reader))` as soon as a reader is idle
961    /// or can be opened under the cap, and `Ok(None)` if none frees up within `timeout`. A zero
962    /// `timeout` never waits: it returns an idle or newly opened reader, or `Ok(None)`. If the
963    /// pool is closed, or closes while waiting, the result is [`DbError::ReadPoolClosed`], never
964    /// `Ok(None)`. Opening a new reader is not bounded by `timeout`. A timeout too large for the
965    /// clock waits in bounded slices until a reader frees up or the pool closes.
966    pub fn get_timeout(&self, timeout: Duration) -> Result<Option<PooledReadConnection>> {
967        self.checkout(Some(timeout))
968    }
969
970    /// Shared checkout. `wait` is `None` for no deadline.
971    fn checkout(&self, wait: Option<Duration>) -> Result<Option<PooledReadConnection>> {
972        let started = Instant::now();
973        let deadline = wait.and_then(|wait| started.checked_add(wait));
974        let mut state = lock_recover(&self.inner.state);
975        loop {
976            if state.closed {
977                return Err(DbError::ReadPoolClosed);
978            }
979            if let Some(conn) = state.idle.pop() {
980                return Ok(Some(PooledReadConnection {
981                    conn: Some(conn),
982                    pool: Arc::downgrade(&self.inner),
983                }));
984            }
985            if state.total < self.inner.cap {
986                state.total += 1;
987                drop(state);
988                return self.open_checked_out().map(Some);
989            }
990            state = match (wait, deadline) {
991                (None, _) => match self.inner.available.wait(state) {
992                    Ok(state) => state,
993                    Err(poisoned) => poisoned.into_inner(),
994                },
995                (Some(_), Some(at)) => {
996                    let remaining = at.saturating_duration_since(Instant::now());
997                    if remaining.is_zero() {
998                        return Ok(None);
999                    }
1000                    match self.inner.available.wait_timeout(state, remaining) {
1001                        Ok((state, _)) => state,
1002                        Err(poisoned) => poisoned.into_inner().0,
1003                    }
1004                }
1005                (Some(_), None) => {
1006                    match self
1007                        .inner
1008                        .available
1009                        .wait_timeout(state, Duration::from_secs(60))
1010                    {
1011                        Ok((state, _)) => state,
1012                        Err(poisoned) => poisoned.into_inner().0,
1013                    }
1014                }
1015            };
1016        }
1017    }
1018
1019    /// Opens a reader for a `get` that reserved a slot. If the pool closed meanwhile, the new
1020    /// reader is dropped and the caller gets [`DbError::ReadPoolClosed`].
1021    fn open_checked_out(&self) -> Result<PooledReadConnection> {
1022        match self.inner.open_reader() {
1023            Ok(conn) => {
1024                let mut state = lock_recover(&self.inner.state);
1025                if state.closed {
1026                    drop(conn);
1027                    state.total -= 1;
1028                    self.inner.drained.notify_all();
1029                    return Err(DbError::ReadPoolClosed);
1030                }
1031                Ok(PooledReadConnection {
1032                    conn: Some(conn),
1033                    pool: Arc::downgrade(&self.inner),
1034                })
1035            }
1036            Err(error) => {
1037                let mut state = lock_recover(&self.inner.state);
1038                state.total -= 1;
1039                self.inner.available.notify_one();
1040                self.inner.drained.notify_all();
1041                Err(error)
1042            }
1043        }
1044    }
1045
1046    /// Stops handing out readers and drops the idle ones now. A reader still checked out closes
1047    /// when it is returned. Calling it again does nothing.
1048    pub fn close(&self) {
1049        let idle = {
1050            let mut state = lock_recover(&self.inner.state);
1051            if state.closed {
1052                return;
1053            }
1054            state.closed = true;
1055            self.inner.available.notify_all();
1056            std::mem::take(&mut state.idle)
1057        };
1058        let closed = idle.len();
1059        // Files close before the count drops, so `close_and_wait` sees them released.
1060        drop(idle);
1061        let mut state = lock_recover(&self.inner.state);
1062        state.total -= closed;
1063        self.inner.drained.notify_all();
1064    }
1065
1066    /// [`Self::close`], then waits up to `timeout` for every checked-out reader to be returned.
1067    /// `Ok(())` means every connection is closed and its database file released. Otherwise it
1068    /// returns [`DbError::ReadPoolCloseTimeout`] with the number still out. Do not call it while
1069    /// holding a reader from this pool: that reader cannot come back, so the call times out. A
1070    /// timeout too large for the clock waits in bounded slices until the readers return.
1071    pub fn close_and_wait(&self, timeout: Duration) -> Result<()> {
1072        self.close();
1073        let deadline = Instant::now().checked_add(timeout);
1074        let mut state = lock_recover(&self.inner.state);
1075        loop {
1076            if state.total == 0 {
1077                return Ok(());
1078            }
1079            let remaining = match deadline {
1080                Some(at) => at.saturating_duration_since(Instant::now()),
1081                None => Duration::from_secs(60),
1082            };
1083            if remaining.is_zero() {
1084                return Err(DbError::ReadPoolCloseTimeout {
1085                    outstanding: state.total,
1086                    timeout,
1087                });
1088            }
1089            let (next, _) = match self.inner.drained.wait_timeout(state, remaining) {
1090                Ok(pair) => pair,
1091                Err(poisoned) => poisoned.into_inner(),
1092            };
1093            state = next;
1094        }
1095    }
1096
1097    pub fn capacity(&self) -> usize {
1098        self.inner.cap
1099    }
1100
1101    pub fn idle_len(&self) -> usize {
1102        lock_recover(&self.inner.state).idle.len()
1103    }
1104
1105    pub fn total_len(&self) -> usize {
1106        lock_recover(&self.inner.state).total
1107    }
1108}
1109
1110fn lock_recover<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
1111    mutex
1112        .lock()
1113        .unwrap_or_else(|poisoned| poisoned.into_inner())
1114}
1115
1116#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1117pub struct CheckpointStatus {
1118    pub busy: i64,
1119    pub log_frames: i64,
1120    pub checkpointed_frames: i64,
1121}
1122
1123pub fn checkpoint_truncate(conn: &Connection) -> Result<CheckpointStatus> {
1124    checkpoint(conn, "PRAGMA wal_checkpoint(TRUNCATE)")
1125}
1126
1127/// `PRAGMA wal_checkpoint(RESTART)`: copies the log into the main file like PASSIVE, waits (under
1128/// the busy timeout) for readers still using the log, then the next writer starts it again from
1129/// the beginning. The log file keeps its size. Use it when the database is quiet and the log is
1130/// large; [`WalCheckpointJob`] decides when.
1131pub fn checkpoint_restart(conn: &Connection) -> Result<CheckpointStatus> {
1132    checkpoint(conn, "PRAGMA wal_checkpoint(RESTART)")
1133}
1134
1135pub fn checkpoint_if_wal_larger_than(
1136    conn: &Connection,
1137    db: &Path,
1138    bytes: u64,
1139) -> Result<Option<CheckpointStatus>> {
1140    let wal = wal_path(db)?;
1141    let size = match std::fs::metadata(wal) {
1142        Ok(metadata) => metadata.len(),
1143        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1144        Err(_) => return Ok(None),
1145    };
1146    if size <= bytes {
1147        return Ok(None);
1148    }
1149    checkpoint(conn, "PRAGMA wal_checkpoint(PASSIVE)").map(Some)
1150}
1151
1152fn checkpoint(conn: &Connection, pragma: &str) -> Result<CheckpointStatus> {
1153    conn.query_row(pragma, [], |row| {
1154        Ok(CheckpointStatus {
1155            busy: row.get(0)?,
1156            log_frames: row.get(1)?,
1157            checkpointed_frames: row.get(2)?,
1158        })
1159    })
1160    .map_err(Into::into)
1161}
1162
1163fn wal_path(db: &Path) -> Result<PathBuf> {
1164    let file_name = db.file_name().ok_or(DbError::InvalidDatabasePath)?;
1165    let mut wal_name = OsString::from(file_name);
1166    wal_name.push("-wal");
1167    Ok(db.with_file_name(wal_name))
1168}
1169
1170pub fn optimize_on_open(conn: &Connection) -> Result<()> {
1171    conn.execute_batch("PRAGMA optimize=0x10002;")?;
1172    Ok(())
1173}
1174
1175pub fn optimize_boundary(conn: &Connection) -> Result<()> {
1176    conn.execute_batch("PRAGMA optimize;")?;
1177    Ok(())
1178}