Skip to main content

rhiza_sql/
lib.rs

1use std::{
2    collections::HashSet,
3    fs::{self, File, OpenOptions},
4    io::{self, Read, Seek, SeekFrom, Write},
5    path::{Path, PathBuf},
6    sync::{
7        atomic::{AtomicBool, Ordering},
8        Mutex, OnceLock,
9    },
10    time::{Duration, Instant},
11};
12
13use rhiza_core::{
14    ConfigurationState, EntryType, LogAnchor, LogEntry, LogHash, LogIndex, RecoveryAnchor,
15    Snapshot, SnapshotIdentity, SnapshotManifest,
16};
17use rusqlite::{
18    config::DbConfig,
19    hooks::{AuthAction, AuthContext, Authorization},
20    params, params_from_iter,
21    types::{ToSql, ToSqlOutput, Value, ValueRef},
22    Connection, OpenFlags, OptionalExtension, Transaction, TransactionBehavior,
23};
24use serde::{Deserialize, Serialize};
25use sha2::{Digest, Sha256};
26use tempfile::NamedTempFile;
27
28use crate::page_state::{PageStateCacheV3, PageStatePatchV3};
29use crate::wal_capture::{capture_wal, WalCapture, WalCommit};
30
31mod control;
32mod page_state;
33mod qwal;
34mod wal_capture;
35
36pub use control::{ControlIdentity, ControlStore, PendingApply, RequestReceipt};
37pub use page_state::StateIdentityV3;
38pub use qwal::{
39    decode_qwal_v3, encode_qwal_v3, sqlite_page_size, QwalEnvelopeV3, QwalPageV3, QwalReceiptV3,
40    MAX_QWAL_V3_BYTES, MAX_QWAL_V3_RECEIPTS, QWAL_V3_MAGIC,
41};
42const CREATE_KV_TABLE_SQL: &str = r#"
43CREATE TABLE IF NOT EXISTS __rhiza_kv (
44    key TEXT PRIMARY KEY,
45    value TEXT NOT NULL
46);
47"#;
48
49const SQL_COMMAND_V2_MAGIC: &[u8] = b"QSQL\0\x02";
50const SQL_RESULT_V1_MAGIC: &[u8] = b"QRES\0\x01";
51const QWAL_SNAPSHOT_V3_MAGIC: &[u8] = b"QSNP\0\x04";
52const SQL_EXECUTOR_POLICY_VERSION: &str = "rhiza-sql-qwal-batch-v3-policy-v8-compat";
53const SQL_CONNECTION_PROFILE: &str = "qwal_batch_v3;wal_autocheckpoint=0;canonical_synchronous=OFF;control_synchronous=OFF;page_state_cache=rebuildable;staging_synchronous=OFF;foreign_keys=ON;trusted_schema=OFF;temp=command_scoped;attach=denied;vtable=bundled";
54pub const MAX_SQL_STATEMENTS: usize = 64;
55pub const MAX_SQL_PARAMETERS: usize = 999;
56pub const MAX_SQL_TEXT_BYTES: usize = 64 * 1024;
57pub const MAX_RETURNING_ROWS: usize = 1_024;
58pub const MAX_RETURNING_BYTES: usize = 1024 * 1024;
59pub const MAX_SQL_EFFECT_BYTES: usize = 512 * 1024;
60pub const DEFAULT_SQL_QUERY_TIMEOUT: Duration = Duration::from_secs(5);
61const SQL_PROGRESS_HANDLER_OPS: i32 = 1_000;
62
63#[derive(Deserialize, Serialize)]
64#[serde(deny_unknown_fields)]
65struct QwalSnapshotV3 {
66    user_db: Vec<u8>,
67    replicated_control: Vec<u8>,
68    user_state: StateIdentityV3,
69}
70
71#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
72#[serde(tag = "type", content = "value", rename_all = "snake_case")]
73pub enum SqlValue {
74    Null,
75    Integer(i64),
76    Real(f64),
77    Text(String),
78    Blob(Vec<u8>),
79}
80
81impl ToSql for SqlValue {
82    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
83        Ok(match self {
84            Self::Null => ToSqlOutput::Owned(Value::Null),
85            Self::Integer(value) => ToSqlOutput::Owned(Value::Integer(*value)),
86            Self::Real(value) => ToSqlOutput::Owned(Value::Real(*value)),
87            Self::Text(value) => ToSqlOutput::Borrowed(ValueRef::Text(value.as_bytes())),
88            Self::Blob(value) => ToSqlOutput::Borrowed(ValueRef::Blob(value)),
89        })
90    }
91}
92
93#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
94#[serde(deny_unknown_fields)]
95pub struct SqlStatement {
96    pub sql: String,
97    #[serde(default)]
98    pub parameters: Vec<SqlValue>,
99}
100
101#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
102#[serde(deny_unknown_fields)]
103pub struct SqlCommand {
104    pub request_id: String,
105    pub statements: Vec<SqlStatement>,
106}
107
108#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
109#[serde(deny_unknown_fields)]
110pub struct SqlQueryResult {
111    pub columns: Vec<String>,
112    pub rows: Vec<Vec<SqlValue>>,
113}
114
115#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
116#[serde(deny_unknown_fields)]
117pub struct SqlStatementResult {
118    pub rows_affected: u64,
119    pub returning: Option<SqlQueryResult>,
120}
121
122#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
123#[serde(deny_unknown_fields)]
124pub struct SqlCommandResult {
125    pub statement_results: Vec<SqlStatementResult>,
126}
127
128#[derive(Deserialize, Serialize)]
129#[serde(deny_unknown_fields)]
130struct SqlCommandV2Envelope {
131    executor_fingerprint: LogHash,
132    command: SqlCommand,
133}
134
135#[derive(Clone, Copy, Debug)]
136pub struct SqlBatchMember<'a> {
137    pub command: &'a SqlCommand,
138    pub request_payload: &'a [u8],
139}
140
141#[derive(Clone, Debug, PartialEq)]
142pub struct SqlBatchPreparation {
143    pub effect: Option<Vec<u8>>,
144    pub results: Vec<Result<SqlCommandResult>>,
145}
146
147struct PreparedQwalMutation {
148    receipts: Vec<QwalReceiptV3>,
149    results: Vec<Result<SqlCommandResult>>,
150}
151
152pub fn sql_executor_fingerprint() -> Result<LogHash> {
153    static FINGERPRINT: OnceLock<std::result::Result<LogHash, String>> = OnceLock::new();
154    FINGERPRINT
155        .get_or_init(compute_sql_executor_fingerprint)
156        .clone()
157        .map_err(Error::Sqlite)
158}
159
160fn compute_sql_executor_fingerprint() -> std::result::Result<LogHash, String> {
161    let conn = Connection::open_in_memory().map_err(|error| error.to_string())?;
162    let source_id: String = conn
163        .query_row("SELECT sqlite_source_id()", [], |row| row.get(0))
164        .map_err(|error| error.to_string())?;
165    let mut statement = conn
166        .prepare("PRAGMA compile_options")
167        .map_err(|error| error.to_string())?;
168    let mut compile_options = statement
169        .query_map([], |row| row.get::<_, String>(0))
170        .map_err(|error| error.to_string())?
171        .collect::<std::result::Result<Vec<_>, _>>()
172        .map_err(|error| error.to_string())?;
173    compile_options.sort_unstable();
174    let canonical = format!(
175        "{SQL_EXECUTOR_POLICY_VERSION}\n{SQL_CONNECTION_PROFILE}\n{}\n{}\n{}",
176        env!("CARGO_PKG_VERSION"),
177        source_id,
178        compile_options.join("\n")
179    );
180    Ok(LogHash::digest(&[canonical.as_bytes()]))
181}
182
183pub fn encode_sql_command(command: &SqlCommand) -> Result<Vec<u8>> {
184    validate_sql_command(command)?;
185    let encoded = serde_json::to_vec(&SqlCommandV2Envelope {
186        executor_fingerprint: sql_executor_fingerprint()?,
187        command: command.clone(),
188    })
189    .map_err(|error| Error::InvalidCommand(format!("cannot encode SQL command: {error}")))?;
190    let mut payload = Vec::with_capacity(SQL_COMMAND_V2_MAGIC.len() + encoded.len());
191    payload.extend_from_slice(SQL_COMMAND_V2_MAGIC);
192    payload.extend_from_slice(&encoded);
193    Ok(payload)
194}
195
196#[derive(Clone, Copy, Debug, Eq, PartialEq)]
197pub struct ApplyProgress {
198    applied_index: LogIndex,
199    applied_hash: LogHash,
200}
201
202impl ApplyProgress {
203    pub const fn new(applied_index: LogIndex, applied_hash: LogHash) -> Self {
204        Self {
205            applied_index,
206            applied_hash,
207        }
208    }
209
210    pub const fn applied_index(&self) -> LogIndex {
211        self.applied_index
212    }
213
214    pub const fn applied_hash(&self) -> LogHash {
215        self.applied_hash
216    }
217}
218
219#[derive(Clone, Debug, PartialEq)]
220pub struct ApplyOutcome {
221    progress: ApplyProgress,
222    sql_result: Option<SqlCommandResult>,
223}
224
225impl ApplyOutcome {
226    pub const fn progress(&self) -> ApplyProgress {
227        self.progress
228    }
229
230    pub const fn sql_result(&self) -> Option<&SqlCommandResult> {
231        self.sql_result.as_ref()
232    }
233}
234
235#[derive(Clone, Copy, Debug, Eq, PartialEq)]
236pub struct RequestOutcome {
237    original_log_index: LogIndex,
238    original_log_hash: LogHash,
239}
240
241impl RequestOutcome {
242    pub const fn new(original_log_index: LogIndex, original_log_hash: LogHash) -> Self {
243        Self {
244            original_log_index,
245            original_log_hash,
246        }
247    }
248
249    pub const fn original_log_index(&self) -> LogIndex {
250        self.original_log_index
251    }
252
253    pub const fn original_log_hash(&self) -> LogHash {
254        self.original_log_hash
255    }
256}
257
258#[derive(Clone, Debug, Eq, PartialEq)]
259pub struct RequestConflict {
260    request_id: String,
261    original_outcome: RequestOutcome,
262}
263
264impl RequestConflict {
265    pub fn request_id(&self) -> &str {
266        &self.request_id
267    }
268
269    pub const fn original_outcome(&self) -> RequestOutcome {
270        self.original_outcome
271    }
272}
273
274impl std::fmt::Display for RequestConflict {
275    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
276        write!(
277            f,
278            "request id reused with different payload: {}",
279            self.request_id
280        )
281    }
282}
283
284impl std::error::Error for RequestConflict {}
285
286pub type Result<T> = std::result::Result<T, Error>;
287pub type SqlRequestLookup = Result<Option<(RequestOutcome, Option<SqlCommandResult>)>>;
288
289#[derive(Clone, Debug, Eq, PartialEq)]
290pub enum Error {
291    ApplyFailed,
292    RestoreFailed,
293    Io(String),
294    Sqlite(String),
295    ResourceExhausted(String),
296    InvalidCommand(String),
297    IdentityMismatch(String),
298    InvalidEntry(String),
299    RequestConflict(RequestConflict),
300    InvalidSnapshot(String),
301}
302
303impl std::fmt::Display for Error {
304    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
305        match self {
306            Self::ApplyFailed => write!(f, "SQLite apply failed"),
307            Self::RestoreFailed => write!(f, "SQLite restore failed"),
308            Self::Io(message) => write!(f, "SQLite io failed: {message}"),
309            Self::Sqlite(message) => write!(f, "SQLite error: {message}"),
310            Self::ResourceExhausted(message) => write!(f, "SQLite resource exhausted: {message}"),
311            Self::InvalidCommand(message) => write!(f, "invalid deterministic command: {message}"),
312            Self::IdentityMismatch(field) => {
313                write!(f, "SQLite database identity mismatch for {field}")
314            }
315            Self::InvalidEntry(message) => write!(f, "invalid log entry: {message}"),
316            Self::RequestConflict(conflict) => conflict.fmt(f),
317            Self::InvalidSnapshot(message) => write!(f, "invalid SQLite snapshot: {message}"),
318        }
319    }
320}
321
322impl std::error::Error for Error {}
323
324pub trait StateMachine {
325    fn applied_index(&self) -> Result<LogIndex>;
326    fn apply(&self, entry: &LogEntry) -> Result<ApplyProgress>;
327    fn create_snapshot(&self, target: LogIndex) -> Result<Snapshot>;
328}
329
330pub struct SqliteStateMachine {
331    path: PathBuf,
332    conn: Mutex<Option<Connection>>,
333    lifecycle: Mutex<()>,
334    control: ControlStore,
335    pending_fence: AtomicBool,
336    page_state: Mutex<CanonicalPageStateV3>,
337    uncommitted_effect: Mutex<Option<LogHash>>,
338    prepared_target: Mutex<Option<PreparedTarget>>,
339}
340
341#[derive(Clone, Debug, Eq, PartialEq)]
342struct CanonicalPageStateV3 {
343    cache: PageStateCacheV3,
344    seal: Option<PreparedBaseSeal>,
345}
346
347struct PreparedTarget {
348    artifact: NamedTempFile,
349    target_seal: Option<PreparedBaseSeal>,
350    base_file: File,
351    base_seal: Option<PreparedBaseSeal>,
352    cluster_id: String,
353    node_id: String,
354    epoch: u64,
355    configuration_id: u64,
356    recovery_generation: u64,
357    materializer_fingerprint: String,
358    base_index: LogIndex,
359    base_hash: LogHash,
360    base_state: StateIdentityV3,
361    target_state: StateIdentityV3,
362    effect_digest: LogHash,
363}
364
365#[derive(Clone, Copy, Debug, Eq, PartialEq)]
366struct PreparedBaseSeal {
367    dev: u64,
368    ino: u64,
369    len: u64,
370    mtime: i64,
371    mtime_nsec: i64,
372    ctime: i64,
373    ctime_nsec: i64,
374}
375
376impl PreparedTarget {
377    fn matches(
378        &self,
379        effect: &QwalEnvelopeV3,
380        effect_payload: &[u8],
381        identity: &ControlIdentity,
382    ) -> bool {
383        self.cluster_id == effect.cluster_id
384            && self.cluster_id == identity.cluster_id()
385            && self.node_id == identity.node_id()
386            && self.epoch == effect.epoch
387            && self.epoch == identity.epoch()
388            && self.configuration_id == effect.configuration_id
389            && self.recovery_generation == effect.recovery_generation
390            && self.materializer_fingerprint == effect.materializer_fingerprint
391            && self.base_index == effect.base_index
392            && self.base_hash == effect.base_hash
393            && self.base_state == effect.base_state
394            && self.target_state == effect.target_state
395            && self.effect_digest == LogHash::digest(&[effect_payload])
396    }
397}
398
399#[derive(Clone, Debug, Eq, PartialEq)]
400pub struct RecoverySnapshot {
401    snapshot: Snapshot,
402    anchor: RecoveryAnchor,
403}
404
405impl RecoverySnapshot {
406    pub const fn snapshot(&self) -> &Snapshot {
407        &self.snapshot
408    }
409
410    pub fn db_bytes(&self) -> &[u8] {
411        self.snapshot.db_bytes()
412    }
413
414    pub const fn anchor(&self) -> &RecoveryAnchor {
415        &self.anchor
416    }
417}
418
419impl SqliteStateMachine {
420    pub fn open(
421        path: impl AsRef<Path>,
422        cluster_id: &str,
423        node_id: &str,
424        epoch: u64,
425        config_id: u64,
426    ) -> Result<Self> {
427        Self::open_with_configuration(
428            path,
429            cluster_id,
430            node_id,
431            epoch,
432            ConfigurationState::active(config_id, LogHash::ZERO),
433        )
434    }
435
436    pub fn open_with_configuration(
437        path: impl AsRef<Path>,
438        cluster_id: &str,
439        node_id: &str,
440        epoch: u64,
441        configuration_state: ConfigurationState,
442    ) -> Result<Self> {
443        let path = path.as_ref().to_path_buf();
444        ensure_parent(&path)?;
445
446        let control_path = control_sidecar_path(&path);
447        match (path.exists(), control_path.exists()) {
448            (false, false) => {
449                Self::create_new(&path, cluster_id, node_id, epoch, configuration_state)
450            }
451            (true, true) => {
452                let db = Self::open_existing_file(&path)?;
453                db.validate_control_identity(cluster_id, node_id, epoch)?;
454                Ok(db)
455            }
456            (true, false) => Err(Error::IdentityMismatch(
457                "QWAL control sidecar is missing; install a QWAL snapshot instead of auto-migrating"
458                    .into(),
459            )),
460            (false, true) => Err(Error::IdentityMismatch(
461                "canonical SQLite database is missing beside its QWAL control sidecar".into(),
462            )),
463        }
464    }
465
466    pub fn open_existing(path: impl AsRef<Path>) -> Result<Self> {
467        Self::open_existing_file(path.as_ref())
468    }
469
470    fn create_new(
471        path: &Path,
472        cluster_id: &str,
473        node_id: &str,
474        epoch: u64,
475        configuration_state: ConfigurationState,
476    ) -> Result<Self> {
477        OpenOptions::new()
478            .write(true)
479            .create_new(true)
480            .open(path)
481            .map_err(|err| Error::Io(err.to_string()))?;
482
483        let control_path = control_sidecar_path(path);
484        let created = (|| {
485            let conn = open_connection(path)?;
486            conn.execute_batch(CREATE_KV_TABLE_SQL)
487                .map_err(sqlite_error)?;
488            conn.execute_batch("VACUUM; PRAGMA wal_checkpoint(TRUNCATE);")
489                .map_err(sqlite_error)?;
490            conn.close()
491                .map_err(|(_, error)| Error::Sqlite(error.to_string()))?;
492            let page_state = rebuild_sealed_page_state(path)?;
493            let identity = ControlIdentity::new(
494                cluster_id,
495                node_id,
496                epoch,
497                configuration_state,
498                1,
499                sql_executor_fingerprint()?,
500                page_state.cache.identity(),
501            );
502            let control = ControlStore::create(&control_path, &identity)?;
503            let conn = open_connection(path)?;
504            Ok(Self {
505                path: path.to_path_buf(),
506                conn: Mutex::new(Some(conn)),
507                lifecycle: Mutex::new(()),
508                control,
509                pending_fence: AtomicBool::new(false),
510                page_state: Mutex::new(page_state),
511                uncommitted_effect: Mutex::new(None),
512                prepared_target: Mutex::new(None),
513            })
514        })();
515        if created.is_err() {
516            let _ = fs::remove_file(path);
517            let _ = fs::remove_file(&control_path);
518        }
519        created
520    }
521
522    fn open_existing_file(path: &Path) -> Result<Self> {
523        let control_path = control_sidecar_path(path);
524        if !path.exists() || !control_path.exists() {
525            return Err(Error::IdentityMismatch(
526                "QWAL database and control sidecar must both exist".into(),
527            ));
528        }
529        let control = ControlStore::open_existing_unchecked(&control_path)?;
530        let (page_state, pending) = validate_control_database_pair(path, &control)?;
531        let conn = open_connection(path)?;
532        Ok(Self {
533            path: path.to_path_buf(),
534            conn: Mutex::new(Some(conn)),
535            lifecycle: Mutex::new(()),
536            control,
537            pending_fence: AtomicBool::new(pending),
538            page_state: Mutex::new(page_state),
539            uncommitted_effect: Mutex::new(None),
540            prepared_target: Mutex::new(None),
541        })
542    }
543
544    fn validate_control_identity(&self, cluster_id: &str, node_id: &str, epoch: u64) -> Result<()> {
545        let identity = self.control.identity()?;
546        if identity.cluster_id() != cluster_id {
547            return Err(Error::IdentityMismatch("cluster_id".into()));
548        }
549        if identity.node_id() != node_id {
550            return Err(Error::IdentityMismatch("node_id".into()));
551        }
552        if identity.epoch() != epoch {
553            return Err(Error::IdentityMismatch("epoch".into()));
554        }
555        if identity.materializer_fingerprint() != sql_executor_fingerprint()? {
556            return Err(Error::IdentityMismatch(
557                "SQLite QWAL materializer fingerprint".into(),
558            ));
559        }
560        Ok(())
561    }
562
563    fn with_connection<T>(&self, operation: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
564        let guard = self
565            .conn
566            .lock()
567            .map_err(|_| Error::Sqlite("SQLite connection lock is poisoned".into()))?;
568        let conn = guard
569            .as_ref()
570            .ok_or_else(|| Error::Sqlite("SQLite connection is closed".into()))?;
571        operation(conn)
572    }
573
574    fn lock_lifecycle(&self) -> Result<std::sync::MutexGuard<'_, ()>> {
575        self.lifecycle
576            .lock()
577            .map_err(|_| Error::Sqlite("SQLite lifecycle lock is poisoned".into()))
578    }
579
580    fn ensure_no_pending_apply(&self) -> Result<()> {
581        if self.pending_fence.load(Ordering::Acquire) {
582            return Err(Error::InvalidEntry(
583                "canonical SQLite state is unavailable while a QWAL apply is pending".into(),
584            ));
585        }
586        Ok(())
587    }
588
589    fn close_connection(&self) -> Result<()> {
590        let conn = self
591            .conn
592            .lock()
593            .map_err(|_| Error::Sqlite("SQLite connection lock is poisoned".into()))?
594            .take();
595        if let Some(conn) = conn {
596            conn.close()
597                .map_err(|(_, error)| Error::Sqlite(error.to_string()))?;
598        }
599        Ok(())
600    }
601
602    /// Closes a detached state machine so another owner can reopen the canonical database.
603    ///
604    /// SQLite may retain empty WAL sidecars after the last connection closes on some
605    /// filesystems. Once the WAL is checkpointed and the connection is closed, those sidecars
606    /// are owned crash artifacts and must be removed before the strict reopen validation.
607    pub fn close_for_handoff(self) -> Result<()> {
608        let _lifecycle = self.lock_lifecycle()?;
609        self.ensure_no_pending_apply()?;
610        self.with_connection(checkpoint_truncate)?;
611        self.close_connection()?;
612        cleanup_empty_wal_sidecars_after_owner_fence(&self.path)?;
613        validate_control_database_pair(&self.path, &self.control)?;
614        Ok(())
615    }
616
617    fn reopen_connection(&self) -> Result<()> {
618        let reopened = open_connection(&self.path)?;
619        let mut guard = self
620            .conn
621            .lock()
622            .map_err(|_| Error::Sqlite("SQLite connection lock is poisoned".into()))?;
623        if guard.is_some() {
624            return Err(Error::Sqlite(
625                "refusing to replace an open SQLite connection".into(),
626            ));
627        }
628        *guard = Some(reopened);
629        Ok(())
630    }
631
632    pub fn apply_entry(&self, entry: &LogEntry) -> Result<ApplyProgress> {
633        Ok(self.apply_entry_with_result(entry)?.progress())
634    }
635
636    pub fn apply_entry_with_result(&self, entry: &LogEntry) -> Result<ApplyOutcome> {
637        let _lifecycle = self.lock_lifecycle()?;
638        self.ensure_page_state_sealed()?;
639        if entry.recompute_hash() != entry.hash {
640            return Err(Error::InvalidEntry(
641                "hash does not match entry contents".into(),
642            ));
643        }
644        let installed = *self
645            .uncommitted_effect
646            .lock()
647            .map_err(|_| Error::Sqlite("uncommitted QWAL effect lock is poisoned".into()))?;
648        if let Some(installed) = installed {
649            if entry.entry_type != EntryType::Command
650                || installed != LogHash::digest(&[&entry.payload])
651            {
652                return Err(Error::InvalidEntry(
653                    "only the exact installed QWAL effect may retry after control failure".into(),
654                ));
655            }
656        } else if self.pending_fence.load(Ordering::Acquire) {
657            let incoming = LogAnchor::new(entry.index, entry.hash);
658            if self.control.pending()?.map(|pending| pending.entry()) != Some(incoming) {
659                return Err(Error::InvalidEntry(
660                    "only the exact durable pending entry may retry physical apply".into(),
661                ));
662            }
663        }
664        self.apply_qwal_entry(entry)
665    }
666
667    fn apply_qwal_entry(&self, entry: &LogEntry) -> Result<ApplyOutcome> {
668        let identity = self.control.identity()?;
669        if entry.cluster_id != identity.cluster_id() {
670            return Err(Error::InvalidEntry(
671                "cluster_id does not match sidecar".into(),
672            ));
673        }
674        if entry.epoch != identity.epoch() {
675            return Err(Error::InvalidEntry("epoch does not match sidecar".into()));
676        }
677        let tip = self.control.applied_tip()?;
678        if entry.index == tip.applied_index() {
679            if entry.hash != tip.applied_hash() {
680                return Err(Error::InvalidEntry(
681                    "current index was reapplied with a different hash".into(),
682                ));
683            }
684            let sql_result = if entry.entry_type == EntryType::Command {
685                let effect = decode_qwal_command(&entry.payload)?;
686                if let [effect_receipt] = effect.receipts.as_slice() {
687                    self.control
688                        .lookup_request(&effect_receipt.request_id, effect_receipt.request_digest)?
689                        .map(|receipt| decode_sql_result(receipt.result_blob()))
690                        .transpose()?
691                } else {
692                    None
693                }
694            } else {
695                None
696            };
697            return Ok(ApplyOutcome {
698                progress: tip,
699                sql_result,
700            });
701        }
702        let next_index = tip
703            .applied_index()
704            .checked_add(1)
705            .ok_or_else(|| Error::InvalidEntry("applied index is exhausted".into()))?;
706        if entry.index != next_index || entry.prev_hash != tip.applied_hash() {
707            return Err(Error::InvalidEntry(
708                "entry does not extend the QWAL applied tip".into(),
709            ));
710        }
711        let current_configuration = self.control.configuration_state()?;
712        let next_configuration = current_configuration
713            .validate_entry(entry)
714            .map_err(|error| Error::InvalidEntry(error.to_string()))?;
715        let base_anchor = LogAnchor::new(tip.applied_index(), tip.applied_hash());
716        let entry_anchor = LogAnchor::new(entry.index, entry.hash);
717
718        if entry.entry_type != EntryType::Command {
719            self.discard_prepared_target()?;
720            match entry.entry_type {
721                EntryType::Noop if entry.payload.is_empty() => {}
722                EntryType::ConfigChange => {}
723                EntryType::Noop => {
724                    return Err(Error::InvalidEntry("Noop payload must be empty".into()))
725                }
726                _ => {
727                    return Err(Error::InvalidEntry(format!(
728                        "entry type {:?} is unsupported by QWAL",
729                        entry.entry_type
730                    )))
731                }
732            }
733            let user_state = self.control.user_state()?;
734            if entry.entry_type == EntryType::Noop && next_configuration == current_configuration {
735                self.control.commit_metadata_only_entry_with_log(
736                    base_anchor,
737                    entry,
738                    &current_configuration,
739                    user_state,
740                )?;
741            } else {
742                let pending = PendingApply::new(base_anchor, entry_anchor, user_state, user_state);
743                self.pending_fence.store(true, Ordering::Release);
744                self.control.begin_pending_with_entry(&pending, entry)?;
745                #[cfg(test)]
746                inject_pending_commit_fault(&self.path)?;
747                self.control
748                    .commit_applied(&pending, &next_configuration, &[])?;
749                self.pending_fence.store(false, Ordering::Release);
750            }
751            return Ok(ApplyOutcome {
752                progress: ApplyProgress::new(entry.index, entry.hash),
753                sql_result: None,
754            });
755        }
756
757        let effect = match decode_qwal_command(&entry.payload) {
758            Ok(effect) => effect,
759            Err(error) => {
760                self.discard_prepared_target()?;
761                return Err(error);
762            }
763        };
764        self.discard_prepared_target_unless(&effect, &entry.payload, &identity)?;
765        validate_qwal_identity(&effect, &identity, &current_configuration)?;
766        if effect.base_index != tip.applied_index() || effect.base_hash != tip.applied_hash() {
767            return Err(Error::InvalidEntry(
768                "QWAL effect base does not match the applied tip".into(),
769            ));
770        }
771        let mut results = Vec::with_capacity(effect.receipts.len());
772        let mut receipts = Vec::with_capacity(effect.receipts.len());
773        for effect_receipt in &effect.receipts {
774            let result = decode_sql_result(&effect_receipt.result_blob)?;
775            if encode_sql_result(&result)? != effect_receipt.result_blob {
776                return Err(Error::InvalidEntry(
777                    "QWAL result is not canonically encoded".into(),
778                ));
779            }
780            results.push(result);
781            receipts.push(RequestReceipt::new(
782                effect_receipt.request_id.clone(),
783                effect_receipt.request_digest,
784                entry_anchor,
785                effect_receipt.result_blob.clone(),
786            ));
787        }
788        let lookup_keys = effect
789            .receipts
790            .iter()
791            .map(|receipt| (receipt.request_id.as_str(), receipt.request_digest))
792            .collect::<Vec<_>>();
793        let existing = self.control.lookup_requests(&lookup_keys)?;
794        for (expected, existing) in receipts.iter().zip(existing) {
795            match existing? {
796                None => {}
797                Some(existing) if existing == *expected => {}
798                Some(_) => {
799                    return Err(Error::InvalidEntry(
800                        "QWAL request receipt already belongs to another entry or result".into(),
801                    ));
802                }
803            }
804        }
805        let pending = PendingApply::new(
806            base_anchor,
807            entry_anchor,
808            effect.base_state,
809            effect.target_state,
810        );
811        self.pending_fence.store(true, Ordering::Release);
812        self.install_qwal_effect(&effect, &entry.payload, entry_anchor)?;
813        #[cfg(test)]
814        let control_commit = inject_qwal_control_fault(&self.path).and_then(|()| {
815            self.control
816                .commit_rebuildable_apply(&pending, entry, &next_configuration, &receipts)
817        });
818        #[cfg(not(test))]
819        let control_commit =
820            self.control
821                .commit_rebuildable_apply(&pending, entry, &next_configuration, &receipts);
822        if let Err(error) = control_commit {
823            let _ = self.close_connection();
824            return Err(error);
825        }
826        self.pending_fence.store(false, Ordering::Release);
827        self.uncommitted_effect
828            .lock()
829            .map_err(|_| Error::Sqlite("uncommitted QWAL effect lock is poisoned".into()))?
830            .take();
831        Ok(ApplyOutcome {
832            progress: ApplyProgress::new(entry.index, entry.hash),
833            sql_result: if results.len() == 1 {
834                results.pop()
835            } else {
836                None
837            },
838        })
839    }
840
841    fn install_qwal_effect(
842        &self,
843        effect: &QwalEnvelopeV3,
844        effect_payload: &[u8],
845        entry_anchor: LogAnchor,
846    ) -> Result<()> {
847        if !self.preverify_page_state_transition(effect, effect_payload, entry_anchor)? {
848            self.remember_uncommitted_effect(effect_payload)?;
849            if self
850                .conn
851                .lock()
852                .map_err(|_| Error::Sqlite("SQLite connection lock is poisoned".into()))?
853                .is_none()
854            {
855                self.reopen_connection()?;
856            }
857            return Ok(());
858        };
859        #[cfg(test)]
860        begin_prepared_base_reuse_audit(&self.path);
861        if let Some(prepared) = self.take_matching_prepared_target(effect, effect_payload)? {
862            self.close_connection()?;
863            match self.promote_prepared_target(&prepared, effect) {
864                Ok(true) => {
865                    let mut installed = OpenOptions::new()
866                        .read(true)
867                        .write(true)
868                        .open(&self.path)
869                        .map_err(io_error)?;
870                    qwal::verify_installed_pages(&mut installed, effect)?;
871                    self.publish_page_state_after_install(effect, &installed)?;
872                    self.remember_uncommitted_effect(effect_payload)?;
873                    #[cfg(test)]
874                    note_prepared_install(&self.path, PreparedInstallPath::Promoted);
875                    return self.reopen_connection();
876                }
877                Ok(false) => self.reopen_connection()?,
878                Err(error) => {
879                    let _ = self.reopen_connection();
880                    return Err(error);
881                }
882            }
883        }
884        #[cfg(test)]
885        note_second_checkpoint(&self.path);
886        self.with_connection(checkpoint_truncate)?;
887        self.close_connection()?;
888        let mut canonical = self.open_bound_canonical()?;
889        qwal::apply_preverified_qwal_in_place(&mut canonical, effect, |page_no| {
890            #[cfg(test)]
891            inject_qwal_apply_fault(&self.path, page_no)?;
892            #[cfg(not(test))]
893            let _ = page_no;
894            Ok(())
895        })?;
896        self.publish_page_state_after_install(effect, &canonical)?;
897        self.remember_uncommitted_effect(effect_payload)?;
898        #[cfg(test)]
899        note_prepared_install(&self.path, PreparedInstallPath::Patched);
900        self.reopen_connection()
901    }
902
903    fn preverify_page_state_transition(
904        &self,
905        effect: &QwalEnvelopeV3,
906        effect_payload: &[u8],
907        entry_anchor: LogAnchor,
908    ) -> Result<bool> {
909        let page_state = self
910            .page_state
911            .lock()
912            .map_err(|_| Error::Sqlite("SQLite page-state cache lock is poisoned".into()))?;
913        verify_bound_canonical(&self.path, &page_state)?;
914        let current = page_state.cache.identity();
915        if current == effect.target_state && current != effect.base_state {
916            let digest = LogHash::digest(&[effect_payload]);
917            let installed = self
918                .uncommitted_effect
919                .lock()
920                .map_err(|_| Error::Sqlite("uncommitted QWAL effect lock is poisoned".into()))?;
921            let exact_in_process_replay = *installed == Some(digest);
922            drop(installed);
923            let durable_replay =
924                if exact_in_process_replay || !self.pending_fence.load(Ordering::Acquire) {
925                    false
926                } else {
927                    self.control.pending()?.is_some_and(|pending| {
928                        pending.base_state() == effect.base_state
929                            && pending.target_state() == effect.target_state
930                            && pending.entry() == entry_anchor
931                    })
932                };
933            if !exact_in_process_replay && !durable_replay {
934                return Err(Error::InvalidEntry(
935                    "QWAL target is installed without an exact in-process replay seal".into(),
936                ));
937            }
938            return Ok(false);
939        }
940        if current != effect.base_state {
941            return Err(Error::InvalidEntry(
942                "QWAL base page state does not match the canonical cache".into(),
943            ));
944        }
945        let patches = effect
946            .pages
947            .iter()
948            .map(|page| PageStatePatchV3::new(page.page_no, &page.after_image))
949            .collect::<Vec<_>>();
950        let target = page_state
951            .cache
952            .overlay(effect.target_state.page_count, &patches)?;
953        if target != effect.target_state {
954            return Err(Error::InvalidEntry(
955                "QWAL target page state mismatch".into(),
956            ));
957        }
958        Ok(current != effect.target_state)
959    }
960
961    fn ensure_page_state_sealed(&self) -> Result<()> {
962        let page_state = self
963            .page_state
964            .lock()
965            .map_err(|_| Error::Sqlite("SQLite page-state cache lock is poisoned".into()))?;
966        verify_bound_canonical(&self.path, &page_state)
967    }
968
969    fn open_bound_canonical(&self) -> Result<File> {
970        if !sqlite_sidecars_absent(&self.path)? {
971            return Err(Error::InvalidEntry(
972                "closed canonical SQLite database still has WAL sidecars".into(),
973            ));
974        }
975        let page_state = self
976            .page_state
977            .lock()
978            .map_err(|_| Error::Sqlite("SQLite page-state cache lock is poisoned".into()))?;
979        open_bound_canonical(&self.path, &page_state)
980    }
981
982    fn publish_page_state_after_install(
983        &self,
984        effect: &QwalEnvelopeV3,
985        canonical: &File,
986    ) -> Result<()> {
987        let seal = seal_held_canonical(&self.path, canonical)?;
988        let mut page_state = self
989            .page_state
990            .lock()
991            .map_err(|_| Error::Sqlite("SQLite page-state cache lock is poisoned".into()))?;
992        let patches = effect
993            .pages
994            .iter()
995            .map(|page| PageStatePatchV3::new(page.page_no, &page.after_image))
996            .collect::<Vec<_>>();
997        let target = page_state
998            .cache
999            .apply_patch(effect.target_state.page_count, &patches)?;
1000        if target != effect.target_state {
1001            return Err(Error::InvalidEntry(
1002                "installed QWAL target page state invariant failed".into(),
1003            ));
1004        }
1005        page_state.seal = seal;
1006        Ok(())
1007    }
1008
1009    fn remember_uncommitted_effect(&self, effect_payload: &[u8]) -> Result<()> {
1010        *self
1011            .uncommitted_effect
1012            .lock()
1013            .map_err(|_| Error::Sqlite("uncommitted QWAL effect lock is poisoned".into()))? =
1014            Some(LogHash::digest(&[effect_payload]));
1015        Ok(())
1016    }
1017
1018    fn discard_prepared_target(&self) -> Result<()> {
1019        self.prepared_target
1020            .lock()
1021            .map_err(|_| Error::Sqlite("prepared SQLite target lock is poisoned".into()))?
1022            .take();
1023        Ok(())
1024    }
1025
1026    fn discard_prepared_target_unless(
1027        &self,
1028        effect: &QwalEnvelopeV3,
1029        effect_payload: &[u8],
1030        identity: &ControlIdentity,
1031    ) -> Result<()> {
1032        let mut prepared = self
1033            .prepared_target
1034            .lock()
1035            .map_err(|_| Error::Sqlite("prepared SQLite target lock is poisoned".into()))?;
1036        if prepared
1037            .as_ref()
1038            .is_some_and(|prepared| !prepared.matches(effect, effect_payload, identity))
1039        {
1040            prepared.take();
1041        }
1042        Ok(())
1043    }
1044
1045    fn take_matching_prepared_target(
1046        &self,
1047        effect: &QwalEnvelopeV3,
1048        effect_payload: &[u8],
1049    ) -> Result<Option<PreparedTarget>> {
1050        let identity = self.control.identity()?;
1051        let mut prepared = self
1052            .prepared_target
1053            .lock()
1054            .map_err(|_| Error::Sqlite("prepared SQLite target lock is poisoned".into()))?;
1055        Ok(prepared
1056            .take()
1057            .filter(|prepared| prepared.matches(effect, effect_payload, &identity)))
1058    }
1059
1060    fn promote_prepared_target(
1061        &self,
1062        prepared: &PreparedTarget,
1063        effect: &QwalEnvelopeV3,
1064    ) -> Result<bool> {
1065        if !prepared_base_still_sealed(&self.path, prepared)?
1066            || !sqlite_sidecars_absent(prepared.artifact.path())?
1067        {
1068            return Ok(false);
1069        }
1070        let owned_metadata = prepared.artifact.as_file().metadata().map_err(io_error)?;
1071        let Some(named_metadata) = symlink_metadata_if_exists(prepared.artifact.path())? else {
1072            return Ok(false);
1073        };
1074        if !prepared_base_metadata_matches(
1075            prepared.target_seal.as_ref(),
1076            &owned_metadata,
1077            &named_metadata,
1078        ) {
1079            return Ok(false);
1080        }
1081        if owned_metadata.len()
1082            != u64::from(effect.target_state.page_size) * u64::from(effect.target_state.page_count)
1083        {
1084            return Ok(false);
1085        }
1086        let Some(rename_metadata) = symlink_metadata_if_exists(prepared.artifact.path())? else {
1087            return Ok(false);
1088        };
1089        if !prepared_base_metadata_matches(
1090            prepared.target_seal.as_ref(),
1091            &owned_metadata,
1092            &rename_metadata,
1093        ) || !prepared_base_still_sealed(&self.path, prepared)?
1094        {
1095            return Ok(false);
1096        }
1097        // The lifecycle lock excludes in-process renames. std has no portable
1098        // rename-by-handle primitive, so an external actor with write access to
1099        // this private directory could still race this final lstat and rename.
1100        if let Err(error) = fs::rename(prepared.artifact.path(), &self.path) {
1101            if error.kind() == std::io::ErrorKind::NotFound {
1102                return Ok(false);
1103            }
1104            return Err(io_error(error));
1105        }
1106        Ok(true)
1107    }
1108
1109    pub fn get_value(&self, key: &str) -> Result<Option<String>> {
1110        let _lifecycle = self.lock_lifecycle()?;
1111        self.ensure_no_pending_apply()?;
1112        self.with_connection(|conn| {
1113            conn.query_row(
1114                "SELECT value FROM __rhiza_kv WHERE key = ?1",
1115                params![key],
1116                |row| row.get(0),
1117            )
1118            .optional()
1119            .map_err(sqlite_error)
1120        })
1121    }
1122
1123    pub fn query_sql(
1124        &self,
1125        query: &SqlStatement,
1126        max_rows: usize,
1127        max_bytes: usize,
1128    ) -> Result<SqlQueryResult> {
1129        self.query_sql_with_timeout(query, max_rows, max_bytes, DEFAULT_SQL_QUERY_TIMEOUT)
1130    }
1131
1132    pub fn query_sql_with_timeout(
1133        &self,
1134        query: &SqlStatement,
1135        max_rows: usize,
1136        max_bytes: usize,
1137        timeout: Duration,
1138    ) -> Result<SqlQueryResult> {
1139        let _lifecycle = self.lock_lifecycle()?;
1140        validate_sql_statement(query)?;
1141        self.ensure_no_pending_apply()?;
1142        if max_rows == 0 || max_bytes == 0 {
1143            return Err(Error::InvalidCommand(
1144                "SQL query limits must be positive".into(),
1145            ));
1146        }
1147        let deadline = Instant::now()
1148            .checked_add(timeout)
1149            .unwrap_or_else(Instant::now);
1150        self.with_connection(|conn| {
1151            conn.progress_handler(
1152                SQL_PROGRESS_HANDLER_OPS,
1153                Some(move || Instant::now() >= deadline),
1154            )
1155            .map_err(sqlite_error)?;
1156            let result = with_sql_authorizer(conn, SqlAuthorizationMode::ReadOnly, || {
1157                let mut statement = conn.prepare(&query.sql).map_err(sql_query_error)?;
1158                if !statement.readonly() {
1159                    return Err(Error::InvalidCommand("SQL query must be read-only".into()));
1160                }
1161                let columns = statement
1162                    .column_names()
1163                    .into_iter()
1164                    .map(str::to_owned)
1165                    .collect::<Vec<_>>();
1166                let column_count = columns.len();
1167                let mut rows = statement
1168                    .query(params_from_iter(query.parameters.iter()))
1169                    .map_err(sql_query_error)?;
1170                let mut result_rows = Vec::new();
1171                let mut result_bytes = columns.iter().map(String::len).sum::<usize>();
1172                while let Some(row) = rows.next().map_err(sql_query_error)? {
1173                    if result_rows.len() == max_rows {
1174                        return Err(Error::InvalidCommand(format!(
1175                            "SQL query exceeds {max_rows} rows"
1176                        )));
1177                    }
1178                    let mut values = Vec::with_capacity(column_count);
1179                    for column in 0..column_count {
1180                        let value = sql_value(row.get_ref(column).map_err(sql_query_error)?)?;
1181                        result_bytes = result_bytes
1182                            .checked_add(sql_value_size(&value))
1183                            .ok_or_else(|| {
1184                                Error::InvalidCommand("SQL result size overflow".into())
1185                            })?;
1186                        if result_bytes > max_bytes {
1187                            return Err(Error::InvalidCommand(format!(
1188                                "SQL query exceeds {max_bytes} result bytes"
1189                            )));
1190                        }
1191                        values.push(value);
1192                    }
1193                    result_rows.push(values);
1194                }
1195                Ok(SqlQueryResult {
1196                    columns,
1197                    rows: result_rows,
1198                })
1199            });
1200            let clear_result = conn
1201                .progress_handler(0, None::<fn() -> bool>)
1202                .map_err(sqlite_error);
1203            match (result, clear_result) {
1204                (Err(error), _) => Err(error),
1205                (Ok(_), Err(error)) => Err(error),
1206                (Ok(result), Ok(())) => Ok(result),
1207            }
1208        })
1209    }
1210
1211    pub fn validate_sql_write(&self, command: &SqlCommand) -> Result<()> {
1212        let request = encode_sql_command(command)?;
1213        let tip = self.control.applied_tip()?;
1214        let preparation = self.prepare_sql_batch_effect(
1215            &[SqlBatchMember {
1216                command,
1217                request_payload: &request,
1218            }],
1219            tip.applied_index(),
1220            tip.applied_hash(),
1221        )?;
1222        preparation
1223            .results
1224            .into_iter()
1225            .next()
1226            .expect("one-member batch returns one result")
1227            .map(|_| ())
1228    }
1229
1230    /// Prepares one physical QWAL v3 effect for the successful subset of an
1231    /// ordered SQL batch.
1232    ///
1233    /// Each member runs inside its own savepoint nested under one outer SQLite
1234    /// transaction. A failed member is rolled back and reported at the same
1235    /// input position; later members still observe all prior successful
1236    /// members. Every successful member is bound into the effect as an ordered
1237    /// receipt template and is committed at the effect's single log anchor.
1238    pub fn prepare_sql_batch_effect(
1239        &self,
1240        members: &[SqlBatchMember<'_>],
1241        base_index: LogIndex,
1242        base_hash: LogHash,
1243    ) -> Result<SqlBatchPreparation> {
1244        if members.is_empty() || members.len() > MAX_QWAL_V3_RECEIPTS {
1245            return Err(Error::InvalidCommand(format!(
1246                "SQL batch must contain 1..={MAX_QWAL_V3_RECEIPTS} members"
1247            )));
1248        }
1249        self.prepare_qwal_effect(base_index, base_hash, |staging| {
1250            let mut preflight = std::iter::repeat_with(|| None)
1251                .take(members.len())
1252                .collect::<Vec<Option<Result<Option<RequestReceipt>>>>>();
1253            let mut lookup_members: Vec<usize> = Vec::with_capacity(members.len());
1254            let mut lookup_keys = Vec::with_capacity(members.len());
1255            let mut seen_request_ids = HashSet::with_capacity(members.len());
1256            for (index, member) in members.iter().enumerate() {
1257                let request_digest = LogHash::digest(&[member.request_payload]);
1258                let validation = validate_sql_command(member.command).and_then(|()| {
1259                    if decode_sql_command(member.request_payload)? != *member.command {
1260                        return Err(Error::InvalidCommand(
1261                            "SQL batch member is not the canonical QSQL v2 command".into(),
1262                        ));
1263                    }
1264                    if !seen_request_ids.insert(member.command.request_id.as_str()) {
1265                        return Err(Error::InvalidCommand(
1266                            "SQL batch member repeats a request_id".into(),
1267                        ));
1268                    }
1269                    Ok(())
1270                });
1271                if let Err(error) = validation {
1272                    preflight[index] = Some(Err(error));
1273                    continue;
1274                }
1275                lookup_members.push(index);
1276                lookup_keys.push((member.command.request_id.as_str(), request_digest));
1277            }
1278            for (member_index, lookup) in lookup_members
1279                .iter()
1280                .copied()
1281                .zip(self.control.lookup_requests(&lookup_keys)?)
1282            {
1283                preflight[member_index] = Some(lookup);
1284            }
1285
1286            let mut tx = Transaction::new_unchecked(staging, TransactionBehavior::Immediate)
1287                .map_err(sqlite_error)?;
1288            let mut receipts = Vec::with_capacity(members.len());
1289            let mut results = Vec::with_capacity(members.len());
1290            for (member, preflight) in members.iter().zip(preflight) {
1291                let request_digest = LogHash::digest(&[member.request_payload]);
1292                match preflight.expect("every SQL batch member has one preflight result") {
1293                    Err(error) => {
1294                        results.push(Err(error));
1295                        continue;
1296                    }
1297                    Ok(Some(_)) => {
1298                        results.push(Err(Error::InvalidCommand(
1299                            "request was already materialized; return its stored receipt".into(),
1300                        )));
1301                        continue;
1302                    }
1303                    Ok(None) => {}
1304                }
1305
1306                let savepoint = tx.savepoint().map_err(sqlite_error)?;
1307                match execute_sql_statements(&savepoint, &member.command.statements)
1308                    .and_then(|result| encode_sql_result(&result).map(|blob| (result, blob)))
1309                {
1310                    Ok((result, result_blob)) => {
1311                        savepoint.commit().map_err(sqlite_error)?;
1312                        receipts.push(QwalReceiptV3 {
1313                            request_id: member.command.request_id.clone(),
1314                            request_digest,
1315                            result_blob,
1316                        });
1317                        results.push(Ok(result));
1318                    }
1319                    Err(error) => {
1320                        savepoint.finish().map_err(sqlite_error)?;
1321                        results.push(Err(error));
1322                    }
1323                }
1324            }
1325            if receipts.is_empty() {
1326                tx.rollback().map_err(sqlite_error)?;
1327            } else {
1328                tx.commit().map_err(sqlite_error)?;
1329            }
1330            Ok(PreparedQwalMutation { receipts, results })
1331        })
1332    }
1333
1334    pub fn prepare_put_effect(
1335        &self,
1336        request_id: &str,
1337        key: &str,
1338        value: &str,
1339        request_payload: &[u8],
1340        base_index: LogIndex,
1341        base_hash: LogHash,
1342    ) -> Result<Vec<u8>> {
1343        let canonical_request = encode_put_request(request_id, key, value)?;
1344        if request_payload != canonical_request {
1345            return Err(Error::InvalidCommand(
1346                "put effect request is not the canonical put command".into(),
1347            ));
1348        }
1349        let preparation = self.prepare_qwal_effect(base_index, base_hash, |staging| {
1350            let request_digest = LogHash::digest(&[request_payload]);
1351            if self
1352                .control
1353                .lookup_request(request_id, request_digest)?
1354                .is_some()
1355            {
1356                return Err(Error::InvalidCommand(
1357                    "request was already materialized; return its stored receipt".into(),
1358                ));
1359            }
1360            let tx = Transaction::new_unchecked(staging, TransactionBehavior::Immediate)
1361                .map_err(sqlite_error)?;
1362            tx.execute(
1363                "INSERT INTO __rhiza_kv(key, value) VALUES (?1, ?2)\n                     ON CONFLICT(key) DO UPDATE SET value = excluded.value",
1364                params![key, value],
1365            )
1366            .map_err(sqlite_error)?;
1367            tx.commit().map_err(sqlite_error)?;
1368            let result = SqlCommandResult {
1369                statement_results: Vec::new(),
1370            };
1371            Ok(PreparedQwalMutation {
1372                receipts: vec![QwalReceiptV3 {
1373                    request_id: request_id.to_owned(),
1374                    request_digest,
1375                    result_blob: encode_sql_result(&result)?,
1376                }],
1377                results: vec![Ok(result)],
1378            })
1379        })?;
1380        preparation.effect.ok_or_else(|| {
1381            Error::InvalidCommand("put effect unexpectedly produced no successful member".into())
1382        })
1383    }
1384
1385    fn prepare_qwal_effect(
1386        &self,
1387        base_index: LogIndex,
1388        base_hash: LogHash,
1389        mutation: impl FnOnce(&mut Connection) -> Result<PreparedQwalMutation>,
1390    ) -> Result<SqlBatchPreparation> {
1391        let _lifecycle = self.lock_lifecycle()?;
1392        self.ensure_page_state_sealed()?;
1393        self.ensure_no_pending_apply()?;
1394        let tip = self.control.applied_tip()?;
1395        if tip != ApplyProgress::new(base_index, base_hash) {
1396            return Err(Error::InvalidEntry(
1397                "QWAL effect base does not match the materialized SQLite tip".into(),
1398            ));
1399        }
1400        let identity = self.control.identity()?;
1401        let base_state = self
1402            .page_state
1403            .lock()
1404            .map_err(|_| Error::Sqlite("SQLite page-state cache lock is poisoned".into()))?
1405            .cache
1406            .identity();
1407        if base_state != identity.user_state() {
1408            return Err(Error::InvalidEntry(
1409                "cached SQLite base state does not match the control sidecar".into(),
1410            ));
1411        }
1412
1413        let prepare_result = (|| {
1414            self.close_connection()?;
1415
1416            let (base_file, base_seal) = open_sealed_prepared_base(&self.path)?;
1417            {
1418                let page_state = self.page_state.lock().map_err(|_| {
1419                    Error::Sqlite("SQLite page-state cache lock is poisoned".into())
1420                })?;
1421                if !prepared_base_metadata_matches(
1422                    page_state.seal.as_ref(),
1423                    &base_file.metadata().map_err(io_error)?,
1424                    &fs::symlink_metadata(&self.path).map_err(io_error)?,
1425                ) {
1426                    return Err(Error::InvalidEntry(
1427                        "closed SQLite base no longer matches the cached page-state seal".into(),
1428                    ));
1429                }
1430            }
1431            let base_file_bytes = fs::metadata(&self.path).map_err(io_error)?.len();
1432            let staging_artifact = clone_or_copy_to_temp(&self.path)?;
1433            let staging_path = staging_artifact.path();
1434            let copied = fs::metadata(staging_path).map_err(io_error)?.len();
1435            if copied != base_file_bytes {
1436                return Err(Error::Io(
1437                    "speculative SQLite clone did not reproduce the closed base size".into(),
1438                ));
1439            }
1440            #[cfg(test)]
1441            note_speculative_copy(&self.path);
1442            let page_size = base_state.page_size;
1443            let base_db_pages = u32::try_from(base_file_bytes / u64::from(page_size))
1444                .map_err(|_| Error::ResourceExhausted("SQLite base page count overflows".into()))?;
1445            if !sqlite_sidecars_absent(staging_path)? {
1446                return Err(Error::InvalidEntry(
1447                    "fresh speculative SQLite clone has inherited sidecars".into(),
1448                ));
1449            }
1450            let sidecar_cleanup = StagingSidecarCleanup::new(staging_path);
1451            let mut staging = open_connection(staging_path)?;
1452            if !staging
1453                .set_db_config(DbConfig::SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE, true)
1454                .map_err(sqlite_error)?
1455                || !staging
1456                    .db_config(DbConfig::SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE)
1457                    .map_err(sqlite_error)?
1458            {
1459                return Err(Error::Sqlite(
1460                    "SQLite refused to disable checkpoint-on-close for QWAL capture".into(),
1461                ));
1462            }
1463            #[cfg(test)]
1464            note_native_wal_capture(&self.path);
1465            staging
1466                .pragma_update(None, "synchronous", "OFF")
1467                .map_err(sqlite_error)?;
1468            #[cfg(test)]
1469            note_speculative_synchronous(&self.path, &staging)?;
1470            let mutation = mutation(&mut staging)?;
1471            if mutation.receipts.is_empty() {
1472                staging
1473                    .close()
1474                    .map_err(|(_, error)| Error::Sqlite(error.to_string()))?;
1475                sidecar_cleanup.cleanup()?;
1476                return Ok((None, mutation.results, None, None, None));
1477            }
1478            let held_wal = open_fresh_staging_wal(staging_path)?;
1479            staging
1480                .close()
1481                .map_err(|(_, error)| Error::Sqlite(error.to_string()))?;
1482            #[cfg(test)]
1483            inject_wal_capture_fault(&self.path, held_wal.as_ref())?;
1484            let capture = match held_wal {
1485                Some((mut wal, seal)) => {
1486                    verify_staging_wal_seal(&wal, seal)?;
1487                    let capture = capture_wal(&mut wal, base_db_pages, MAX_QWAL_V3_BYTES)?;
1488                    verify_staging_wal_seal(&wal, seal)?;
1489                    capture
1490                }
1491                None => WalCapture::NoChange,
1492            };
1493            let pages = materialize_wal_capture(
1494                &base_file,
1495                staging_path,
1496                page_size,
1497                base_file_bytes,
1498                capture,
1499            )?;
1500            sidecar_cleanup.cleanup()?;
1501            let target_file_bytes = fs::metadata(staging_path).map_err(io_error)?.len();
1502            let target_page_count = u32::try_from(target_file_bytes / u64::from(page_size))
1503                .map_err(|_| {
1504                    Error::ResourceExhausted("SQLite target page count overflows".into())
1505                })?;
1506            let patches = pages
1507                .iter()
1508                .map(|page| PageStatePatchV3::new(page.page_no, &page.after_image))
1509                .collect::<Vec<_>>();
1510            let target_state = self
1511                .page_state
1512                .lock()
1513                .map_err(|_| Error::Sqlite("SQLite page-state cache lock is poisoned".into()))?
1514                .cache
1515                .overlay(target_page_count, &patches)?;
1516
1517            let effect = QwalEnvelopeV3 {
1518                cluster_id: identity.cluster_id().to_owned(),
1519                epoch: identity.epoch(),
1520                configuration_id: identity.configuration_state().config_id(),
1521                recovery_generation: identity.recovery_generation(),
1522                base_index,
1523                base_hash,
1524                base_state,
1525                target_state,
1526                materializer_fingerprint: identity.materializer_fingerprint().to_hex(),
1527                receipts: mutation.receipts,
1528                pages,
1529            };
1530            let encoded = encode_qwal_v3(&effect)?;
1531            Ok((
1532                Some(encoded),
1533                mutation.results,
1534                Some(effect),
1535                Some((base_file, base_seal)),
1536                Some(staging_artifact),
1537            ))
1538        })();
1539
1540        if self
1541            .conn
1542            .lock()
1543            .map_err(|_| Error::Sqlite("SQLite connection lock is poisoned".into()))?
1544            .is_none()
1545        {
1546            let reopen_result = self.reopen_connection();
1547            if prepare_result.is_ok() {
1548                reopen_result?;
1549            }
1550        }
1551        let (encoded, results, effect, prepared_base, staging_artifact) = prepare_result?;
1552        let (Some(encoded), Some(effect)) = (encoded, effect) else {
1553            self.discard_prepared_target()?;
1554            return Ok(SqlBatchPreparation {
1555                effect: None,
1556                results,
1557            });
1558        };
1559        let (base_file, base_seal) =
1560            prepared_base.expect("a prepared QWAL effect retains its sealed canonical base");
1561        let staging_artifact =
1562            staging_artifact.expect("a prepared QWAL effect retains its speculative target");
1563        let target_owned = staging_artifact.as_file().metadata().map_err(io_error)?;
1564        let target_named = fs::symlink_metadata(staging_artifact.path()).map_err(io_error)?;
1565        let target_seal = prepared_base_seal(&target_owned, &target_named)?;
1566        let prepared = PreparedTarget {
1567            artifact: staging_artifact,
1568            target_seal,
1569            base_file,
1570            base_seal,
1571            cluster_id: effect.cluster_id.clone(),
1572            node_id: identity.node_id().to_owned(),
1573            epoch: effect.epoch,
1574            configuration_id: effect.configuration_id,
1575            recovery_generation: effect.recovery_generation,
1576            materializer_fingerprint: effect.materializer_fingerprint.clone(),
1577            base_index: effect.base_index,
1578            base_hash: effect.base_hash,
1579            base_state: effect.base_state,
1580            target_state: effect.target_state,
1581            effect_digest: LogHash::digest(&[&encoded]),
1582        };
1583        *self
1584            .prepared_target
1585            .lock()
1586            .map_err(|_| Error::Sqlite("prepared SQLite target lock is poisoned".into()))? =
1587            Some(prepared);
1588        Ok(SqlBatchPreparation {
1589            effect: Some(encoded),
1590            results,
1591        })
1592    }
1593
1594    pub fn check_request(
1595        &self,
1596        request_id: &str,
1597        command_payload: &[u8],
1598    ) -> Result<Option<RequestOutcome>> {
1599        let _lifecycle = self.lock_lifecycle()?;
1600        self.with_connection(|_| Ok(()))?;
1601        self.ensure_no_pending_apply()?;
1602        let Some(receipt) = self
1603            .control
1604            .lookup_request(request_id, LogHash::digest(&[command_payload]))?
1605        else {
1606            return Ok(None);
1607        };
1608        Ok(Some(RequestOutcome::new(
1609            receipt.original_anchor().index(),
1610            receipt.original_anchor().hash(),
1611        )))
1612    }
1613
1614    pub fn connection_pragmas(&self) -> Result<(String, i64)> {
1615        let _lifecycle = self.lock_lifecycle()?;
1616        self.ensure_no_pending_apply()?;
1617        self.with_connection(|conn| {
1618            let journal_mode = conn
1619                .query_row("PRAGMA journal_mode;", [], |row| row.get(0))
1620                .map_err(sqlite_error)?;
1621            let synchronous = conn
1622                .query_row("PRAGMA synchronous;", [], |row| row.get(0))
1623                .map_err(sqlite_error)?;
1624            Ok((journal_mode, synchronous))
1625        })
1626    }
1627
1628    pub fn check_sql_request(
1629        &self,
1630        request_id: &str,
1631        command_payload: &[u8],
1632    ) -> Result<Option<(RequestOutcome, Option<SqlCommandResult>)>> {
1633        self.check_sql_requests(&[(request_id, command_payload)])?
1634            .pop()
1635            .expect("one SQL request produces one aligned lookup")
1636    }
1637
1638    /// Checks unique SQL request ids with one bounded control-sidecar query.
1639    ///
1640    /// Results preserve input order. A missing receipt is `Ok(None)`, an exact
1641    /// receipt is decoded into its original outcome and result, and a digest
1642    /// conflict or invalid payload is returned in that member's aligned slot.
1643    /// Duplicate request ids are rejected before querying; callers that accept
1644    /// aliases must deduplicate by id and fan the aligned result back out.
1645    pub fn check_sql_requests(&self, requests: &[(&str, &[u8])]) -> Result<Vec<SqlRequestLookup>> {
1646        let _lifecycle = self.lock_lifecycle()?;
1647        self.with_connection(|_| Ok(()))?;
1648        self.ensure_no_pending_apply()?;
1649        let mut validations = Vec::with_capacity(requests.len());
1650        let mut lookup_keys = Vec::with_capacity(requests.len());
1651        for (request_id, command_payload) in requests {
1652            let validation = decode_sql_command(command_payload).and_then(|command| {
1653                if command.request_id != *request_id {
1654                    return Err(Error::InvalidCommand(
1655                        "SQL payload request_id does not match lookup request_id".into(),
1656                    ));
1657                }
1658                Ok(())
1659            });
1660            validations.push(validation);
1661            lookup_keys.push((*request_id, LogHash::digest(&[command_payload])));
1662        }
1663        let receipts = self.control.lookup_requests(&lookup_keys)?;
1664        let mut aligned = Vec::with_capacity(requests.len());
1665        for (validation, receipt) in validations.into_iter().zip(receipts) {
1666            let checked = match validation {
1667                Err(error) => Err(error),
1668                Ok(()) => match receipt {
1669                    Err(error) => Err(error),
1670                    Ok(None) => Ok(None),
1671                    Ok(Some(receipt)) => decode_sql_result(receipt.result_blob()).map(|result| {
1672                        Some((
1673                            RequestOutcome::new(
1674                                receipt.original_anchor().index(),
1675                                receipt.original_anchor().hash(),
1676                            ),
1677                            Some(result),
1678                        ))
1679                    }),
1680                },
1681            };
1682            aligned.push(checked);
1683        }
1684        Ok(aligned)
1685    }
1686
1687    pub fn applied_index_value(&self) -> Result<LogIndex> {
1688        Ok(self.control.applied_tip()?.applied_index())
1689    }
1690
1691    pub fn applied_hash_value(&self) -> Result<LogHash> {
1692        Ok(self.control.applied_tip()?.applied_hash())
1693    }
1694
1695    /// Returns the applied index and hash observed by one control-store snapshot.
1696    pub fn applied_tip(&self) -> Result<ApplyProgress> {
1697        self.control.applied_tip()
1698    }
1699
1700    pub fn applied_tip_value(&self) -> Result<(LogIndex, LogHash)> {
1701        let tip = self.applied_tip()?;
1702        Ok((tip.applied_index(), tip.applied_hash()))
1703    }
1704
1705    pub fn configuration_state_value(&self) -> Result<ConfigurationState> {
1706        self.control.configuration_state()
1707    }
1708
1709    pub fn embedded_log_entries(
1710        &self,
1711        from_index: LogIndex,
1712        through_index: LogIndex,
1713    ) -> Result<Vec<LogEntry>> {
1714        self.control.embedded_log_entries(from_index, through_index)
1715    }
1716
1717    pub fn compact_embedded_log_before(&self, anchor_index: LogIndex) -> Result<()> {
1718        self.control.compact_embedded_log_before(anchor_index)
1719    }
1720
1721    pub fn canonical_db_digest(&self) -> Result<LogHash> {
1722        let _lifecycle = self.lock_lifecycle()?;
1723        self.ensure_no_pending_apply()?;
1724        self.with_connection(checkpoint_truncate)?;
1725        self.close_connection()?;
1726        let digest = file_digest(&self.path);
1727        let reopen = self.reopen_connection();
1728        match (digest, reopen) {
1729            (Err(error), _) => Err(error),
1730            (Ok(_), Err(error)) => Err(error),
1731            (Ok(digest), Ok(())) => Ok(digest),
1732        }
1733    }
1734
1735    pub fn create_snapshot(&self, target: LogIndex) -> Result<Snapshot> {
1736        let _lifecycle = self.lock_lifecycle()?;
1737        self.ensure_page_state_sealed()?;
1738        self.ensure_no_pending_apply()?;
1739        let tip = self.control.applied_tip()?;
1740        if tip.applied_index() != target {
1741            return Err(Error::InvalidSnapshot(format!(
1742                "snapshot target {target} does not match applied index {}",
1743                tip.applied_index()
1744            )));
1745        }
1746        let identity = self.control.identity()?;
1747        let manifest = SnapshotManifest::new(
1748            identity.cluster_id(),
1749            identity.configuration_state().clone(),
1750            identity.epoch(),
1751            target,
1752            tip.applied_hash(),
1753            1,
1754            identity.node_id(),
1755            sql_executor_fingerprint()?,
1756        );
1757        self.with_connection(checkpoint_truncate)?;
1758        self.close_connection()?;
1759        let snapshot = (|| {
1760            let mut canonical = self.open_bound_canonical()?;
1761            canonical.seek(SeekFrom::Start(0)).map_err(io_error)?;
1762            let mut user_db = Vec::new();
1763            canonical.read_to_end(&mut user_db).map_err(io_error)?;
1764            seal_held_canonical(&self.path, &canonical)?;
1765            let page_state = page_state_from_database_bytes(&user_db)?;
1766            if page_state.identity() != identity.user_state() {
1767                return Err(Error::InvalidSnapshot(
1768                    "canonical database page state does not match control sidecar".into(),
1769                ));
1770            }
1771            let container = QwalSnapshotV3 {
1772                user_db,
1773                replicated_control: self.control.export_replicated_snapshot()?,
1774                user_state: page_state.identity(),
1775            };
1776            encode_qwal_snapshot(&container).map(|bytes| Snapshot::new(manifest, bytes))
1777        })();
1778        let reopen = self.reopen_connection();
1779        match (snapshot, reopen) {
1780            (Err(error), _) => Err(error),
1781            (Ok(_), Err(error)) => Err(error),
1782            (Ok(snapshot), Ok(())) => Ok(snapshot),
1783        }
1784    }
1785
1786    pub fn create_recovery_snapshot(&self, recovery_generation: u64) -> Result<RecoverySnapshot> {
1787        if recovery_generation == 0 {
1788            return Err(Error::InvalidSnapshot(
1789                "recovery_generation must be positive".into(),
1790            ));
1791        }
1792        let target = self.applied_index_value()?;
1793        if target == 0 {
1794            return Err(Error::InvalidSnapshot(
1795                "recovery snapshot requires an applied entry".into(),
1796            ));
1797        }
1798        let snapshot = self.create_snapshot(target)?;
1799        let manifest = snapshot.manifest();
1800        let size_bytes = u64::try_from(snapshot.db_bytes().len())
1801            .map_err(|_| Error::InvalidSnapshot("snapshot size exceeds u64".into()))?;
1802        let anchor = RecoveryAnchor::new(
1803            manifest.cluster_id(),
1804            manifest.epoch(),
1805            manifest.configuration_state().clone(),
1806            recovery_generation,
1807            LogAnchor::new(manifest.index(), manifest.applied_hash()),
1808            SnapshotIdentity::new(
1809                manifest.snapshot_id(),
1810                LogHash::digest(&[snapshot.db_bytes()]),
1811                size_bytes,
1812                manifest.executor_fingerprint(),
1813            ),
1814        );
1815        Ok(RecoverySnapshot { snapshot, anchor })
1816    }
1817}
1818
1819pub fn encode_put_request(request_id: &str, key: &str, value: &str) -> Result<Vec<u8>> {
1820    if request_id.is_empty() || request_id.len() > 256 || key.is_empty() {
1821        return Err(Error::InvalidCommand(
1822            "put request_id and key must be non-empty and request_id at most 256 bytes".into(),
1823        ));
1824    }
1825    if [request_id, key, value]
1826        .iter()
1827        .any(|field| field.as_bytes().contains(&b'\t'))
1828    {
1829        return Err(Error::InvalidCommand(
1830            "put request fields must not contain a tab".into(),
1831        ));
1832    }
1833    Ok(format!("put\t{request_id}\t{key}\t{value}").into_bytes())
1834}
1835
1836impl StateMachine for SqliteStateMachine {
1837    fn applied_index(&self) -> Result<LogIndex> {
1838        self.applied_index_value()
1839    }
1840
1841    fn apply(&self, entry: &LogEntry) -> Result<ApplyProgress> {
1842        self.apply_entry(entry)
1843    }
1844
1845    fn create_snapshot(&self, target: LogIndex) -> Result<Snapshot> {
1846        self.create_snapshot(target)
1847    }
1848}
1849
1850pub fn restore_snapshot_file(
1851    path: impl AsRef<Path>,
1852    snapshot: &Snapshot,
1853    target_node_id: &str,
1854) -> Result<()> {
1855    restore_snapshot_file_with_recovery_generation(path, snapshot, target_node_id, None)
1856}
1857
1858fn restore_snapshot_file_with_recovery_generation(
1859    path: impl AsRef<Path>,
1860    snapshot: &Snapshot,
1861    target_node_id: &str,
1862    recovery_generation: Option<u64>,
1863) -> Result<()> {
1864    if target_node_id.is_empty() {
1865        return Err(Error::InvalidSnapshot("target node_id is empty".into()));
1866    }
1867    let path = path.as_ref();
1868    ensure_parent(path)?;
1869    let parent = parent_dir(path);
1870    let control_path = control_sidecar_path(path);
1871    let mut wal_path = path.as_os_str().to_os_string();
1872    wal_path.push("-wal");
1873    let mut shm_path = path.as_os_str().to_os_string();
1874    shm_path.push("-shm");
1875    if path.exists()
1876        || control_path.exists()
1877        || Path::new(&wal_path).exists()
1878        || Path::new(&shm_path).exists()
1879    {
1880        return Err(Error::InvalidSnapshot(
1881            "QWAL restore destination and SQLite sidecars must not exist".into(),
1882        ));
1883    }
1884    let container = decode_qwal_snapshot(snapshot.db_bytes())?;
1885    if snapshot.manifest().executor_fingerprint() != sql_executor_fingerprint()? {
1886        return Err(Error::InvalidSnapshot(
1887            "QWAL snapshot materializer fingerprint does not match local SQLite".into(),
1888        ));
1889    }
1890    let mut restore_file = NamedTempFile::new_in(parent).map_err(io_error)?;
1891    restore_file
1892        .write_all(&container.user_db)
1893        .map_err(io_error)?;
1894    restore_file.as_file().sync_all().map_err(io_error)?;
1895
1896    {
1897        let restore_conn = Connection::open_with_flags(
1898            restore_file.path(),
1899            OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
1900        )
1901        .map_err(|err| Error::InvalidSnapshot(err.to_string()))?;
1902        integrity_check(&restore_conn)?;
1903    }
1904
1905    let page_state = page_state_from_database_bytes(&container.user_db)?;
1906    if page_state.identity() != container.user_state {
1907        return Err(Error::InvalidSnapshot(
1908            "QWAL snapshot database does not match its page state".into(),
1909        ));
1910    }
1911    let control_temp_dir = tempfile::tempdir_in(parent).map_err(io_error)?;
1912    let control_temp_path = control_temp_dir.path().join("control.sqlite");
1913    let control_identity = ControlIdentity::new(
1914        snapshot.manifest().cluster_id(),
1915        target_node_id,
1916        snapshot.manifest().epoch(),
1917        snapshot.manifest().configuration_state().clone(),
1918        1,
1919        sql_executor_fingerprint()?,
1920        container.user_state,
1921    );
1922    let control = ControlStore::create(&control_temp_path, &control_identity)?;
1923    control.import_replicated_snapshot_with_recovery_generation(
1924        &container.replicated_control,
1925        recovery_generation,
1926    )?;
1927    let imported_tip = control.applied_tip()?;
1928    if imported_tip.applied_index() != snapshot.manifest().index()
1929        || imported_tip.applied_hash() != snapshot.manifest().applied_hash()
1930        || control.configuration_state()? != *snapshot.manifest().configuration_state()
1931        || control.user_state()? != container.user_state
1932        || recovery_generation
1933            .is_some_and(|expected| control.recovery_generation().ok() != Some(expected))
1934    {
1935        return Err(Error::InvalidSnapshot(
1936            "QWAL snapshot manifest does not match replicated control state".into(),
1937        ));
1938    }
1939    drop(control);
1940    File::open(&control_temp_path)
1941        .and_then(|file| file.sync_all())
1942        .map_err(io_error)?;
1943
1944    let restored = restore_file
1945        .persist_noclobber(path)
1946        .map_err(|err| Error::Io(err.error.to_string()))?;
1947    restored.sync_all().map_err(io_error)?;
1948    if let Err(error) = fs::hard_link(&control_temp_path, &control_path) {
1949        drop(restored);
1950        let cleanup = fs::remove_file(path);
1951        let synced = sync_parent(parent);
1952        if let Err(cleanup_error) = cleanup {
1953            return Err(Error::Io(format!(
1954                "control publish failed ({error}); database cleanup failed ({cleanup_error})"
1955            )));
1956        }
1957        synced?;
1958        return Err(io_error(error));
1959    }
1960    sync_parent(parent)
1961}
1962
1963pub fn restore_recovery_snapshot_file(
1964    path: impl AsRef<Path>,
1965    db_bytes: &[u8],
1966    anchor: &RecoveryAnchor,
1967    target_node_id: &str,
1968) -> Result<()> {
1969    if target_node_id.is_empty() {
1970        return Err(Error::InvalidSnapshot("target node_id is empty".into()));
1971    }
1972    if anchor.snapshot().size_bytes() != db_bytes.len() as u64
1973        || anchor.snapshot().digest() != LogHash::digest(&[db_bytes])
1974    {
1975        return Err(Error::InvalidSnapshot(
1976            "recovery anchor does not match snapshot bytes".into(),
1977        ));
1978    }
1979    let fingerprint = anchor.executor_fingerprint();
1980    let expected = sql_executor_fingerprint()?;
1981    if fingerprint != expected {
1982        return Err(Error::InvalidSnapshot(format!(
1983            "recovery snapshot executor fingerprint {} does not match local {}",
1984            fingerprint.to_hex(),
1985            expected.to_hex()
1986        )));
1987    }
1988    let manifest = SnapshotManifest::new(
1989        anchor.cluster_id(),
1990        anchor.configuration_state().clone(),
1991        anchor.epoch(),
1992        anchor.compacted().index(),
1993        anchor.compacted().hash(),
1994        1,
1995        target_node_id,
1996        sql_executor_fingerprint()?,
1997    );
1998    if manifest.snapshot_id() != anchor.snapshot().snapshot_id() {
1999        return Err(Error::InvalidSnapshot(
2000            "recovery snapshot id does not match compacted index".into(),
2001        ));
2002    }
2003    restore_snapshot_file_with_recovery_generation(
2004        path,
2005        &Snapshot::new(manifest, db_bytes.to_vec()),
2006        target_node_id,
2007        Some(anchor.recovery_generation()),
2008    )
2009}
2010
2011fn open_connection(path: &Path) -> Result<Connection> {
2012    let flags = OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX;
2013    let conn = Connection::open_with_flags(path, flags).map_err(sqlite_error)?;
2014    let journal_mode: String = conn
2015        .query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))
2016        .map_err(sqlite_error)?;
2017    if !journal_mode.eq_ignore_ascii_case("wal") {
2018        return Err(Error::Sqlite(format!(
2019            "SQLite refused WAL journal mode: {journal_mode}"
2020        )));
2021    }
2022    conn.pragma_update(None, "synchronous", "OFF")
2023        .map_err(sqlite_error)?;
2024    conn.pragma_update(None, "foreign_keys", "ON")
2025        .map_err(sqlite_error)?;
2026    conn.pragma_update(None, "trusted_schema", "OFF")
2027        .map_err(sqlite_error)?;
2028    conn.pragma_update(None, "wal_autocheckpoint", 0)
2029        .map_err(sqlite_error)?;
2030    Ok(conn)
2031}
2032
2033fn checkpoint_truncate(conn: &Connection) -> Result<()> {
2034    let (busy, _, _): (i64, i64, i64) = conn
2035        .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
2036            Ok((row.get(0)?, row.get(1)?, row.get(2)?))
2037        })
2038        .map_err(sqlite_error)?;
2039    if busy != 0 {
2040        return Err(Error::Sqlite("SQLite WAL checkpoint is busy".into()));
2041    }
2042    Ok(())
2043}
2044
2045#[cfg(test)]
2046#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2047struct SpeculativePrepareAudit {
2048    copy_count: usize,
2049    synchronous: i64,
2050    native_vfs: bool,
2051    no_checkpoint_on_close: bool,
2052}
2053
2054#[cfg(test)]
2055fn speculative_prepare_audits() -> &'static Mutex<Vec<(PathBuf, SpeculativePrepareAudit)>> {
2056    static AUDITS: OnceLock<Mutex<Vec<(PathBuf, SpeculativePrepareAudit)>>> = OnceLock::new();
2057    AUDITS.get_or_init(|| Mutex::new(Vec::new()))
2058}
2059
2060#[cfg(test)]
2061fn note_speculative_copy(path: &Path) {
2062    if let Ok(mut audits) = speculative_prepare_audits().lock() {
2063        audits.push((
2064            path.to_path_buf(),
2065            SpeculativePrepareAudit {
2066                copy_count: 1,
2067                synchronous: -1,
2068                native_vfs: false,
2069                no_checkpoint_on_close: false,
2070            },
2071        ));
2072    }
2073}
2074
2075#[cfg(test)]
2076fn note_native_wal_capture(path: &Path) {
2077    if let Ok(mut audits) = speculative_prepare_audits().lock() {
2078        if let Some((_, audit)) = audits.iter_mut().rev().find(|(audited, _)| audited == path) {
2079            audit.native_vfs = true;
2080            audit.no_checkpoint_on_close = true;
2081        }
2082    }
2083}
2084
2085#[cfg(test)]
2086fn note_speculative_synchronous(path: &Path, staging: &Connection) -> Result<()> {
2087    let synchronous = staging
2088        .query_row("PRAGMA synchronous", [], |row| row.get(0))
2089        .map_err(sqlite_error)?;
2090    if let Ok(mut audits) = speculative_prepare_audits().lock() {
2091        let Some((_, audit)) = audits.iter_mut().rev().find(|(audited, _)| audited == path) else {
2092            return Err(Error::Sqlite(
2093                "speculative SQLite synchronous audit is missing its copy".into(),
2094            ));
2095        };
2096        audit.synchronous = synchronous;
2097    }
2098    Ok(())
2099}
2100
2101#[cfg(test)]
2102fn speculative_prepare_audit(path: &Path) -> Option<SpeculativePrepareAudit> {
2103    speculative_prepare_audits().lock().ok().and_then(|audits| {
2104        audits
2105            .iter()
2106            .rev()
2107            .find_map(|(audited, audit)| (audited == path).then_some(*audit))
2108    })
2109}
2110
2111#[cfg(test)]
2112#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2113enum PreparedInstallPath {
2114    Promoted,
2115    Patched,
2116}
2117
2118#[cfg(test)]
2119fn prepared_installs() -> &'static Mutex<Vec<(PathBuf, PreparedInstallPath)>> {
2120    static INSTALLS: OnceLock<Mutex<Vec<(PathBuf, PreparedInstallPath)>>> = OnceLock::new();
2121    INSTALLS.get_or_init(|| Mutex::new(Vec::new()))
2122}
2123
2124#[cfg(test)]
2125fn note_prepared_install(path: &Path, install: PreparedInstallPath) {
2126    if let Ok(mut installs) = prepared_installs().lock() {
2127        installs.push((path.to_path_buf(), install));
2128    }
2129}
2130
2131#[cfg(test)]
2132fn prepared_install_path(path: &Path) -> Option<PreparedInstallPath> {
2133    prepared_installs().lock().ok().and_then(|installs| {
2134        installs
2135            .iter()
2136            .rev()
2137            .find_map(|(installed, method)| (installed == path).then_some(*method))
2138    })
2139}
2140
2141#[cfg(test)]
2142#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
2143struct PreparedBaseReuseAudit {
2144    second_checkpoint_count: usize,
2145}
2146
2147#[cfg(test)]
2148fn prepared_base_reuse_audits() -> &'static Mutex<Vec<(PathBuf, PreparedBaseReuseAudit)>> {
2149    static AUDITS: OnceLock<Mutex<Vec<(PathBuf, PreparedBaseReuseAudit)>>> = OnceLock::new();
2150    AUDITS.get_or_init(|| Mutex::new(Vec::new()))
2151}
2152
2153#[cfg(test)]
2154fn begin_prepared_base_reuse_audit(path: &Path) {
2155    if let Ok(mut audits) = prepared_base_reuse_audits().lock() {
2156        audits.push((path.to_path_buf(), PreparedBaseReuseAudit::default()));
2157    }
2158}
2159
2160#[cfg(test)]
2161fn note_second_checkpoint(path: &Path) {
2162    if let Ok(mut audits) = prepared_base_reuse_audits().lock() {
2163        if let Some((_, audit)) = audits.iter_mut().rev().find(|(audited, _)| audited == path) {
2164            audit.second_checkpoint_count += 1;
2165        }
2166    }
2167}
2168
2169#[cfg(test)]
2170fn prepared_base_reuse_audit(path: &Path) -> Option<PreparedBaseReuseAudit> {
2171    prepared_base_reuse_audits().lock().ok().and_then(|audits| {
2172        audits
2173            .iter()
2174            .rev()
2175            .find_map(|(audited, audit)| (audited == path).then_some(*audit))
2176    })
2177}
2178
2179#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2180struct StagingWalSeal {
2181    len: u64,
2182    modified: Option<std::time::SystemTime>,
2183    #[cfg(unix)]
2184    unix: PreparedBaseSeal,
2185}
2186
2187impl StagingWalSeal {
2188    fn from_metadata(metadata: &fs::Metadata) -> Self {
2189        Self {
2190            len: metadata.len(),
2191            modified: metadata.modified().ok(),
2192            #[cfg(unix)]
2193            unix: unix_metadata_seal(metadata),
2194        }
2195    }
2196}
2197
2198struct StagingSidecarCleanup {
2199    path: PathBuf,
2200}
2201
2202impl StagingSidecarCleanup {
2203    fn new(path: &Path) -> Self {
2204        Self {
2205            path: path.to_path_buf(),
2206        }
2207    }
2208
2209    fn cleanup(&self) -> Result<()> {
2210        for suffix in ["-wal", "-shm"] {
2211            let sidecar = sqlite_sidecar_path(&self.path, suffix);
2212            match fs::symlink_metadata(&sidecar) {
2213                Ok(metadata) if metadata.file_type().is_file() => {
2214                    fs::remove_file(&sidecar).map_err(io_error)?;
2215                }
2216                Ok(_) => {
2217                    return Err(Error::InvalidEntry(format!(
2218                        "owned speculative SQLite sidecar {} is not a regular file",
2219                        sidecar.display()
2220                    )));
2221                }
2222                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
2223                Err(error) => return Err(io_error(error)),
2224            }
2225        }
2226        Ok(())
2227    }
2228}
2229
2230impl Drop for StagingSidecarCleanup {
2231    fn drop(&mut self) {
2232        for suffix in ["-wal", "-shm"] {
2233            let _ = fs::remove_file(sqlite_sidecar_path(&self.path, suffix));
2234        }
2235    }
2236}
2237
2238fn sqlite_sidecar_path(path: &Path, suffix: &str) -> PathBuf {
2239    let mut sidecar = path.as_os_str().to_os_string();
2240    sidecar.push(suffix);
2241    PathBuf::from(sidecar)
2242}
2243
2244/// Removes sidecars that cannot contain committed WAL frames after the caller has fenced every
2245/// possible database owner.
2246///
2247/// A non-empty WAL or a non-regular sidecar is rejected and preserved for recovery inspection.
2248pub fn cleanup_empty_wal_sidecars_after_owner_fence(path: impl AsRef<Path>) -> Result<()> {
2249    let path = path.as_ref();
2250    let wal = sqlite_sidecar_path(path, "-wal");
2251    let shm = sqlite_sidecar_path(path, "-shm");
2252    let wal_metadata = symlink_metadata_if_exists(&wal)?;
2253    let shm_metadata = symlink_metadata_if_exists(&shm)?;
2254    for (name, metadata) in [
2255        ("WAL", wal_metadata.as_ref()),
2256        ("SHM", shm_metadata.as_ref()),
2257    ] {
2258        if metadata.is_some_and(|metadata| !metadata.file_type().is_file()) {
2259            return Err(Error::InvalidEntry(format!(
2260                "SQLite {name} sidecar is not a regular file"
2261            )));
2262        }
2263    }
2264    if wal_metadata
2265        .as_ref()
2266        .is_some_and(|metadata| metadata.len() != 0)
2267    {
2268        return Err(Error::InvalidEntry(
2269            "SQLite WAL sidecar contains uncheckpointed frames".into(),
2270        ));
2271    }
2272    let mut changed = false;
2273    for sidecar in [&wal, &shm] {
2274        match fs::remove_file(sidecar) {
2275            Ok(()) => changed = true,
2276            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
2277            Err(error) => return Err(io_error(error)),
2278        }
2279    }
2280    if changed {
2281        sync_parent(parent_dir(path))?;
2282    }
2283    Ok(())
2284}
2285
2286fn sqlite_sidecars_absent(path: &Path) -> Result<bool> {
2287    for suffix in ["-wal", "-shm"] {
2288        if sqlite_sidecar_path(path, suffix)
2289            .try_exists()
2290            .map_err(io_error)?
2291        {
2292            return Ok(false);
2293        }
2294    }
2295    Ok(true)
2296}
2297
2298fn open_fresh_staging_wal(path: &Path) -> Result<Option<(File, StagingWalSeal)>> {
2299    let wal_path = sqlite_sidecar_path(path, "-wal");
2300    let mut options = OpenOptions::new();
2301    options.read(true);
2302    #[cfg(test)]
2303    options.write(true);
2304    let wal = match options.open(&wal_path) {
2305        Ok(wal) => wal,
2306        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
2307        Err(error) => return Err(io_error(error)),
2308    };
2309    let owned = wal.metadata().map_err(io_error)?;
2310    let named = fs::symlink_metadata(&wal_path).map_err(io_error)?;
2311    if !owned.file_type().is_file() || !named.file_type().is_file() {
2312        return Err(Error::InvalidEntry(
2313            "fresh speculative SQLite WAL is not a regular file".into(),
2314        ));
2315    }
2316    #[cfg(unix)]
2317    if !same_file(&owned, &named) {
2318        return Err(Error::InvalidEntry(
2319            "held speculative SQLite WAL inode is no longer named by its path".into(),
2320        ));
2321    }
2322    let seal = StagingWalSeal::from_metadata(&owned);
2323    if StagingWalSeal::from_metadata(&named) != seal {
2324        return Err(Error::InvalidEntry(
2325            "fresh speculative SQLite WAL metadata is unstable".into(),
2326        ));
2327    }
2328    Ok(Some((wal, seal)))
2329}
2330
2331fn verify_staging_wal_seal(wal: &File, seal: StagingWalSeal) -> Result<()> {
2332    if StagingWalSeal::from_metadata(&wal.metadata().map_err(io_error)?) != seal {
2333        return Err(Error::InvalidEntry(
2334            "held speculative SQLite WAL changed after capture was sealed".into(),
2335        ));
2336    }
2337    Ok(())
2338}
2339
2340#[cfg(test)]
2341#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2342enum WalCaptureFault {
2343    ChangeHeldWalAfterSeal,
2344}
2345
2346#[cfg(test)]
2347#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2348enum QwalApplyFault {
2349    AfterPage(u32),
2350    BeforeControlCommit,
2351    BeforePendingCommit,
2352}
2353
2354#[cfg(test)]
2355fn qwal_apply_faults() -> &'static Mutex<Vec<(PathBuf, QwalApplyFault)>> {
2356    static FAULTS: OnceLock<Mutex<Vec<(PathBuf, QwalApplyFault)>>> = OnceLock::new();
2357    FAULTS.get_or_init(|| Mutex::new(Vec::new()))
2358}
2359
2360#[cfg(test)]
2361fn arm_qwal_apply_fault(path: &Path, fault: QwalApplyFault) {
2362    qwal_apply_faults()
2363        .lock()
2364        .unwrap()
2365        .push((path.to_path_buf(), fault));
2366}
2367
2368#[cfg(test)]
2369fn take_qwal_apply_fault(path: &Path, matches: impl Fn(QwalApplyFault) -> bool) -> Result<bool> {
2370    let mut faults = qwal_apply_faults()
2371        .lock()
2372        .map_err(|_| Error::Sqlite("QWAL apply fault lock is poisoned".into()))?;
2373    Ok(faults
2374        .iter()
2375        .position(|(armed, fault)| armed == path && matches(*fault))
2376        .map(|position| faults.swap_remove(position))
2377        .is_some())
2378}
2379
2380#[cfg(test)]
2381fn inject_qwal_apply_fault(path: &Path, page_no: u32) -> Result<()> {
2382    if take_qwal_apply_fault(
2383        path,
2384        |fault| matches!(fault, QwalApplyFault::AfterPage(expected) if expected == page_no),
2385    )? {
2386        return Err(Error::Io(
2387            "injected failure during canonical QWAL page writes".into(),
2388        ));
2389    }
2390    Ok(())
2391}
2392
2393#[cfg(test)]
2394fn inject_qwal_control_fault(path: &Path) -> Result<()> {
2395    if take_qwal_apply_fault(path, |fault| fault == QwalApplyFault::BeforeControlCommit)? {
2396        return Err(Error::Sqlite(
2397            "injected post-install control commit failure".into(),
2398        ));
2399    }
2400    Ok(())
2401}
2402
2403#[cfg(test)]
2404fn inject_pending_commit_fault(path: &Path) -> Result<()> {
2405    if take_qwal_apply_fault(path, |fault| fault == QwalApplyFault::BeforePendingCommit)? {
2406        return Err(Error::Sqlite(
2407            "injected pending control commit failure".into(),
2408        ));
2409    }
2410    Ok(())
2411}
2412
2413#[cfg(test)]
2414fn wal_capture_faults() -> &'static Mutex<Vec<(PathBuf, WalCaptureFault)>> {
2415    static FAULTS: OnceLock<Mutex<Vec<(PathBuf, WalCaptureFault)>>> = OnceLock::new();
2416    FAULTS.get_or_init(|| Mutex::new(Vec::new()))
2417}
2418
2419#[cfg(test)]
2420fn arm_wal_capture_fault(path: &Path, fault: WalCaptureFault) {
2421    wal_capture_faults()
2422        .lock()
2423        .unwrap()
2424        .push((path.to_path_buf(), fault));
2425}
2426
2427#[cfg(test)]
2428fn inject_wal_capture_fault(path: &Path, held_wal: Option<&(File, StagingWalSeal)>) -> Result<()> {
2429    let fault = {
2430        let mut faults = wal_capture_faults()
2431            .lock()
2432            .map_err(|_| Error::Sqlite("WAL capture fault lock is poisoned".into()))?;
2433        faults
2434            .iter()
2435            .position(|(armed, _)| armed == path)
2436            .map(|position| faults.swap_remove(position).1)
2437    };
2438    match (fault, held_wal) {
2439        (Some(WalCaptureFault::ChangeHeldWalAfterSeal), Some((wal, seal))) => wal
2440            .set_len(
2441                seal.len
2442                    .checked_add(1)
2443                    .ok_or_else(|| Error::ResourceExhausted("test WAL length overflow".into()))?,
2444            )
2445            .map_err(io_error),
2446        (Some(_), None) => Err(Error::InvalidEntry(
2447            "test expected a held speculative SQLite WAL".into(),
2448        )),
2449        (None, _) => Ok(()),
2450    }
2451}
2452
2453fn materialize_wal_capture(
2454    base: &File,
2455    target_path: &Path,
2456    expected_page_size: u32,
2457    base_file_bytes: u64,
2458    capture: WalCapture,
2459) -> Result<Vec<QwalPageV3>> {
2460    let WalCapture::Committed(WalCommit {
2461        page_size,
2462        target_db_pages,
2463        target_file_bytes,
2464        pages: captured_pages,
2465    }) = capture
2466    else {
2467        if fs::metadata(target_path).map_err(io_error)?.len() != base_file_bytes
2468            || sqlite_page_size(target_path)? != expected_page_size
2469        {
2470            return Err(Error::InvalidEntry(
2471                "no-change SQLite WAL capture does not reproduce its closed base".into(),
2472            ));
2473        }
2474        return Ok(Vec::new());
2475    };
2476    if page_size != expected_page_size {
2477        return Err(Error::InvalidEntry(
2478            "SQLite WAL page size differs from its closed base".into(),
2479        ));
2480    }
2481    let expected_target_bytes = u64::from(target_db_pages)
2482        .checked_mul(u64::from(page_size))
2483        .ok_or_else(|| Error::InvalidEntry("SQLite WAL target size overflows".into()))?;
2484    if target_file_bytes != expected_target_bytes {
2485        return Err(Error::InvalidEntry(
2486            "SQLite WAL target size does not match its commit page count".into(),
2487        ));
2488    }
2489
2490    let page_bytes = u64::from(page_size);
2491    let base_pages = base_file_bytes / page_bytes;
2492    let mut base = base.try_clone().map_err(io_error)?;
2493    let mut target = OpenOptions::new()
2494        .read(true)
2495        .write(true)
2496        .open(target_path)
2497        .map_err(io_error)?;
2498    target.set_len(target_file_bytes).map_err(io_error)?;
2499    let mut changed = Vec::with_capacity(captured_pages.len());
2500    let mut base_page = vec![0; page_size as usize];
2501    for page in captured_pages {
2502        let page_no = u64::from(page.page_no);
2503        let offset = page_no
2504            .checked_sub(1)
2505            .and_then(|index| index.checked_mul(page_bytes))
2506            .ok_or_else(|| Error::InvalidEntry("SQLite WAL page offset overflows".into()))?;
2507        let differs_from_base = if page_no <= base_pages {
2508            base.seek(SeekFrom::Start(offset)).map_err(io_error)?;
2509            base.read_exact(&mut base_page).map_err(io_error)?;
2510            base_page != page.after_image
2511        } else {
2512            true
2513        };
2514        target.seek(SeekFrom::Start(offset)).map_err(io_error)?;
2515        target.write_all(&page.after_image).map_err(io_error)?;
2516        if differs_from_base {
2517            changed.push(QwalPageV3 {
2518                page_no: u32::try_from(page_no)
2519                    .map_err(|_| Error::ResourceExhausted("QWAL page count exceeds u32".into()))?,
2520                after_image: page.after_image,
2521            });
2522        }
2523    }
2524    drop(target);
2525
2526    // sqlite_page_size also validates the SQLite header database-size field
2527    // at bytes 28..32 against this exact target file length.
2528    if fs::metadata(target_path).map_err(io_error)?.len() != target_file_bytes
2529        || sqlite_page_size(target_path)? != page_size
2530    {
2531        return Err(Error::InvalidEntry(
2532            "materialized SQLite WAL does not match its committed header and target size".into(),
2533        ));
2534    }
2535    Ok(changed)
2536}
2537
2538fn open_file_digest(file: &File) -> Result<LogHash> {
2539    let mut file = file.try_clone().map_err(io_error)?;
2540    file.seek(SeekFrom::Start(0)).map_err(io_error)?;
2541    let mut hasher = Sha256::new();
2542    let mut buffer = [0u8; 64 * 1024];
2543    loop {
2544        let read = file.read(&mut buffer).map_err(io_error)?;
2545        if read == 0 {
2546            break;
2547        }
2548        hasher.update(&buffer[..read]);
2549    }
2550    Ok(LogHash::from_bytes(hasher.finalize().into()))
2551}
2552
2553fn file_digest(path: impl AsRef<Path>) -> Result<LogHash> {
2554    open_file_digest(&File::open(path.as_ref()).map_err(io_error)?)
2555}
2556
2557fn page_state_from_database_bytes(bytes: &[u8]) -> Result<PageStateCacheV3> {
2558    let header = bytes
2559        .get(..100)
2560        .ok_or_else(|| Error::InvalidEntry("SQLite database header is truncated".into()))?;
2561    let file_bytes = u64::try_from(bytes.len())
2562        .map_err(|_| Error::ResourceExhausted("SQLite database size exceeds u64".into()))?;
2563    let page_size = qwal::sqlite_page_size_from_header(header, file_bytes)?;
2564    PageStateCacheV3::from_pages(page_size, bytes.chunks_exact(page_size as usize))
2565}
2566
2567#[cfg(test)]
2568fn rebuild_page_state(path: &Path) -> Result<PageStateCacheV3> {
2569    page_state_from_database_bytes(&fs::read(path).map_err(io_error)?)
2570}
2571
2572fn rebuild_sealed_page_state(path: &Path) -> Result<CanonicalPageStateV3> {
2573    if !sqlite_sidecars_absent(path)? {
2574        return Err(Error::InvalidEntry(
2575            "closed canonical SQLite database has WAL sidecars during page-state rebuild".into(),
2576        ));
2577    }
2578    let named_before = fs::symlink_metadata(path).map_err(io_error)?;
2579    let mut file = File::open(path).map_err(io_error)?;
2580    let owned_before = file.metadata().map_err(io_error)?;
2581    let seal = prepared_base_seal(&owned_before, &named_before)?;
2582    let expected_bytes = owned_before.len();
2583    let cache = rebuild_page_state_from_file(&mut file, expected_bytes)?;
2584    let owned_after = file.metadata().map_err(io_error)?;
2585    let named_after = fs::symlink_metadata(path).map_err(io_error)?;
2586    if !prepared_base_metadata_matches(seal.as_ref(), &owned_after, &named_after)
2587        || owned_after.len() != expected_bytes
2588        || !sqlite_sidecars_absent(path)?
2589    {
2590        return Err(Error::InvalidEntry(
2591            "SQLite database changed while rebuilding page state".into(),
2592        ));
2593    }
2594    Ok(CanonicalPageStateV3 { cache, seal })
2595}
2596
2597fn rebuild_page_state_from_file(file: &mut File, expected_bytes: u64) -> Result<PageStateCacheV3> {
2598    let mut header = [0_u8; 100];
2599    file.read_exact(&mut header).map_err(io_error)?;
2600    let page_size = qwal::sqlite_page_size_from_header(&header, expected_bytes)?;
2601    file.seek(SeekFrom::Start(0)).map_err(io_error)?;
2602    let mut remaining = expected_bytes / u64::from(page_size);
2603    let mut stream_error = None;
2604    let cache = PageStateCacheV3::from_pages(
2605        page_size,
2606        std::iter::from_fn(|| {
2607            if remaining == 0 || stream_error.is_some() {
2608                return None;
2609            }
2610            let mut page = vec![0; page_size as usize];
2611            match file.read_exact(&mut page) {
2612                Ok(()) => {
2613                    remaining -= 1;
2614                    Some(page)
2615                }
2616                Err(error) => {
2617                    stream_error = Some(io_error(error));
2618                    None
2619                }
2620            }
2621        }),
2622    )?;
2623    if let Some(error) = stream_error {
2624        return Err(error);
2625    }
2626    Ok(cache)
2627}
2628
2629fn open_bound_canonical(path: &Path, page_state: &CanonicalPageStateV3) -> Result<File> {
2630    let file = OpenOptions::new()
2631        .read(true)
2632        .write(true)
2633        .open(path)
2634        .map_err(io_error)?;
2635    let owned = file.metadata().map_err(io_error)?;
2636    let named = fs::symlink_metadata(path).map_err(io_error)?;
2637    if !prepared_base_metadata_matches(page_state.seal.as_ref(), &owned, &named) {
2638        return Err(Error::InvalidEntry(
2639            "canonical SQLite file no longer matches the cached page-state seal".into(),
2640        ));
2641    }
2642    Ok(file)
2643}
2644
2645fn verify_bound_canonical(path: &Path, page_state: &CanonicalPageStateV3) -> Result<()> {
2646    open_bound_canonical(path, page_state).map(drop)
2647}
2648
2649fn seal_held_canonical(path: &Path, file: &File) -> Result<Option<PreparedBaseSeal>> {
2650    if !sqlite_sidecars_absent(path)? {
2651        return Err(Error::InvalidEntry(
2652            "closed canonical SQLite database has WAL sidecars while refreshing its seal".into(),
2653        ));
2654    }
2655    let owned_before = file.metadata().map_err(io_error)?;
2656    let named_before = fs::symlink_metadata(path).map_err(io_error)?;
2657    let seal = prepared_base_seal(&owned_before, &named_before)?;
2658    let owned_after = file.metadata().map_err(io_error)?;
2659    let named_after = fs::symlink_metadata(path).map_err(io_error)?;
2660    if !prepared_base_metadata_matches(seal.as_ref(), &owned_after, &named_after)
2661        || !sqlite_sidecars_absent(path)?
2662    {
2663        return Err(Error::InvalidEntry(
2664            "canonical SQLite file changed while refreshing its page-state seal".into(),
2665        ));
2666    }
2667    Ok(seal)
2668}
2669
2670fn clone_or_copy_to_temp(base: &Path) -> Result<NamedTempFile> {
2671    const COW_CLONE_MIN_BYTES: u64 = 256 * 1024;
2672    let placeholder = NamedTempFile::new_in(parent_dir(base)).map_err(io_error)?;
2673    let (placeholder_file, temp_path) = placeholder.into_parts();
2674    drop(placeholder_file);
2675    fs::remove_file(&temp_path).map_err(io_error)?;
2676
2677    let clone_result = if fs::metadata(base).map_err(io_error)?.len() >= COW_CLONE_MIN_BYTES {
2678        try_platform_clone(base, &temp_path)
2679    } else {
2680        Err(io::Error::new(
2681            io::ErrorKind::Unsupported,
2682            "small SQLite bases are cheaper to copy than clone",
2683        ))
2684    };
2685    let file = match clone_result {
2686        Ok(file) => file,
2687        Err(_) => {
2688            if temp_path.exists() {
2689                fs::remove_file(&temp_path).map_err(io_error)?;
2690            }
2691            fs::copy(base, &temp_path).map_err(io_error)?;
2692            OpenOptions::new()
2693                .read(true)
2694                .write(true)
2695                .open(&temp_path)
2696                .map_err(io_error)?
2697        }
2698    };
2699    Ok(NamedTempFile::from_parts(file, temp_path))
2700}
2701
2702#[cfg(target_os = "macos")]
2703fn try_platform_clone(base: &Path, target: &Path) -> io::Result<File> {
2704    use std::{ffi::CString, os::unix::ffi::OsStrExt};
2705
2706    unsafe extern "C" {
2707        fn clonefile(
2708            source: *const std::os::raw::c_char,
2709            target: *const std::os::raw::c_char,
2710            flags: u32,
2711        ) -> i32;
2712    }
2713
2714    let source = CString::new(base.as_os_str().as_bytes())
2715        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "source path contains NUL"))?;
2716    let target_c = CString::new(target.as_os_str().as_bytes())
2717        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "target path contains NUL"))?;
2718    // SAFETY: both C strings are NUL-terminated and remain alive for the call.
2719    if unsafe { clonefile(source.as_ptr(), target_c.as_ptr(), 0) } != 0 {
2720        return Err(io::Error::last_os_error());
2721    }
2722    OpenOptions::new().read(true).write(true).open(target)
2723}
2724
2725#[cfg(target_os = "linux")]
2726fn try_platform_clone(base: &Path, target: &Path) -> io::Result<File> {
2727    use std::os::{fd::AsRawFd, raw::c_ulong};
2728
2729    unsafe extern "C" {
2730        fn ioctl(fd: std::os::raw::c_int, request: c_ulong, ...) -> std::os::raw::c_int;
2731    }
2732
2733    const FICLONE: c_ulong = 0x4004_9409;
2734    let source = File::open(base)?;
2735    let cloned = OpenOptions::new()
2736        .read(true)
2737        .write(true)
2738        .create_new(true)
2739        .open(target)?;
2740    // SAFETY: FICLONE expects a valid destination fd and source fd argument.
2741    if unsafe { ioctl(cloned.as_raw_fd(), FICLONE, source.as_raw_fd()) } == 0 {
2742        return Ok(cloned);
2743    }
2744    let error = io::Error::last_os_error();
2745    drop(cloned);
2746    let _ = fs::remove_file(target);
2747    Err(error)
2748}
2749
2750#[cfg(not(any(target_os = "macos", target_os = "linux")))]
2751fn try_platform_clone(_base: &Path, _target: &Path) -> io::Result<File> {
2752    Err(io::Error::new(
2753        io::ErrorKind::Unsupported,
2754        "copy-on-write cloning is not supported on this platform",
2755    ))
2756}
2757
2758fn open_sealed_prepared_base(path: &Path) -> Result<(File, Option<PreparedBaseSeal>)> {
2759    let named_before = fs::symlink_metadata(path).map_err(io_error)?;
2760    if !named_before.file_type().is_file() {
2761        return Err(Error::InvalidEntry(
2762            "closed SQLite base is not a regular file".into(),
2763        ));
2764    }
2765    let file = File::open(path).map_err(io_error)?;
2766    let owned_before = file.metadata().map_err(io_error)?;
2767    if !owned_before.file_type().is_file() {
2768        return Err(Error::InvalidEntry(
2769            "owned SQLite base is not a regular file".into(),
2770        ));
2771    }
2772    let seal = prepared_base_seal(&owned_before, &named_before)?;
2773    if !sqlite_sidecars_absent(path)? {
2774        return Err(Error::InvalidEntry(
2775            "closed SQLite base still has WAL sidecars".into(),
2776        ));
2777    }
2778    let owned_after = file.metadata().map_err(io_error)?;
2779    let named_after = fs::symlink_metadata(path).map_err(io_error)?;
2780    if !prepared_base_metadata_matches(seal.as_ref(), &owned_after, &named_after)
2781        || !sqlite_sidecars_absent(path)?
2782    {
2783        return Err(Error::InvalidEntry(
2784            "closed SQLite base changed while it was sealed".into(),
2785        ));
2786    }
2787    Ok((file, seal))
2788}
2789
2790#[cfg(unix)]
2791fn prepared_base_seal(
2792    owned: &fs::Metadata,
2793    named: &fs::Metadata,
2794) -> Result<Option<PreparedBaseSeal>> {
2795    if !owned_regular_file_still_named(owned, named) {
2796        return Err(Error::InvalidEntry(
2797            "owned SQLite base inode is no longer named by its path".into(),
2798        ));
2799    }
2800    let seal = unix_metadata_seal(owned);
2801    if unix_metadata_seal(named) != seal {
2802        return Err(Error::InvalidEntry(
2803            "owned SQLite base metadata is not stable".into(),
2804        ));
2805    }
2806    Ok(Some(seal))
2807}
2808
2809#[cfg(not(unix))]
2810fn prepared_base_seal(
2811    _owned: &fs::Metadata,
2812    _named: &fs::Metadata,
2813) -> Result<Option<PreparedBaseSeal>> {
2814    Ok(None)
2815}
2816
2817#[cfg(unix)]
2818fn unix_metadata_seal(metadata: &fs::Metadata) -> PreparedBaseSeal {
2819    use std::os::unix::fs::MetadataExt;
2820
2821    PreparedBaseSeal {
2822        dev: metadata.dev(),
2823        ino: metadata.ino(),
2824        len: metadata.len(),
2825        mtime: metadata.mtime(),
2826        mtime_nsec: metadata.mtime_nsec(),
2827        ctime: metadata.ctime(),
2828        ctime_nsec: metadata.ctime_nsec(),
2829    }
2830}
2831
2832#[cfg(unix)]
2833fn prepared_base_metadata_matches(
2834    seal: Option<&PreparedBaseSeal>,
2835    owned: &fs::Metadata,
2836    named: &fs::Metadata,
2837) -> bool {
2838    seal.is_some_and(|seal| {
2839        owned_regular_file_still_named(owned, named)
2840            && unix_metadata_seal(owned) == *seal
2841            && unix_metadata_seal(named) == *seal
2842    })
2843}
2844
2845#[cfg(not(unix))]
2846fn prepared_base_metadata_matches(
2847    _seal: Option<&PreparedBaseSeal>,
2848    _owned: &fs::Metadata,
2849    _named: &fs::Metadata,
2850) -> bool {
2851    false
2852}
2853
2854fn prepared_base_still_sealed(path: &Path, prepared: &PreparedTarget) -> Result<bool> {
2855    let Some(named) = symlink_metadata_if_exists(path)? else {
2856        return Ok(false);
2857    };
2858    let owned = prepared.base_file.metadata().map_err(io_error)?;
2859    Ok(
2860        prepared_base_metadata_matches(prepared.base_seal.as_ref(), &owned, &named)
2861            && sqlite_sidecars_absent(path)?,
2862    )
2863}
2864
2865fn symlink_metadata_if_exists(path: &Path) -> Result<Option<fs::Metadata>> {
2866    match fs::symlink_metadata(path) {
2867        Ok(metadata) => Ok(Some(metadata)),
2868        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
2869        Err(error) => Err(io_error(error)),
2870    }
2871}
2872
2873#[cfg(unix)]
2874fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool {
2875    use std::os::unix::fs::MetadataExt;
2876
2877    left.dev() == right.dev() && left.ino() == right.ino()
2878}
2879
2880#[cfg(not(unix))]
2881fn same_file(_left: &fs::Metadata, _right: &fs::Metadata) -> bool {
2882    // The optimization is intentionally disabled when the platform cannot
2883    // prove that the owned temporary inode is still the one named by its path.
2884    false
2885}
2886
2887fn owned_regular_file_still_named(owned: &fs::Metadata, named: &fs::Metadata) -> bool {
2888    owned.file_type().is_file() && named.file_type().is_file() && same_file(owned, named)
2889}
2890
2891fn control_sidecar_path(path: &Path) -> PathBuf {
2892    let mut sidecar = path.as_os_str().to_os_string();
2893    sidecar.push(".control");
2894    PathBuf::from(sidecar)
2895}
2896
2897fn validate_control_database_pair(
2898    path: &Path,
2899    control: &ControlStore,
2900) -> Result<(CanonicalPageStateV3, bool)> {
2901    let page_state = rebuild_sealed_page_state(path)?;
2902    let actual = page_state.cache.identity();
2903    let pending = control.pending()?;
2904    if let Some(pending) = pending.as_ref() {
2905        let tip = control.applied_tip()?;
2906        let expected_entry_index = tip
2907            .applied_index()
2908            .checked_add(1)
2909            .ok_or_else(|| Error::InvalidEntry("pending QWAL entry index is exhausted".into()))?;
2910        if pending.base() != LogAnchor::new(tip.applied_index(), tip.applied_hash())
2911            || pending.base_state() != control.user_state()?
2912            || pending.entry().index() != expected_entry_index
2913        {
2914            return Err(Error::InvalidEntry(
2915                "pending QWAL intent does not extend the committed control state".into(),
2916            ));
2917        }
2918        if actual == pending.base_state() {
2919            return Ok((page_state, true));
2920        }
2921        if actual == pending.target_state() {
2922            let verify = Connection::open_with_flags(
2923                path,
2924                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
2925            )
2926            .map_err(sqlite_error)?;
2927            integrity_check(&verify)?;
2928            return Ok((page_state, true));
2929        }
2930        return Err(Error::InvalidEntry(
2931            "pending QWAL database state matches neither base nor target".into(),
2932        ));
2933    }
2934    if actual != control.user_state()? {
2935        return Err(Error::InvalidEntry(
2936            "canonical SQLite page state does not match the control sidecar".into(),
2937        ));
2938    }
2939    Ok((page_state, false))
2940}
2941
2942fn decode_qwal_command(payload: &[u8]) -> Result<QwalEnvelopeV3> {
2943    if !payload.starts_with(QWAL_V3_MAGIC) {
2944        return Err(Error::InvalidCommand(
2945            "QWAL-only SQLite apply requires a QWAL v3 payload".into(),
2946        ));
2947    }
2948    decode_qwal_v3(payload)
2949}
2950
2951fn validate_qwal_identity(
2952    effect: &QwalEnvelopeV3,
2953    identity: &ControlIdentity,
2954    configuration: &ConfigurationState,
2955) -> Result<()> {
2956    if effect.cluster_id != identity.cluster_id()
2957        || effect.epoch != identity.epoch()
2958        || effect.configuration_id != configuration.config_id()
2959        || effect.recovery_generation != identity.recovery_generation()
2960        || effect.materializer_fingerprint != identity.materializer_fingerprint().to_hex()
2961        || effect.base_state != identity.user_state()
2962    {
2963        return Err(Error::InvalidEntry(
2964            "QWAL effect identity or materializer fingerprint mismatch".into(),
2965        ));
2966    }
2967    Ok(())
2968}
2969
2970fn encode_qwal_snapshot(snapshot: &QwalSnapshotV3) -> Result<Vec<u8>> {
2971    let body = postcard::to_allocvec(snapshot)
2972        .map_err(|error| Error::InvalidSnapshot(format!("QSNP encode failed: {error}")))?;
2973    let mut encoded = Vec::with_capacity(QWAL_SNAPSHOT_V3_MAGIC.len() + body.len());
2974    encoded.extend_from_slice(QWAL_SNAPSHOT_V3_MAGIC);
2975    encoded.extend_from_slice(&body);
2976    Ok(encoded)
2977}
2978
2979fn decode_qwal_snapshot(encoded: &[u8]) -> Result<QwalSnapshotV3> {
2980    let body = encoded
2981        .strip_prefix(QWAL_SNAPSHOT_V3_MAGIC)
2982        .ok_or_else(|| Error::InvalidSnapshot("QWAL snapshot magic is missing".into()))?;
2983    let snapshot: QwalSnapshotV3 = postcard::from_bytes(body)
2984        .map_err(|error| Error::InvalidSnapshot(format!("QSNP decode failed: {error}")))?;
2985    if postcard::to_allocvec(&snapshot)
2986        .map_err(|error| Error::InvalidSnapshot(format!("QSNP re-encode failed: {error}")))?
2987        != body
2988    {
2989        return Err(Error::InvalidSnapshot(
2990            "QWAL snapshot is not canonically encoded".into(),
2991        ));
2992    }
2993    Ok(snapshot)
2994}
2995
2996#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2997enum SqlAuthorizationMode {
2998    ReadOnly,
2999    PhysicalWrite,
3000}
3001
3002fn execute_sql_statements(
3003    conn: &Connection,
3004    statements: &[SqlStatement],
3005) -> Result<SqlCommandResult> {
3006    let result = with_sql_authorizer(conn, SqlAuthorizationMode::PhysicalWrite, || {
3007        let mut statement_results = Vec::with_capacity(statements.len());
3008        let mut returning_rows = 0usize;
3009        let mut returning_bytes = 0usize;
3010        for operation in statements {
3011            let mut statement = conn.prepare(&operation.sql).map_err(sqlite_error)?;
3012            let column_count = statement.column_count();
3013            let total_changes_before = conn.total_changes();
3014            let returning = if column_count == 0 {
3015                statement
3016                    .execute(params_from_iter(operation.parameters.iter()))
3017                    .map_err(sqlite_error)?;
3018                None
3019            } else {
3020                let columns = statement
3021                    .column_names()
3022                    .into_iter()
3023                    .map(str::to_owned)
3024                    .collect::<Vec<_>>();
3025                for column in &columns {
3026                    add_returning_bytes(&mut returning_bytes, column.len())?;
3027                }
3028                let mut result_rows = Vec::new();
3029                {
3030                    let mut rows = statement
3031                        .query(params_from_iter(operation.parameters.iter()))
3032                        .map_err(sqlite_error)?;
3033                    while let Some(row) = rows.next().map_err(sqlite_error)? {
3034                        returning_rows = returning_rows.checked_add(1).ok_or_else(|| {
3035                            Error::InvalidCommand("SQL RETURNING row count overflow".into())
3036                        })?;
3037                        if returning_rows > MAX_RETURNING_ROWS {
3038                            return Err(Error::InvalidCommand(format!(
3039                                "SQL RETURNING exceeds {MAX_RETURNING_ROWS} rows"
3040                            )));
3041                        }
3042                        let mut values = Vec::with_capacity(column_count);
3043                        for column in 0..column_count {
3044                            let value = sql_value(row.get_ref(column).map_err(sqlite_error)?)?;
3045                            add_returning_bytes(&mut returning_bytes, sql_value_size(&value))?;
3046                            values.push(value);
3047                        }
3048                        result_rows.push(values);
3049                    }
3050                }
3051                Some(SqlQueryResult {
3052                    columns,
3053                    rows: result_rows,
3054                })
3055            };
3056            let total_changes = conn
3057                .total_changes()
3058                .checked_sub(total_changes_before)
3059                .ok_or_else(|| Error::Sqlite("SQLite total_changes moved backwards".into()))?;
3060            let rows_affected = if total_changes == 0 {
3061                0
3062            } else {
3063                conn.changes()
3064            };
3065            statement_results.push(SqlStatementResult {
3066                rows_affected,
3067                returning,
3068            });
3069        }
3070        validate_temp_schema_empty(conn)?;
3071        Ok(SqlCommandResult { statement_results })
3072    })?;
3073    validate_reserved_schema(conn)?;
3074    Ok(result)
3075}
3076
3077fn add_returning_bytes(total: &mut usize, bytes: usize) -> Result<()> {
3078    *total = total
3079        .checked_add(bytes)
3080        .ok_or_else(|| Error::InvalidCommand("SQL RETURNING result size overflow".into()))?;
3081    if *total > MAX_RETURNING_BYTES {
3082        return Err(Error::InvalidCommand(format!(
3083            "SQL RETURNING exceeds {MAX_RETURNING_BYTES} result bytes"
3084        )));
3085    }
3086    Ok(())
3087}
3088
3089fn decode_sql_command(payload: &[u8]) -> Result<SqlCommand> {
3090    let encoded = payload
3091        .strip_prefix(SQL_COMMAND_V2_MAGIC)
3092        .ok_or_else(|| Error::InvalidCommand("canonical QSQL v2 magic is missing".into()))?;
3093    let envelope: SqlCommandV2Envelope = serde_json::from_slice(encoded)
3094        .map_err(|error| Error::InvalidCommand(format!("invalid SQL command: {error}")))?;
3095    let expected = sql_executor_fingerprint()?;
3096    if envelope.executor_fingerprint != expected {
3097        return Err(Error::InvalidCommand(format!(
3098            "SQL executor fingerprint {} does not match local {}",
3099            envelope.executor_fingerprint.to_hex(),
3100            expected.to_hex()
3101        )));
3102    }
3103    let command = envelope.command;
3104    validate_sql_command(&command)?;
3105    if encode_sql_command(&command)? != payload {
3106        return Err(Error::InvalidCommand(
3107            "QSQL v2 command is not canonically encoded".into(),
3108        ));
3109    }
3110    Ok(command)
3111}
3112
3113fn validate_sql_command(command: &SqlCommand) -> Result<()> {
3114    if command.request_id.is_empty() || command.request_id.len() > 256 {
3115        return Err(Error::InvalidCommand(
3116            "SQL request_id must contain 1..=256 bytes".into(),
3117        ));
3118    }
3119    if command.statements.is_empty() || command.statements.len() > MAX_SQL_STATEMENTS {
3120        return Err(Error::InvalidCommand(format!(
3121            "SQL command must contain 1..={MAX_SQL_STATEMENTS} statements"
3122        )));
3123    }
3124    for statement in &command.statements {
3125        validate_sql_statement(statement)?;
3126    }
3127    Ok(())
3128}
3129
3130fn validate_sql_statement(statement: &SqlStatement) -> Result<()> {
3131    if statement.sql.trim().is_empty() || statement.sql.len() > MAX_SQL_TEXT_BYTES {
3132        return Err(Error::InvalidCommand(format!(
3133            "SQL text must contain 1..={MAX_SQL_TEXT_BYTES} bytes"
3134        )));
3135    }
3136    if statement.sql.contains('\0') {
3137        return Err(Error::InvalidCommand(
3138            "SQL text must not contain NUL".into(),
3139        ));
3140    }
3141    if statement.parameters.len() > MAX_SQL_PARAMETERS {
3142        return Err(Error::InvalidCommand(format!(
3143            "SQL statement exceeds {MAX_SQL_PARAMETERS} parameters"
3144        )));
3145    }
3146    if statement
3147        .parameters
3148        .iter()
3149        .any(|value| matches!(value, SqlValue::Real(number) if !number.is_finite()))
3150    {
3151        return Err(Error::InvalidCommand(
3152            "SQL real parameters must be finite".into(),
3153        ));
3154    }
3155    Ok(())
3156}
3157
3158fn with_sql_authorizer<T>(
3159    conn: &Connection,
3160    mode: SqlAuthorizationMode,
3161    operation: impl FnOnce() -> Result<T>,
3162) -> Result<T> {
3163    conn.authorizer(Some(move |context: AuthContext<'_>| {
3164        authorize_sql(context, mode)
3165    }))
3166    .map_err(sqlite_error)?;
3167    let result = operation();
3168    let cleared = conn
3169        .authorizer(None::<fn(AuthContext<'_>) -> Authorization>)
3170        .map_err(sqlite_error);
3171    match (result, cleared) {
3172        (Ok(value), Ok(())) => Ok(value),
3173        (Err(error), _) => Err(error),
3174        (Ok(_), Err(error)) => Err(error),
3175    }
3176}
3177
3178fn authorize_sql(context: AuthContext<'_>, mode: SqlAuthorizationMode) -> Authorization {
3179    if context.database_name.is_some_and(|name| {
3180        name != "main" && !(mode == SqlAuthorizationMode::PhysicalWrite && name == "temp")
3181    }) {
3182        return Authorization::Deny;
3183    }
3184    match context.action {
3185        AuthAction::Unknown { .. }
3186        | AuthAction::Transaction { .. }
3187        | AuthAction::Attach { .. }
3188        | AuthAction::Detach { .. }
3189        | AuthAction::Savepoint { .. } => Authorization::Deny,
3190        AuthAction::CreateTempIndex { .. }
3191        | AuthAction::CreateTempTable { .. }
3192        | AuthAction::CreateTempTrigger { .. }
3193        | AuthAction::CreateTempView { .. }
3194        | AuthAction::DropTempIndex { .. }
3195        | AuthAction::DropTempTable { .. }
3196        | AuthAction::DropTempTrigger { .. }
3197        | AuthAction::DropTempView { .. }
3198            if mode == SqlAuthorizationMode::PhysicalWrite =>
3199        {
3200            Authorization::Allow
3201        }
3202        AuthAction::Pragma {
3203            pragma_name,
3204            pragma_value,
3205        } if authorized_pragma(mode, pragma_name, pragma_value) => Authorization::Allow,
3206        AuthAction::Pragma { .. } => Authorization::Deny,
3207        AuthAction::CreateVtable { module_name, .. }
3208        | AuthAction::DropVtable { module_name, .. }
3209            if mode == SqlAuthorizationMode::PhysicalWrite && bundled_vtable(module_name) =>
3210        {
3211            Authorization::Allow
3212        }
3213        AuthAction::CreateVtable { .. } | AuthAction::DropVtable { .. } => Authorization::Deny,
3214        AuthAction::Function { function_name } if unsafe_sql_function(function_name) => {
3215            Authorization::Deny
3216        }
3217        AuthAction::CreateIndex {
3218            index_name,
3219            table_name,
3220        }
3221        | AuthAction::DropIndex {
3222            index_name,
3223            table_name,
3224        } if reserved_name(index_name) || reserved_name(table_name) => Authorization::Deny,
3225        AuthAction::CreateTrigger {
3226            trigger_name,
3227            table_name,
3228        }
3229        | AuthAction::DropTrigger {
3230            trigger_name,
3231            table_name,
3232        } if reserved_name(trigger_name) || reserved_name(table_name) => Authorization::Deny,
3233        AuthAction::CreateTable { table_name }
3234        | AuthAction::Delete { table_name }
3235        | AuthAction::DropTable { table_name }
3236        | AuthAction::Insert { table_name }
3237        | AuthAction::Read { table_name, .. }
3238        | AuthAction::Update { table_name, .. }
3239        | AuthAction::AlterTable { table_name, .. }
3240        | AuthAction::Analyze { table_name }
3241            if reserved_name(table_name) =>
3242        {
3243            Authorization::Deny
3244        }
3245        AuthAction::CreateView { view_name } | AuthAction::DropView { view_name }
3246            if reserved_name(view_name) =>
3247        {
3248            Authorization::Deny
3249        }
3250        AuthAction::Reindex { index_name } if reserved_name(index_name) => Authorization::Deny,
3251        _ => Authorization::Allow,
3252    }
3253}
3254
3255fn authorized_pragma(mode: SqlAuthorizationMode, name: &str, value: Option<&str>) -> bool {
3256    if observational_pragma(name, value) {
3257        return true;
3258    }
3259    mode == SqlAuthorizationMode::PhysicalWrite
3260        && ((matches_ignore_ascii_case(name, &["application_id", "user_version"])
3261            && value.is_some())
3262            || (name.eq_ignore_ascii_case("optimize")
3263                && value.is_none_or(|value| {
3264                    value.parse::<u32>().is_ok()
3265                        || value
3266                            .strip_prefix("0x")
3267                            .or_else(|| value.strip_prefix("0X"))
3268                            .is_some_and(|hex| u32::from_str_radix(hex, 16).is_ok())
3269                })))
3270}
3271
3272fn observational_pragma(name: &str, value: Option<&str>) -> bool {
3273    const ARGUMENT_SAFE: &[&str] = &[
3274        "foreign_key_check",
3275        "foreign_key_list",
3276        "index_info",
3277        "index_list",
3278        "index_xinfo",
3279        "integrity_check",
3280        "quick_check",
3281        "table_info",
3282        "table_list",
3283        "table_xinfo",
3284    ];
3285    const NO_ARGUMENT_ONLY: &[&str] = &[
3286        "analysis_limit",
3287        "application_id",
3288        "auto_vacuum",
3289        "automatic_index",
3290        "busy_timeout",
3291        "cache_size",
3292        "cache_spill",
3293        "case_sensitive_like",
3294        "cell_size_check",
3295        "checkpoint_fullfsync",
3296        "collation_list",
3297        "compile_options",
3298        "count_changes",
3299        "data_version",
3300        "default_cache_size",
3301        "defer_foreign_keys",
3302        "empty_result_callbacks",
3303        "encoding",
3304        "freelist_count",
3305        "foreign_keys",
3306        "full_column_names",
3307        "fullfsync",
3308        "function_list",
3309        "hard_heap_limit",
3310        "ignore_check_constraints",
3311        "journal_size_limit",
3312        "legacy_alter_table",
3313        "locking_mode",
3314        "max_page_count",
3315        "mmap_size",
3316        "module_list",
3317        "page_count",
3318        "page_size",
3319        "pragma_list",
3320        "query_only",
3321        "read_uncommitted",
3322        "recursive_triggers",
3323        "reverse_unordered_selects",
3324        "schema_version",
3325        "secure_delete",
3326        "short_column_names",
3327        "soft_heap_limit",
3328        "synchronous",
3329        "temp_store",
3330        "threads",
3331        "trusted_schema",
3332        "user_version",
3333    ];
3334
3335    if value.is_some_and(reserved_name) {
3336        return false;
3337    }
3338    ARGUMENT_SAFE
3339        .iter()
3340        .any(|allowed| name.eq_ignore_ascii_case(allowed))
3341        || value.is_none()
3342            && NO_ARGUMENT_ONLY
3343                .iter()
3344                .any(|allowed| name.eq_ignore_ascii_case(allowed))
3345}
3346
3347fn reserved_name(name: &str) -> bool {
3348    const TABLES: &[&str] = &["__rhiza_kv"];
3349    reserved_table_name(name)
3350        || strip_ascii_prefix_ignore_case(name, "sqlite_autoindex_").is_some_and(|suffix| {
3351            TABLES.iter().any(|table| {
3352                suffix
3353                    .get(..table.len())
3354                    .is_some_and(|candidate| candidate.eq_ignore_ascii_case(table))
3355                    && suffix.as_bytes().get(table.len()) == Some(&b'_')
3356            })
3357        })
3358}
3359
3360fn reserved_table_name(name: &str) -> bool {
3361    matches_ignore_ascii_case(name, &["__rhiza_kv"])
3362}
3363
3364fn validate_reserved_schema(conn: &Connection) -> Result<()> {
3365    let unexpected: Option<String> = conn
3366        .query_row(
3367            "SELECT name
3368             FROM sqlite_schema
3369             WHERE
3370               (lower(name) = '__rhiza_kv' OR lower(tbl_name) = '__rhiza_kv')
3371                AND NOT (
3372                    (type = 'table' AND name = '__rhiza_kv' AND tbl_name = '__rhiza_kv')
3373                    OR
3374                    (type = 'index'
3375                     AND name GLOB 'sqlite_autoindex___rhiza_kv_*'
3376                     AND tbl_name = '__rhiza_kv')
3377                )
3378             LIMIT 1",
3379            [],
3380            |row| row.get(0),
3381        )
3382        .optional()
3383        .map_err(sqlite_error)?;
3384    if let Some(name) = unexpected {
3385        return Err(Error::InvalidCommand(format!(
3386            "SQL object uses reserved rhiza namespace: {name}"
3387        )));
3388    }
3389    let mut statement = conn
3390        .prepare(
3391            "SELECT name, sql FROM sqlite_schema
3392             WHERE type = 'table' AND lower(sql) LIKE 'create virtual table%'",
3393        )
3394        .map_err(sqlite_error)?;
3395    let definitions = statement
3396        .query_map([], |row| {
3397            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3398        })
3399        .map_err(sqlite_error)?
3400        .collect::<std::result::Result<Vec<_>, _>>()
3401        .map_err(sqlite_error)?;
3402    if let Some((name, _)) = definitions
3403        .into_iter()
3404        .find(|(_, sql)| virtual_table_uses_reserved_content(sql))
3405    {
3406        return Err(Error::InvalidCommand(format!(
3407            "SQL virtual table uses reserved rhiza content: {name}"
3408        )));
3409    }
3410    Ok(())
3411}
3412
3413fn virtual_table_uses_reserved_content(sql: &str) -> bool {
3414    let bytes = sql.as_bytes();
3415    let Some(mut index) = unquoted_open_paren(bytes) else {
3416        return false;
3417    };
3418    index += 1;
3419    let mut depth = 1usize;
3420    let mut argument = Vec::new();
3421    while index < bytes.len() {
3422        match bytes[index] {
3423            b'-' if bytes.get(index + 1) == Some(&b'-') => {
3424                index += 2;
3425                while bytes.get(index).is_some_and(|byte| *byte != b'\n') {
3426                    index += 1;
3427                }
3428                argument.push(b' ');
3429            }
3430            b'/' if bytes.get(index + 1) == Some(&b'*') => {
3431                index += 2;
3432                while index + 1 < bytes.len() && (bytes[index] != b'*' || bytes[index + 1] != b'/')
3433                {
3434                    index += 1;
3435                }
3436                index = (index + 2).min(bytes.len());
3437                argument.push(b' ');
3438            }
3439            quote @ (b'\'' | b'"' | b'`' | b'[') => {
3440                let close = if quote == b'[' { b']' } else { quote };
3441                argument.push(quote);
3442                index += 1;
3443                while let Some(byte) = bytes.get(index).copied() {
3444                    argument.push(byte);
3445                    index += 1;
3446                    if byte == close {
3447                        if bytes.get(index) == Some(&close) {
3448                            argument.push(close);
3449                            index += 1;
3450                        } else {
3451                            break;
3452                        }
3453                    }
3454                }
3455            }
3456            b'(' => {
3457                depth += 1;
3458                argument.push(b'(');
3459                index += 1;
3460            }
3461            b')' if depth == 1 => {
3462                return content_argument_uses_reserved_table(&argument);
3463            }
3464            b')' => {
3465                depth -= 1;
3466                argument.push(b')');
3467                index += 1;
3468            }
3469            b',' if depth == 1 => {
3470                if content_argument_uses_reserved_table(&argument) {
3471                    return true;
3472                }
3473                argument.clear();
3474                index += 1;
3475            }
3476            byte => {
3477                argument.push(byte);
3478                index += 1;
3479            }
3480        }
3481    }
3482    content_argument_uses_reserved_table(&argument)
3483}
3484
3485fn unquoted_open_paren(bytes: &[u8]) -> Option<usize> {
3486    let mut index = 0;
3487    while index < bytes.len() {
3488        match bytes[index] {
3489            b'-' if bytes.get(index + 1) == Some(&b'-') => {
3490                index += 2;
3491                while bytes.get(index).is_some_and(|byte| *byte != b'\n') {
3492                    index += 1;
3493                }
3494            }
3495            b'/' if bytes.get(index + 1) == Some(&b'*') => {
3496                index += 2;
3497                while index + 1 < bytes.len() && (bytes[index] != b'*' || bytes[index + 1] != b'/')
3498                {
3499                    index += 1;
3500                }
3501                index = (index + 2).min(bytes.len());
3502            }
3503            quote @ (b'\'' | b'"' | b'`' | b'[') => {
3504                let close = if quote == b'[' { b']' } else { quote };
3505                index += 1;
3506                while let Some(byte) = bytes.get(index).copied() {
3507                    index += 1;
3508                    if byte == close {
3509                        if bytes.get(index) == Some(&close) {
3510                            index += 1;
3511                        } else {
3512                            break;
3513                        }
3514                    }
3515                }
3516            }
3517            b'(' => return Some(index),
3518            _ => index += 1,
3519        }
3520    }
3521    None
3522}
3523
3524fn content_argument_uses_reserved_table(argument: &[u8]) -> bool {
3525    let argument = trim_ascii(argument);
3526    let Some(equal) = unquoted_equal(argument) else {
3527        return false;
3528    };
3529    let key = dequote_whole_sql_token(trim_ascii(&argument[..equal]));
3530    if !key.eq_ignore_ascii_case(b"content") {
3531        return false;
3532    }
3533    let value = dequote_whole_sql_token(trim_ascii(&argument[equal + 1..]));
3534    std::str::from_utf8(&value).is_ok_and(reserved_table_name)
3535}
3536
3537fn unquoted_equal(token: &[u8]) -> Option<usize> {
3538    let mut index = 0;
3539    let mut depth = 0usize;
3540    while index < token.len() {
3541        match token[index] {
3542            quote @ (b'\'' | b'"' | b'`' | b'[') => {
3543                let close = if quote == b'[' { b']' } else { quote };
3544                index += 1;
3545                while let Some(byte) = token.get(index).copied() {
3546                    index += 1;
3547                    if byte == close {
3548                        if token.get(index) == Some(&close) {
3549                            index += 1;
3550                        } else {
3551                            break;
3552                        }
3553                    }
3554                }
3555            }
3556            b'(' => {
3557                depth += 1;
3558                index += 1;
3559            }
3560            b')' => {
3561                depth = depth.saturating_sub(1);
3562                index += 1;
3563            }
3564            b'=' if depth == 0 => return Some(index),
3565            _ => index += 1,
3566        }
3567    }
3568    None
3569}
3570
3571fn dequote_whole_sql_token(token: &[u8]) -> Vec<u8> {
3572    let Some(open) = token.first().copied() else {
3573        return Vec::new();
3574    };
3575    let close = match open {
3576        b'\'' | b'"' | b'`' => open,
3577        b'[' => b']',
3578        _ => return token.to_vec(),
3579    };
3580    if token.last() != Some(&close) || token.len() < 2 {
3581        return token.to_vec();
3582    }
3583    let mut dequoted = Vec::with_capacity(token.len() - 2);
3584    let mut index = 1;
3585    while index + 1 < token.len() {
3586        let byte = token[index];
3587        dequoted.push(byte);
3588        index += 1;
3589        if byte == close && token.get(index) == Some(&close) {
3590            index += 1;
3591        }
3592    }
3593    dequoted
3594}
3595
3596fn trim_ascii(mut value: &[u8]) -> &[u8] {
3597    while value.first().is_some_and(u8::is_ascii_whitespace) {
3598        value = &value[1..];
3599    }
3600    while value.last().is_some_and(u8::is_ascii_whitespace) {
3601        value = &value[..value.len() - 1];
3602    }
3603    value
3604}
3605
3606fn strip_ascii_prefix_ignore_case<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
3607    value
3608        .get(..prefix.len())
3609        .filter(|candidate| candidate.eq_ignore_ascii_case(prefix))
3610        .map(|_| &value[prefix.len()..])
3611}
3612
3613fn validate_temp_schema_empty(conn: &Connection) -> Result<()> {
3614    let name = conn
3615        .query_row("SELECT name FROM sqlite_temp_schema LIMIT 1", [], |row| {
3616            row.get::<_, String>(0)
3617        })
3618        .optional()
3619        .map_err(sqlite_error)?;
3620    if let Some(name) = name {
3621        return Err(Error::InvalidCommand(format!(
3622            "replicated SQL member left TEMP object {name} behind"
3623        )));
3624    }
3625    Ok(())
3626}
3627
3628fn bundled_vtable(module_name: &str) -> bool {
3629    matches_ignore_ascii_case(
3630        module_name,
3631        &[
3632            "fts3",
3633            "fts3tokenize",
3634            "fts4",
3635            "fts4aux",
3636            "fts5",
3637            "fts5vocab",
3638            "dbstat",
3639            "rtree",
3640            "rtree_i32",
3641        ],
3642    )
3643}
3644
3645fn matches_ignore_ascii_case(value: &str, choices: &[&str]) -> bool {
3646    choices
3647        .iter()
3648        .any(|choice| value.eq_ignore_ascii_case(choice))
3649}
3650
3651fn unsafe_sql_function(name: &str) -> bool {
3652    name.eq_ignore_ascii_case("load_extension")
3653}
3654
3655fn sql_value(value: ValueRef<'_>) -> Result<SqlValue> {
3656    Ok(match value {
3657        ValueRef::Null => SqlValue::Null,
3658        ValueRef::Integer(value) => SqlValue::Integer(value),
3659        ValueRef::Real(value) if value.is_finite() => SqlValue::Real(value),
3660        ValueRef::Real(_) => {
3661            return Err(Error::InvalidCommand(
3662                "SQL real result must be finite".into(),
3663            ));
3664        }
3665        ValueRef::Text(value) => SqlValue::Text(
3666            String::from_utf8(value.to_vec())
3667                .map_err(|_| Error::InvalidCommand("SQL TEXT result is not valid UTF-8".into()))?,
3668        ),
3669        ValueRef::Blob(value) => SqlValue::Blob(value.to_vec()),
3670    })
3671}
3672
3673fn sql_value_size(value: &SqlValue) -> usize {
3674    match value {
3675        SqlValue::Null => 0,
3676        SqlValue::Integer(_) | SqlValue::Real(_) => 8,
3677        SqlValue::Text(value) => value.len(),
3678        SqlValue::Blob(value) => value.len(),
3679    }
3680}
3681
3682fn encode_sql_result(result: &SqlCommandResult) -> Result<Vec<u8>> {
3683    validate_sql_result_bounds(result)?;
3684    let encoded = serde_json::to_vec(result)
3685        .map_err(|error| Error::InvalidCommand(format!("cannot encode SQL result: {error}")))?;
3686    let mut blob = Vec::with_capacity(SQL_RESULT_V1_MAGIC.len() + encoded.len());
3687    blob.extend_from_slice(SQL_RESULT_V1_MAGIC);
3688    blob.extend_from_slice(&encoded);
3689    Ok(blob)
3690}
3691
3692fn decode_sql_result(blob: &[u8]) -> Result<SqlCommandResult> {
3693    let encoded = blob
3694        .strip_prefix(SQL_RESULT_V1_MAGIC)
3695        .ok_or_else(|| Error::Sqlite("unsupported SQL result encoding".into()))?;
3696    let result: SqlCommandResult = serde_json::from_slice(encoded)
3697        .map_err(|error| Error::Sqlite(format!("invalid SQL result: {error}")))?;
3698    validate_sql_result_bounds(&result)?;
3699    if encode_sql_result(&result)? != blob {
3700        return Err(Error::Sqlite("SQL result blob is not canonical".into()));
3701    }
3702    Ok(result)
3703}
3704
3705pub(crate) fn validate_sql_result_blob_bounds(blob: &[u8]) -> Result<()> {
3706    decode_sql_result(blob).map(|_| ())
3707}
3708
3709fn validate_sql_result_bounds(result: &SqlCommandResult) -> Result<()> {
3710    let mut row_count = 0usize;
3711    let mut bytes = 0usize;
3712    for statement in &result.statement_results {
3713        let Some(returning) = &statement.returning else {
3714            continue;
3715        };
3716        row_count = row_count
3717            .checked_add(returning.rows.len())
3718            .ok_or_else(|| Error::InvalidCommand("SQL RETURNING row count overflow".into()))?;
3719        if row_count > MAX_RETURNING_ROWS {
3720            return Err(Error::InvalidCommand(format!(
3721                "SQL RETURNING exceeds {MAX_RETURNING_ROWS} rows"
3722            )));
3723        }
3724        for column in &returning.columns {
3725            add_returning_bytes(&mut bytes, column.len())?;
3726        }
3727        for row in &returning.rows {
3728            if row.len() != returning.columns.len() {
3729                return Err(Error::InvalidCommand(
3730                    "SQL RETURNING row width does not match its columns".into(),
3731                ));
3732            }
3733            for value in row {
3734                add_returning_bytes(&mut bytes, sql_value_size(value))?;
3735            }
3736        }
3737    }
3738    Ok(())
3739}
3740
3741fn integrity_check(conn: &Connection) -> Result<()> {
3742    let mut statement = conn
3743        .prepare("PRAGMA integrity_check;")
3744        .map_err(|err| Error::InvalidSnapshot(err.to_string()))?;
3745    let mut rows = statement
3746        .query([])
3747        .map_err(|err| Error::InvalidSnapshot(err.to_string()))?;
3748    let mut messages = Vec::new();
3749    while let Some(row) = rows
3750        .next()
3751        .map_err(|err| Error::InvalidSnapshot(err.to_string()))?
3752    {
3753        messages.push(
3754            row.get::<_, String>(0)
3755                .map_err(|err| Error::InvalidSnapshot(err.to_string()))?,
3756        );
3757    }
3758    if messages == ["ok"] {
3759        return Ok(());
3760    }
3761    Err(Error::InvalidSnapshot(if messages.is_empty() {
3762        "integrity_check returned no result".into()
3763    } else {
3764        messages.join("; ")
3765    }))
3766}
3767
3768fn ensure_parent(path: &Path) -> Result<()> {
3769    let parent = parent_dir(path);
3770    fs::create_dir_all(parent).map_err(io_error)
3771}
3772
3773fn parent_dir(path: &Path) -> &Path {
3774    path.parent()
3775        .filter(|parent| !parent.as_os_str().is_empty())
3776        .unwrap_or_else(|| Path::new("."))
3777}
3778
3779fn sync_parent(parent: &Path) -> Result<()> {
3780    File::open(parent)
3781        .and_then(|directory| directory.sync_all())
3782        .map_err(io_error)
3783}
3784
3785fn sqlite_error(error: rusqlite::Error) -> Error {
3786    Error::Sqlite(error.to_string())
3787}
3788
3789fn sql_query_error(error: rusqlite::Error) -> Error {
3790    match &error {
3791        rusqlite::Error::SqliteFailure(code, _)
3792            if code.code == rusqlite::ffi::ErrorCode::OperationInterrupted =>
3793        {
3794            Error::ResourceExhausted("SQL query execution timed out".into())
3795        }
3796        _ => sqlite_error(error),
3797    }
3798}
3799
3800fn io_error(error: std::io::Error) -> Error {
3801    Error::Io(error.to_string())
3802}
3803
3804#[cfg(test)]
3805mod query_policy_tests {
3806    use super::*;
3807
3808    #[test]
3809    fn reserved_name_and_vtable_content_checks_match_only_structural_sentinels() {
3810        for name in ["__rhiza_kv", "sqlite_autoindex___rhiza_kv_1"] {
3811            assert!(reserved_name(name));
3812        }
3813        for name in [
3814            "__rhiza_user_table",
3815            "__RHIZA_META",
3816            "__rhiza_requests",
3817            "x__rhiza_kv",
3818            "sqlite_autoindex_user_1",
3819        ] {
3820            assert!(!reserved_name(name));
3821        }
3822        for sql in [
3823            "CREATE VIRTUAL TABLE x USING fts5(body, content='__rhiza_kv')",
3824            "CREATE VIRTUAL TABLE x USING fts5(prefix=(a,b), CoNtEnT /* option */ = '__rhiza_kv')",
3825        ] {
3826            assert!(virtual_table_uses_reserved_content(sql));
3827        }
3828        for sql in [
3829            "CREATE VIRTUAL TABLE x USING fts5(__rhiza_kv)",
3830            "CREATE VIRTUAL TABLE x USING fts5(body, tokenize='__rhiza_kv')",
3831            "CREATE VIRTUAL TABLE x USING fts5(body, content='__rhiza_kv_user')",
3832            "CREATE VIRTUAL TABLE x USING fts5(body, CONTENT = [__RHIZA_META])",
3833            "CREATE VIRTUAL TABLE x USING fts5(body, content=__rhiza_requests)",
3834            "CREATE VIRTUAL TABLE x USING fts5(body, 'content=__rhiza_kv')",
3835            "CREATE VIRTUAL TABLE x USING fts5(body, \"content=__rhiza_meta\")",
3836            "CREATE VIRTUAL TABLE x USING fts5(body, `content=__rhiza_requests`)",
3837            "CREATE VIRTUAL TABLE x USING fts5(body, [content=__rhiza_kv])",
3838            "CREATE VIRTUAL TABLE x USING fts5(body, 'content=''__rhiza_kv''')",
3839            "CREATE VIRTUAL TABLE x USING fts5(body, /* content='__rhiza_kv' */ tokenize='porter')",
3840            "CREATE VIRTUAL TABLE x USING fts5(body, -- content='__rhiza_kv'\n tokenize='porter')",
3841        ] {
3842            assert!(!virtual_table_uses_reserved_content(sql));
3843        }
3844    }
3845
3846    fn prepare_single_sql_effect(
3847        database: &SqliteStateMachine,
3848        command: &SqlCommand,
3849        request: &[u8],
3850        base_index: LogIndex,
3851        base_hash: LogHash,
3852    ) -> Result<Vec<u8>> {
3853        let preparation = database.prepare_sql_batch_effect(
3854            &[SqlBatchMember {
3855                command,
3856                request_payload: request,
3857            }],
3858            base_index,
3859            base_hash,
3860        )?;
3861        preparation
3862            .results
3863            .into_iter()
3864            .next()
3865            .expect("one-member batch returns one result")?;
3866        preparation
3867            .effect
3868            .ok_or_else(|| Error::InvalidCommand("successful batch produced no effect".into()))
3869    }
3870
3871    #[test]
3872    fn speculative_prepare_uses_one_copy_and_non_durable_sqlite_without_weakening_canonical() {
3873        let dir = tempfile::tempdir().unwrap();
3874        let path = dir.path().join("state.sqlite");
3875        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
3876        let command = SqlCommand {
3877            request_id: "speculative-durability".into(),
3878            statements: vec![SqlStatement {
3879                sql: "CREATE TABLE speculative(value TEXT NOT NULL)".into(),
3880                parameters: vec![],
3881            }],
3882        };
3883        let request = encode_sql_command(&command).unwrap();
3884        let base_digest = database.canonical_db_digest().unwrap();
3885
3886        prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
3887
3888        let audit = speculative_prepare_audit(&path).expect("prepare records its test audit");
3889        assert_eq!(audit.copy_count, 1);
3890        assert_eq!(audit.synchronous, 0);
3891        assert_eq!(database.connection_pragmas().unwrap(), ("wal".into(), 0));
3892        assert_eq!(database.canonical_db_digest().unwrap(), base_digest);
3893        let prepared = database.prepared_target.lock().unwrap();
3894        assert!(
3895            prepared
3896                .as_ref()
3897                .unwrap()
3898                .artifact
3899                .as_file()
3900                .metadata()
3901                .unwrap()
3902                .len()
3903                > 0
3904        );
3905    }
3906
3907    #[test]
3908    fn batch_capacity_rejects_1025_members_before_speculative_io() {
3909        let dir = tempfile::tempdir().unwrap();
3910        let path = dir.path().join("state.sqlite");
3911        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
3912        let command = SqlCommand {
3913            request_id: "over-capacity".into(),
3914            statements: vec![SqlStatement {
3915                sql: "CREATE TABLE must_not_run(value INTEGER NOT NULL)".into(),
3916                parameters: vec![],
3917            }],
3918        };
3919        let payload = encode_sql_command(&command).unwrap();
3920        let member = SqlBatchMember {
3921            command: &command,
3922            request_payload: &payload,
3923        };
3924
3925        assert!(matches!(
3926            database.prepare_sql_batch_effect(&vec![member; 1025], 0, LogHash::ZERO),
3927            Err(Error::InvalidCommand(_))
3928        ));
3929        assert_eq!(speculative_prepare_audit(&path), None);
3930        assert_eq!(database.applied_tip_value().unwrap(), (0, LogHash::ZERO));
3931    }
3932
3933    #[test]
3934    fn capacity_sized_batch_rejects_a_duplicate_request_id_at_the_tail() {
3935        let dir = tempfile::tempdir().unwrap();
3936        let database =
3937            SqliteStateMachine::open(dir.path().join("state.sqlite"), "cluster-a", "node-1", 1, 1)
3938                .unwrap();
3939        let commands = (0usize..MAX_QWAL_V3_RECEIPTS)
3940            .map(|index| SqlCommand {
3941                request_id: if index + 1 == MAX_QWAL_V3_RECEIPTS {
3942                    "request-0000".into()
3943                } else {
3944                    format!("request-{index:04}")
3945                },
3946                statements: vec![SqlStatement {
3947                    sql: "INSERT INTO absent_table(value) VALUES (?1)".into(),
3948                    parameters: vec![SqlValue::Integer(index as i64)],
3949                }],
3950            })
3951            .collect::<Vec<_>>();
3952        let payloads = commands
3953            .iter()
3954            .map(|command| encode_sql_command(command).unwrap())
3955            .collect::<Vec<_>>();
3956        let members = commands
3957            .iter()
3958            .zip(&payloads)
3959            .map(|(command, request_payload)| SqlBatchMember {
3960                command,
3961                request_payload,
3962            })
3963            .collect::<Vec<_>>();
3964
3965        let preparation = database
3966            .prepare_sql_batch_effect(&members, 0, LogHash::ZERO)
3967            .unwrap();
3968
3969        assert!(preparation.effect.is_none());
3970        assert_eq!(preparation.results.len(), MAX_QWAL_V3_RECEIPTS);
3971        assert!(matches!(
3972            preparation.results.last().unwrap(),
3973            Err(Error::InvalidCommand(message)) if message.contains("repeats a request_id")
3974        ));
3975        assert_eq!(database.applied_tip_value().unwrap(), (0, LogHash::ZERO));
3976    }
3977
3978    #[test]
3979    fn applied_tip_returns_index_and_hash_from_the_same_database_state() {
3980        let dir = tempfile::tempdir().unwrap();
3981        let database =
3982            SqliteStateMachine::open(dir.path().join("state.sqlite"), "cluster-a", "node-1", 1, 1)
3983                .unwrap();
3984        let request = b"put\trequest-1\tkey-1\tvalue-1";
3985        let payload = database
3986            .prepare_put_effect("request-1", "key-1", "value-1", request, 0, LogHash::ZERO)
3987            .unwrap();
3988        let hash = LogEntry::calculate_hash(
3989            "cluster-a",
3990            1,
3991            1,
3992            1,
3993            EntryType::Command,
3994            LogHash::ZERO,
3995            &payload,
3996        );
3997        database
3998            .apply_entry(&LogEntry {
3999                cluster_id: "cluster-a".into(),
4000                epoch: 1,
4001                config_id: 1,
4002                index: 1,
4003                entry_type: EntryType::Command,
4004                payload: payload.to_vec(),
4005                prev_hash: LogHash::ZERO,
4006                hash,
4007            })
4008            .unwrap();
4009
4010        assert_eq!(database.applied_tip().unwrap(), ApplyProgress::new(1, hash));
4011        assert_eq!(database.applied_tip_value().unwrap(), (1, hash));
4012    }
4013
4014    #[test]
4015    fn bulk_sql_request_check_aligns_exact_absent_and_conflict_results() {
4016        let dir = tempfile::tempdir().unwrap();
4017        let path = dir.path().join("state.sqlite");
4018        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4019        let committed = SqlCommand {
4020            request_id: "committed".into(),
4021            statements: vec![SqlStatement {
4022                sql: "CREATE TABLE committed(value INTEGER NOT NULL)".into(),
4023                parameters: vec![],
4024            }],
4025        };
4026        let committed_payload = encode_sql_command(&committed).unwrap();
4027        let effect =
4028            prepare_single_sql_effect(&database, &committed, &committed_payload, 0, LogHash::ZERO)
4029                .unwrap();
4030        let entry = command_entry(1, LogHash::ZERO, effect);
4031        database.apply_entry(&entry).unwrap();
4032        let absent = SqlCommand {
4033            request_id: "absent".into(),
4034            statements: vec![SqlStatement {
4035                sql: "SELECT 1".into(),
4036                parameters: vec![],
4037            }],
4038        };
4039        let absent_payload = encode_sql_command(&absent).unwrap();
4040
4041        let aligned = database
4042            .check_sql_requests(&[
4043                ("committed", committed_payload.as_slice()),
4044                ("absent", absent_payload.as_slice()),
4045            ])
4046            .unwrap();
4047        assert!(matches!(
4048            aligned.as_slice(),
4049            [Ok(Some((outcome, Some(_)))), Ok(None)]
4050                if *outcome == RequestOutcome::new(1, entry.hash)
4051        ));
4052
4053        let conflicting = SqlCommand {
4054            request_id: "committed".into(),
4055            statements: vec![SqlStatement {
4056                sql: "SELECT 2".into(),
4057                parameters: vec![],
4058            }],
4059        };
4060        let conflicting_payload = encode_sql_command(&conflicting).unwrap();
4061        assert!(matches!(
4062            database
4063                .check_sql_requests(&[("committed", conflicting_payload.as_slice())])
4064                .unwrap()
4065                .pop()
4066                .unwrap(),
4067            Err(Error::RequestConflict(_))
4068        ));
4069        assert!(matches!(
4070            database.check_sql_requests(&[
4071                ("committed", committed_payload.as_slice()),
4072                ("committed", committed_payload.as_slice()),
4073            ]),
4074            Err(Error::InvalidCommand(message)) if message.contains("duplicate")
4075        ));
4076    }
4077
4078    #[test]
4079    fn read_query_timeout_interrupts_work_and_releases_the_connection() {
4080        let dir = tempfile::tempdir().unwrap();
4081        let database =
4082            SqliteStateMachine::open(dir.path().join("state.sqlite"), "cluster-a", "node-1", 1, 1)
4083                .unwrap();
4084        let expensive = SqlStatement {
4085            sql: "WITH RECURSIVE count(value) AS (VALUES(0) UNION ALL SELECT value + 1 FROM count WHERE value < 100000000) SELECT sum(value) FROM count".into(),
4086            parameters: vec![],
4087        };
4088
4089        assert_eq!(
4090            database
4091                .query_sql_with_timeout(&expensive, 1, 1024, Duration::ZERO)
4092                .unwrap_err(),
4093            Error::ResourceExhausted("SQL query execution timed out".into())
4094        );
4095
4096        let quick = database
4097            .query_sql(
4098                &SqlStatement {
4099                    sql: "SELECT 1".into(),
4100                    parameters: vec![],
4101                },
4102                1,
4103                1024,
4104            )
4105            .unwrap();
4106        assert_eq!(quick.rows, vec![vec![SqlValue::Integer(1)]]);
4107    }
4108
4109    #[test]
4110    fn read_query_allows_nondeterministic_and_runtime_introspection_functions() {
4111        let dir = tempfile::tempdir().unwrap();
4112        let database =
4113            SqliteStateMachine::open(dir.path().join("state.sqlite"), "cluster-a", "node-1", 1, 1)
4114                .unwrap();
4115
4116        let result = database
4117            .query_sql(
4118                &SqlStatement {
4119                    sql: "SELECT random(), datetime('now'), sqlite_version()".into(),
4120                    parameters: vec![],
4121                },
4122                1,
4123                4096,
4124            )
4125            .unwrap();
4126
4127        assert_eq!(result.rows.len(), 1);
4128        assert_eq!(result.rows[0].len(), 3);
4129    }
4130
4131    #[test]
4132    fn normal_reads_do_not_query_the_control_pending_table() {
4133        let dir = tempfile::tempdir().unwrap();
4134        let path = dir.path().join("state.sqlite");
4135        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4136        let control_path = control_sidecar_path(&path);
4137        control::begin_pending_query_audit(&control_path);
4138        let command = SqlCommand {
4139            request_id: "pending-audit".into(),
4140            statements: vec![SqlStatement {
4141                sql: "SELECT 1".into(),
4142                parameters: vec![],
4143            }],
4144        };
4145        let request = encode_sql_command(&command).unwrap();
4146
4147        assert_eq!(database.get_value("absent").unwrap(), None);
4148        database
4149            .query_sql(
4150                &SqlStatement {
4151                    sql: "SELECT 1".into(),
4152                    parameters: vec![],
4153                },
4154                1,
4155                64,
4156            )
4157            .unwrap();
4158        assert_eq!(
4159            database.check_request("pending-audit", &request).unwrap(),
4160            None
4161        );
4162        assert!(matches!(
4163            database
4164                .check_sql_requests(&[("pending-audit", request.as_slice())])
4165                .unwrap()
4166                .as_slice(),
4167            [Ok(None)]
4168        ));
4169
4170        assert_eq!(control::pending_query_count(&control_path), Some(0));
4171    }
4172
4173    #[test]
4174    fn physical_effect_preparation_accepts_nondeterministic_functions() {
4175        let dir = tempfile::tempdir().unwrap();
4176        let database =
4177            SqliteStateMachine::open(dir.path().join("state.sqlite"), "cluster-a", "node-1", 1, 1)
4178                .unwrap();
4179        let command = SqlCommand {
4180            request_id: "nondeterministic-write".into(),
4181            statements: vec![
4182                SqlStatement {
4183                    sql: "CREATE TABLE generated(value INTEGER DEFAULT (random()))".into(),
4184                    parameters: vec![],
4185                },
4186                SqlStatement {
4187                    sql: "INSERT INTO generated DEFAULT VALUES".into(),
4188                    parameters: vec![],
4189                },
4190            ],
4191        };
4192
4193        let payload = encode_sql_command(&command).unwrap();
4194        let effect =
4195            prepare_single_sql_effect(&database, &command, &payload, 0, LogHash::ZERO).unwrap();
4196        assert!(effect.starts_with(QWAL_V3_MAGIC));
4197        assert_eq!(database.applied_index_value().unwrap(), 0);
4198    }
4199
4200    #[test]
4201    fn non_durable_staging_uses_native_wal_capture_without_checkpoint_or_full_diff() {
4202        let dir = tempfile::tempdir().unwrap();
4203        let path = dir.path().join("state.sqlite");
4204        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4205        let command = SqlCommand {
4206            request_id: "recorded-write".into(),
4207            statements: vec![
4208                SqlStatement {
4209                    sql: "CREATE TABLE recorded(value TEXT NOT NULL)".into(),
4210                    parameters: vec![],
4211                },
4212                SqlStatement {
4213                    sql: "INSERT INTO recorded(value) VALUES ('shadow')".into(),
4214                    parameters: vec![],
4215                },
4216            ],
4217        };
4218        let request = encode_sql_command(&command).unwrap();
4219        let payload =
4220            prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
4221
4222        let audit = speculative_prepare_audit(&path).unwrap();
4223        assert!(audit.native_vfs);
4224        assert!(audit.no_checkpoint_on_close);
4225        {
4226            let prepared = database.prepared_target.lock().unwrap();
4227            assert!(sqlite_sidecars_absent(prepared.as_ref().unwrap().artifact.path()).unwrap());
4228        }
4229        let effect = decode_qwal_v3(&payload).unwrap();
4230        let hash = LogEntry::calculate_hash(
4231            "cluster-a",
4232            1,
4233            1,
4234            1,
4235            EntryType::Command,
4236            LogHash::ZERO,
4237            &payload,
4238        );
4239        database
4240            .apply_entry(&LogEntry {
4241                cluster_id: "cluster-a".into(),
4242                epoch: 1,
4243                config_id: 1,
4244                index: 1,
4245                entry_type: EntryType::Command,
4246                payload,
4247                prev_hash: LogHash::ZERO,
4248                hash,
4249            })
4250            .unwrap();
4251        assert_eq!(
4252            rebuild_page_state(&path).unwrap().identity(),
4253            effect.target_state
4254        );
4255    }
4256
4257    #[test]
4258    fn prepare_fails_closed_without_changing_canonical_or_control_when_held_wal_changes() {
4259        let dir = tempfile::tempdir().unwrap();
4260        let path = dir.path().join("state.sqlite");
4261        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4262        let command = SqlCommand {
4263            request_id: "unstable-held-wal".into(),
4264            statements: vec![SqlStatement {
4265                sql: "CREATE TABLE unstable(value INTEGER NOT NULL)".into(),
4266                parameters: vec![],
4267            }],
4268        };
4269        let request = encode_sql_command(&command).unwrap();
4270        let base_digest = database.canonical_db_digest().unwrap();
4271        arm_wal_capture_fault(&path, WalCaptureFault::ChangeHeldWalAfterSeal);
4272
4273        let error =
4274            prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap_err();
4275
4276        assert!(
4277            matches!(
4278                &error,
4279                Error::InvalidEntry(message) if message.contains("changed after capture was sealed")
4280            ),
4281            "{error:?}"
4282        );
4283        assert_eq!(database.canonical_db_digest().unwrap(), base_digest);
4284        assert_eq!(database.applied_tip_value().unwrap(), (0, LogHash::ZERO));
4285        assert_eq!(
4286            database
4287                .check_sql_request("unstable-held-wal", &request)
4288                .unwrap(),
4289            None
4290        );
4291        assert!(database.prepared_target.lock().unwrap().is_none());
4292        assert!(fs::read_dir(dir.path()).unwrap().all(|entry| {
4293            entry
4294                .unwrap()
4295                .file_name()
4296                .to_string_lossy()
4297                .starts_with("state.sqlite")
4298        }));
4299    }
4300
4301    #[test]
4302    fn successful_noop_uses_explicit_no_change_effect_and_still_replays_its_receipt() {
4303        let dir = tempfile::tempdir().unwrap();
4304        let path = dir.path().join("state.sqlite");
4305        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4306        let setup = SqlCommand {
4307            request_id: "no-physical-change-setup".into(),
4308            statements: vec![SqlStatement {
4309                sql: "CREATE TABLE noop_target(value TEXT NOT NULL)".into(),
4310                parameters: vec![],
4311            }],
4312        };
4313        let setup_request = encode_sql_command(&setup).unwrap();
4314        let setup_payload =
4315            prepare_single_sql_effect(&database, &setup, &setup_request, 0, LogHash::ZERO).unwrap();
4316        let setup_entry = command_entry(1, LogHash::ZERO, setup_payload);
4317        database.apply_entry(&setup_entry).unwrap();
4318        let command = SqlCommand {
4319            request_id: "no-physical-change".into(),
4320            statements: vec![SqlStatement {
4321                sql: "UPDATE noop_target SET value = 'unused' WHERE rowid = -1".into(),
4322                parameters: vec![],
4323            }],
4324        };
4325        let request = encode_sql_command(&command).unwrap();
4326        let base_digest = database.canonical_db_digest().unwrap();
4327        let payload =
4328            prepare_single_sql_effect(&database, &command, &request, 1, setup_entry.hash).unwrap();
4329        let effect = decode_qwal_v3(&payload).unwrap();
4330        assert!(effect.pages.is_empty());
4331        assert_eq!(effect.base_state, effect.target_state);
4332
4333        let entry = command_entry(2, setup_entry.hash, payload);
4334        database.apply_entry(&entry).unwrap();
4335
4336        assert_eq!(database.canonical_db_digest().unwrap(), base_digest);
4337        assert!(database
4338            .check_sql_request("no-physical-change", &request)
4339            .unwrap()
4340            .is_some());
4341    }
4342
4343    #[test]
4344    fn no_change_control_failure_blocks_everything_except_exact_replay() {
4345        let dir = tempfile::tempdir().unwrap();
4346        let path = dir.path().join("state.sqlite");
4347        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4348        let setup = SqlCommand {
4349            request_id: "no-change-fault-setup".into(),
4350            statements: vec![SqlStatement {
4351                sql: "CREATE TABLE no_change_fault(value TEXT NOT NULL)".into(),
4352                parameters: vec![],
4353            }],
4354        };
4355        let setup_request = encode_sql_command(&setup).unwrap();
4356        let setup_payload =
4357            prepare_single_sql_effect(&database, &setup, &setup_request, 0, LogHash::ZERO).unwrap();
4358        let setup_entry = command_entry(1, LogHash::ZERO, setup_payload);
4359        database.apply_entry(&setup_entry).unwrap();
4360
4361        let command = SqlCommand {
4362            request_id: "no-change-fault".into(),
4363            statements: vec![SqlStatement {
4364                sql: "UPDATE no_change_fault SET value = 'unused' WHERE rowid = -1".into(),
4365                parameters: vec![],
4366            }],
4367        };
4368        let request = encode_sql_command(&command).unwrap();
4369        let payload =
4370            prepare_single_sql_effect(&database, &command, &request, 1, setup_entry.hash).unwrap();
4371        let effect = decode_qwal_v3(&payload).unwrap();
4372        assert!(effect.pages.is_empty());
4373        assert_eq!(effect.base_state, effect.target_state);
4374        let entry = command_entry(2, setup_entry.hash, payload);
4375        arm_qwal_apply_fault(&path, QwalApplyFault::BeforeControlCommit);
4376
4377        assert!(database.apply_entry(&entry).is_err());
4378        assert!(database.pending_fence.load(Ordering::Acquire));
4379        assert!(database.get_value("blocked").is_err());
4380        assert!(database
4381            .query_sql(
4382                &SqlStatement {
4383                    sql: "SELECT 1".into(),
4384                    parameters: vec![],
4385                },
4386                1,
4387                64,
4388            )
4389            .is_err());
4390        assert!(database.check_request("no-change-fault", &request).is_err());
4391        assert!(database
4392            .prepare_sql_batch_effect(
4393                &[SqlBatchMember {
4394                    command: &command,
4395                    request_payload: &request,
4396                }],
4397                1,
4398                setup_entry.hash,
4399            )
4400            .is_err());
4401        let different = LogEntry {
4402            cluster_id: "cluster-a".into(),
4403            epoch: 1,
4404            config_id: 1,
4405            index: 2,
4406            entry_type: EntryType::Noop,
4407            payload: Vec::new(),
4408            prev_hash: setup_entry.hash,
4409            hash: LogEntry::calculate_hash(
4410                "cluster-a",
4411                2,
4412                1,
4413                1,
4414                EntryType::Noop,
4415                setup_entry.hash,
4416                &[],
4417            ),
4418        };
4419        assert!(database.apply_entry(&different).is_err());
4420
4421        database.apply_entry(&entry).unwrap();
4422        assert!(!database.pending_fence.load(Ordering::Acquire));
4423        assert_eq!(database.applied_tip_value().unwrap(), (2, entry.hash));
4424        assert_eq!(database.get_value("unblocked").unwrap(), None);
4425        assert!(database
4426            .check_sql_request("no-change-fault", &request)
4427            .unwrap()
4428            .is_some());
4429    }
4430
4431    #[test]
4432    fn exact_consensus_winner_promotes_the_prepared_target_without_rebuilding() {
4433        let dir = tempfile::tempdir().unwrap();
4434        let path = dir.path().join("state.sqlite");
4435        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4436        let command = SqlCommand {
4437            request_id: "prepared-winner".into(),
4438            statements: vec![SqlStatement {
4439                sql: "CREATE TABLE promoted(value TEXT NOT NULL)".into(),
4440                parameters: vec![],
4441            }],
4442        };
4443        let request = encode_sql_command(&command).unwrap();
4444        let payload =
4445            prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
4446        assert_eq!(
4447            database
4448                .query_sql(
4449                    &SqlStatement {
4450                        sql: "SELECT 1".into(),
4451                        parameters: vec![],
4452                    },
4453                    1,
4454                    1024,
4455                )
4456                .unwrap()
4457                .rows,
4458            vec![vec![SqlValue::Integer(1)]]
4459        );
4460        let entry = command_entry(1, LogHash::ZERO, payload);
4461
4462        let outcome = database.apply_entry_with_result(&entry).unwrap();
4463
4464        assert_eq!(
4465            prepared_install_path(&path),
4466            Some(PreparedInstallPath::Promoted)
4467        );
4468        assert_eq!(
4469            prepared_base_reuse_audit(&path),
4470            Some(PreparedBaseReuseAudit {
4471                second_checkpoint_count: 0,
4472            })
4473        );
4474        assert_eq!(outcome.progress(), ApplyProgress::new(1, entry.hash));
4475        assert_eq!(
4476            database
4477                .check_sql_request("prepared-winner", &request)
4478                .unwrap()
4479                .unwrap()
4480                .0,
4481            RequestOutcome::new(1, entry.hash)
4482        );
4483    }
4484
4485    #[test]
4486    fn foreign_consensus_winner_discards_the_prepared_target_and_patches_in_place() {
4487        let dir = tempfile::tempdir().unwrap();
4488        let local_path = dir.path().join("local.sqlite");
4489        let foreign_path = dir.path().join("foreign.sqlite");
4490        let local = SqliteStateMachine::open(&local_path, "cluster-a", "node-1", 1, 1).unwrap();
4491        let foreign = SqliteStateMachine::open(&foreign_path, "cluster-a", "node-2", 1, 1).unwrap();
4492        let local_command = SqlCommand {
4493            request_id: "local-loser".into(),
4494            statements: vec![SqlStatement {
4495                sql: "CREATE TABLE local_only(value TEXT NOT NULL)".into(),
4496                parameters: vec![],
4497            }],
4498        };
4499        let local_request = encode_sql_command(&local_command).unwrap();
4500        let _ = prepare_single_sql_effect(&local, &local_command, &local_request, 0, LogHash::ZERO)
4501            .unwrap();
4502        let foreign_command = SqlCommand {
4503            request_id: "foreign-winner".into(),
4504            statements: vec![SqlStatement {
4505                sql: "CREATE TABLE foreign_only(value TEXT NOT NULL)".into(),
4506                parameters: vec![],
4507            }],
4508        };
4509        let foreign_request = encode_sql_command(&foreign_command).unwrap();
4510        let foreign_effect = prepare_single_sql_effect(
4511            &foreign,
4512            &foreign_command,
4513            &foreign_request,
4514            0,
4515            LogHash::ZERO,
4516        )
4517        .unwrap();
4518
4519        local
4520            .apply_entry(&command_entry(1, LogHash::ZERO, foreign_effect))
4521            .unwrap();
4522
4523        assert_eq!(
4524            prepared_install_path(&local_path),
4525            Some(PreparedInstallPath::Patched)
4526        );
4527        assert_eq!(
4528            prepared_base_reuse_audit(&local_path),
4529            Some(PreparedBaseReuseAudit {
4530                second_checkpoint_count: 1,
4531            })
4532        );
4533        assert_eq!(
4534            local
4535                .query_sql(
4536                    &SqlStatement {
4537                        sql: "SELECT name FROM sqlite_schema WHERE name = 'foreign_only'".into(),
4538                        parameters: vec![],
4539                    },
4540                    1,
4541                    1024,
4542                )
4543                .unwrap()
4544                .rows,
4545            vec![vec![SqlValue::Text("foreign_only".into())]]
4546        );
4547    }
4548
4549    #[cfg(unix)]
4550    #[test]
4551    fn same_size_canonical_mutation_is_rejected_before_qwal_writes() {
4552        let dir = tempfile::tempdir().unwrap();
4553        let path = dir.path().join("state.sqlite");
4554        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4555        let command = SqlCommand {
4556            request_id: "sealed-base-mutation".into(),
4557            statements: vec![SqlStatement {
4558                sql: "CREATE TABLE must_not_be_installed(value INTEGER NOT NULL)".into(),
4559                parameters: vec![],
4560            }],
4561        };
4562        let request = encode_sql_command(&command).unwrap();
4563        let payload =
4564            prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
4565        let entry = command_entry(1, LogHash::ZERO, payload);
4566        {
4567            let _lifecycle = database.lock_lifecycle().unwrap();
4568            database.close_connection().unwrap();
4569            let mut file = OpenOptions::new()
4570                .read(true)
4571                .write(true)
4572                .open(&path)
4573                .unwrap();
4574            let offset = file.metadata().unwrap().len() - 1;
4575            file.seek(SeekFrom::Start(offset)).unwrap();
4576            let mut byte = [0_u8; 1];
4577            file.read_exact(&mut byte).unwrap();
4578            byte[0] ^= 0xff;
4579            file.seek(SeekFrom::Start(offset)).unwrap();
4580            file.write_all(&byte).unwrap();
4581        }
4582        let mutated = fs::read(&path).unwrap();
4583
4584        assert!(matches!(
4585            database.apply_entry(&entry),
4586            Err(Error::InvalidEntry(message)) if message.contains("page-state seal")
4587        ));
4588        assert_eq!(fs::read(&path).unwrap(), mutated);
4589        assert_eq!(database.applied_tip_value().unwrap(), (0, LogHash::ZERO));
4590    }
4591
4592    #[test]
4593    fn interrupted_in_place_apply_is_rejected_on_reopen_for_recorder_rebuild() {
4594        let dir = tempfile::tempdir().unwrap();
4595        let proposer_path = dir.path().join("proposer.sqlite");
4596        let follower_path = dir.path().join("follower.sqlite");
4597        let proposer =
4598            SqliteStateMachine::open(&proposer_path, "cluster-a", "node-1", 1, 1).unwrap();
4599        let follower =
4600            SqliteStateMachine::open(&follower_path, "cluster-a", "node-2", 1, 1).unwrap();
4601        let command = SqlCommand {
4602            request_id: "interrupt-in-place".into(),
4603            statements: vec![
4604                SqlStatement {
4605                    sql: "CREATE TABLE interrupted(value BLOB NOT NULL)".into(),
4606                    parameters: vec![],
4607                },
4608                SqlStatement {
4609                    sql: "INSERT INTO interrupted VALUES (zeroblob(20000))".into(),
4610                    parameters: vec![],
4611                },
4612            ],
4613        };
4614        let request = encode_sql_command(&command).unwrap();
4615        let payload =
4616            prepare_single_sql_effect(&proposer, &command, &request, 0, LogHash::ZERO).unwrap();
4617        let effect = decode_qwal_v3(&payload).unwrap();
4618        assert!(effect.pages.len() > 1);
4619        arm_qwal_apply_fault(
4620            &follower_path,
4621            QwalApplyFault::AfterPage(effect.pages[0].page_no),
4622        );
4623
4624        assert!(follower
4625            .apply_entry(&command_entry(1, LogHash::ZERO, payload))
4626            .is_err());
4627        assert!(matches!(
4628            follower.query_sql(&SqlStatement { sql: "SELECT 1".into(), parameters: vec![] }, 1, 64),
4629            Err(Error::InvalidEntry(message)) if message.contains("pending")
4630        ));
4631        drop(follower);
4632        assert!(matches!(
4633            SqliteStateMachine::open_existing(&follower_path),
4634            Err(Error::InvalidEntry(message)) if message.contains("page state")
4635        ));
4636    }
4637
4638    #[test]
4639    fn startup_rejects_untracked_committed_wal_before_exposing_reads() {
4640        let dir = tempfile::tempdir().unwrap();
4641        let path = dir.path().join("state.sqlite");
4642        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4643        drop(database);
4644
4645        let external = Connection::open(&path).unwrap();
4646        external
4647            .pragma_update(None, "wal_autocheckpoint", 0)
4648            .unwrap();
4649        external
4650            .execute_batch(
4651                "CREATE TABLE untracked_wal(value TEXT NOT NULL);
4652                 INSERT INTO untracked_wal VALUES ('must-not-be-visible');",
4653            )
4654            .unwrap();
4655        assert!(
4656            fs::metadata(sqlite_sidecar_path(&path, "-wal"))
4657                .unwrap()
4658                .len()
4659                > 0
4660        );
4661
4662        assert!(matches!(
4663            SqliteStateMachine::open_existing(&path),
4664            Err(Error::InvalidEntry(message)) if message.contains("sidecars")
4665        ));
4666        assert!(cleanup_empty_wal_sidecars_after_owner_fence(&path).is_err());
4667        assert!(
4668            fs::metadata(sqlite_sidecar_path(&path, "-wal"))
4669                .unwrap()
4670                .len()
4671                > 0
4672        );
4673        drop(external);
4674    }
4675
4676    #[test]
4677    fn post_install_control_failure_closes_reads_until_exact_replay_commits() {
4678        let dir = tempfile::tempdir().unwrap();
4679        let path = dir.path().join("state.sqlite");
4680        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4681        let command = SqlCommand {
4682            request_id: "control-failure".into(),
4683            statements: vec![SqlStatement {
4684                sql: "CREATE TABLE installed_before_control(value INTEGER NOT NULL)".into(),
4685                parameters: vec![],
4686            }],
4687        };
4688        let request = encode_sql_command(&command).unwrap();
4689        let payload =
4690            prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
4691        let entry = command_entry(1, LogHash::ZERO, payload);
4692        arm_qwal_apply_fault(&path, QwalApplyFault::BeforeControlCommit);
4693
4694        assert!(database.apply_entry(&entry).is_err());
4695        assert_eq!(database.applied_tip_value().unwrap(), (0, LogHash::ZERO));
4696        assert!(matches!(
4697            database.query_sql(&SqlStatement { sql: "SELECT 1".into(), parameters: vec![] }, 1, 64),
4698            Err(Error::InvalidEntry(message)) if message.contains("pending")
4699        ));
4700        assert!(matches!(
4701            database.check_request("control-failure", &request),
4702            Err(Error::Sqlite(message)) if message.contains("closed")
4703        ));
4704        assert!(matches!(
4705            database.check_sql_requests(&[("control-failure", request.as_slice())]),
4706            Err(Error::Sqlite(message)) if message.contains("closed")
4707        ));
4708
4709        let control_path = control_sidecar_path(&path);
4710        control::begin_pending_query_audit(&control_path);
4711        database.apply_entry(&entry).unwrap();
4712        assert_eq!(control::pending_query_count(&control_path), Some(0));
4713        assert_eq!(database.applied_tip_value().unwrap(), (1, entry.hash));
4714        assert_eq!(
4715            database
4716                .query_sql(
4717                    &SqlStatement {
4718                        sql:
4719                            "SELECT name FROM sqlite_schema WHERE name = 'installed_before_control'"
4720                                .into(),
4721                        parameters: vec![],
4722                    },
4723                    1,
4724                    256,
4725                )
4726                .unwrap()
4727                .rows,
4728            vec![vec![SqlValue::Text("installed_before_control".into())]]
4729        );
4730    }
4731
4732    #[test]
4733    fn pending_commit_failure_keeps_reads_fenced_until_exact_replay_clears_it() {
4734        let dir = tempfile::tempdir().unwrap();
4735        let path = dir.path().join("state.sqlite");
4736        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4737        let change = rhiza_core::ConfigChange::bound_stop(
4738            "cluster-a",
4739            1,
4740            LogHash::ZERO,
4741            2,
4742            vec!["node-1".into(), "node-2".into(), "node-3".into()],
4743        )
4744        .unwrap()
4745        .to_stored_command();
4746        let hash = LogEntry::calculate_hash(
4747            "cluster-a",
4748            1,
4749            1,
4750            1,
4751            change.entry_type,
4752            LogHash::ZERO,
4753            &change.payload,
4754        );
4755        let entry = LogEntry {
4756            cluster_id: "cluster-a".into(),
4757            epoch: 1,
4758            config_id: 1,
4759            index: 1,
4760            entry_type: change.entry_type,
4761            payload: change.payload,
4762            prev_hash: LogHash::ZERO,
4763            hash,
4764        };
4765        arm_qwal_apply_fault(&path, QwalApplyFault::BeforePendingCommit);
4766
4767        assert!(database.apply_entry(&entry).is_err());
4768        assert!(database.pending_fence.load(Ordering::Acquire));
4769        assert!(database.control.pending().unwrap().is_some());
4770        assert!(matches!(
4771            database.get_value("blocked"),
4772            Err(Error::InvalidEntry(message)) if message.contains("pending")
4773        ));
4774
4775        database.apply_entry(&entry).unwrap();
4776        assert!(!database.pending_fence.load(Ordering::Acquire));
4777        assert_eq!(database.control.pending().unwrap(), None);
4778        assert_eq!(database.get_value("unblocked").unwrap(), None);
4779    }
4780
4781    #[cfg(unix)]
4782    #[test]
4783    fn prepared_target_promotion_rejects_a_symlink_to_the_owned_inode() {
4784        use std::os::unix::fs::symlink;
4785
4786        let dir = tempfile::tempdir().unwrap();
4787        let path = dir.path().join("state.sqlite");
4788        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4789        let command = SqlCommand {
4790            request_id: "symlinked-target".into(),
4791            statements: vec![SqlStatement {
4792                sql: "CREATE TABLE safe_fallback(value TEXT NOT NULL)".into(),
4793                parameters: vec![],
4794            }],
4795        };
4796        let request = encode_sql_command(&command).unwrap();
4797        let payload =
4798            prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
4799        let staging_path = database
4800            .prepared_target
4801            .lock()
4802            .unwrap()
4803            .as_ref()
4804            .unwrap()
4805            .artifact
4806            .path()
4807            .to_path_buf();
4808        let backing_path = staging_path.with_extension("owned-inode");
4809        fs::rename(&staging_path, &backing_path).unwrap();
4810        symlink(&backing_path, &staging_path).unwrap();
4811
4812        database
4813            .apply_entry(&command_entry(1, LogHash::ZERO, payload))
4814            .unwrap();
4815
4816        assert_eq!(
4817            prepared_install_path(&path),
4818            Some(PreparedInstallPath::Patched)
4819        );
4820        assert_eq!(
4821            prepared_base_reuse_audit(&path),
4822            Some(PreparedBaseReuseAudit {
4823                second_checkpoint_count: 1,
4824            })
4825        );
4826        assert!(!fs::symlink_metadata(&path)
4827            .unwrap()
4828            .file_type()
4829            .is_symlink());
4830        assert_eq!(
4831            database
4832                .query_sql(
4833                    &SqlStatement {
4834                        sql: "SELECT name FROM sqlite_schema WHERE name = 'safe_fallback'".into(),
4835                        parameters: vec![],
4836                    },
4837                    1,
4838                    1024,
4839                )
4840                .unwrap()
4841                .rows,
4842            vec![vec![SqlValue::Text("safe_fallback".into())]]
4843        );
4844    }
4845
4846    #[cfg(unix)]
4847    #[test]
4848    fn prepared_base_new_inode_is_rejected_by_the_page_state_seal() {
4849        use std::os::unix::fs::MetadataExt;
4850
4851        let dir = tempfile::tempdir().unwrap();
4852        let path = dir.path().join("state.sqlite");
4853        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4854        let command = SqlCommand {
4855            request_id: "new-base-inode".into(),
4856            statements: vec![SqlStatement {
4857                sql: "CREATE TABLE rebuilt_from_new_inode(value INTEGER NOT NULL)".into(),
4858                parameters: vec![],
4859            }],
4860        };
4861        let request = encode_sql_command(&command).unwrap();
4862        let payload =
4863            prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
4864        let original_inode = fs::metadata(&path).unwrap().ino();
4865        {
4866            let _lifecycle = database.lock_lifecycle().unwrap();
4867            database.close_connection().unwrap();
4868            let replacement = path.with_extension("replacement");
4869            fs::copy(&path, &replacement).unwrap();
4870            fs::rename(&replacement, &path).unwrap();
4871        }
4872        assert_ne!(fs::metadata(&path).unwrap().ino(), original_inode);
4873
4874        assert!(database
4875            .apply_entry(&command_entry(1, LogHash::ZERO, payload))
4876            .is_err());
4877        assert_eq!(prepared_install_path(&path), None);
4878    }
4879
4880    #[cfg(unix)]
4881    #[test]
4882    fn prepared_base_symlink_falls_back_without_promoting_through_it() {
4883        use std::os::unix::fs::symlink;
4884
4885        let dir = tempfile::tempdir().unwrap();
4886        let path = dir.path().join("state.sqlite");
4887        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4888        let command = SqlCommand {
4889            request_id: "symlinked-base".into(),
4890            statements: vec![SqlStatement {
4891                sql: "CREATE TABLE rebuilt_from_symlink(value INTEGER NOT NULL)".into(),
4892                parameters: vec![],
4893            }],
4894        };
4895        let request = encode_sql_command(&command).unwrap();
4896        let payload =
4897            prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
4898        let backing = path.with_extension("backing");
4899        {
4900            let _lifecycle = database.lock_lifecycle().unwrap();
4901            database.close_connection().unwrap();
4902            fs::rename(&path, &backing).unwrap();
4903            symlink(&backing, &path).unwrap();
4904        }
4905
4906        assert!(database
4907            .apply_entry(&command_entry(1, LogHash::ZERO, payload))
4908            .is_err());
4909        assert_eq!(prepared_install_path(&path), None);
4910        assert!(fs::symlink_metadata(&path)
4911            .unwrap()
4912            .file_type()
4913            .is_symlink());
4914    }
4915
4916    #[cfg(unix)]
4917    #[test]
4918    fn prepared_base_same_inode_mutation_rejects_rebuildable_apply() {
4919        use std::os::unix::fs::MetadataExt;
4920
4921        let dir = tempfile::tempdir().unwrap();
4922        let path = dir.path().join("state.sqlite");
4923        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4924        let command = SqlCommand {
4925            request_id: "mutated-base".into(),
4926            statements: vec![SqlStatement {
4927                sql: "CREATE TABLE should_not_promote(value INTEGER NOT NULL)".into(),
4928                parameters: vec![],
4929            }],
4930        };
4931        let request = encode_sql_command(&command).unwrap();
4932        let payload =
4933            prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
4934        let entry = command_entry(1, LogHash::ZERO, payload);
4935        let original_inode = fs::metadata(&path).unwrap().ino();
4936        {
4937            let _lifecycle = database.lock_lifecycle().unwrap();
4938            database.close_connection().unwrap();
4939            let mutation = open_connection(&path).unwrap();
4940            mutation
4941                .execute_batch(
4942                    "CREATE TABLE external_mutation(value INTEGER NOT NULL); PRAGMA wal_checkpoint(TRUNCATE);",
4943                )
4944                .unwrap();
4945            mutation.close().unwrap();
4946        }
4947        assert_eq!(fs::metadata(&path).unwrap().ino(), original_inode);
4948
4949        assert!(database.apply_entry(&entry).is_err());
4950
4951        assert_eq!(database.control.pending().unwrap(), None);
4952        assert_ne!(
4953            prepared_install_path(&path),
4954            Some(PreparedInstallPath::Promoted)
4955        );
4956    }
4957
4958    #[cfg(unix)]
4959    #[test]
4960    fn missing_prepared_target_falls_back_to_an_in_place_patch() {
4961        let dir = tempfile::tempdir().unwrap();
4962        let path = dir.path().join("state.sqlite");
4963        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4964        let command = SqlCommand {
4965            request_id: "missing-target".into(),
4966            statements: vec![SqlStatement {
4967                sql: "CREATE TABLE rebuilt_missing_target(value INTEGER NOT NULL)".into(),
4968                parameters: vec![],
4969            }],
4970        };
4971        let request = encode_sql_command(&command).unwrap();
4972        let payload =
4973            prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
4974        let staging_path = database
4975            .prepared_target
4976            .lock()
4977            .unwrap()
4978            .as_ref()
4979            .unwrap()
4980            .artifact
4981            .path()
4982            .to_path_buf();
4983        fs::remove_file(staging_path).unwrap();
4984
4985        database
4986            .apply_entry(&command_entry(1, LogHash::ZERO, payload))
4987            .unwrap();
4988
4989        assert_eq!(
4990            prepared_install_path(&path),
4991            Some(PreparedInstallPath::Patched)
4992        );
4993    }
4994
4995    #[cfg(unix)]
4996    #[test]
4997    fn mutated_prepared_target_falls_back_to_an_in_place_patch() {
4998        let dir = tempfile::tempdir().unwrap();
4999        let path = dir.path().join("state.sqlite");
5000        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
5001        let command = SqlCommand {
5002            request_id: "mutated-target".into(),
5003            statements: vec![SqlStatement {
5004                sql: "CREATE TABLE rebuilt_mutated_target(value INTEGER NOT NULL)".into(),
5005                parameters: vec![],
5006            }],
5007        };
5008        let request = encode_sql_command(&command).unwrap();
5009        let payload =
5010            prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
5011        let effect = decode_qwal_v3(&payload).unwrap();
5012        let staging_path = database
5013            .prepared_target
5014            .lock()
5015            .unwrap()
5016            .as_ref()
5017            .unwrap()
5018            .artifact
5019            .path()
5020            .to_path_buf();
5021        let mutation = open_connection(&staging_path).unwrap();
5022        mutation
5023            .execute_batch(
5024                "CREATE TABLE injected_target(value INTEGER NOT NULL); PRAGMA wal_checkpoint(TRUNCATE);",
5025            )
5026            .unwrap();
5027        mutation.close().unwrap();
5028        assert_ne!(
5029            rebuild_page_state(&staging_path).unwrap().identity(),
5030            effect.target_state
5031        );
5032
5033        database
5034            .apply_entry(&command_entry(1, LogHash::ZERO, payload))
5035            .unwrap();
5036
5037        assert_eq!(
5038            prepared_install_path(&path),
5039            Some(PreparedInstallPath::Patched)
5040        );
5041        assert!(database
5042            .query_sql(
5043                &SqlStatement {
5044                    sql: "SELECT name FROM sqlite_schema WHERE name = 'injected_target'".into(),
5045                    parameters: vec![],
5046                },
5047                1,
5048                1024,
5049            )
5050            .unwrap()
5051            .rows
5052            .is_empty());
5053    }
5054
5055    #[test]
5056    fn stale_prepared_entry_cannot_promote_after_the_tip_moves() {
5057        let dir = tempfile::tempdir().unwrap();
5058        let path = dir.path().join("state.sqlite");
5059        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
5060        let command = SqlCommand {
5061            request_id: "stale-prepared".into(),
5062            statements: vec![SqlStatement {
5063                sql: "CREATE TABLE stale_target(value INTEGER NOT NULL)".into(),
5064                parameters: vec![],
5065            }],
5066        };
5067        let request = encode_sql_command(&command).unwrap();
5068        let payload =
5069            prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
5070        let stale = command_entry(1, LogHash::ZERO, payload);
5071        let noop_hash =
5072            LogEntry::calculate_hash("cluster-a", 1, 1, 1, EntryType::Noop, LogHash::ZERO, &[]);
5073        database
5074            .apply_entry(&LogEntry {
5075                cluster_id: "cluster-a".into(),
5076                epoch: 1,
5077                config_id: 1,
5078                index: 1,
5079                entry_type: EntryType::Noop,
5080                payload: vec![],
5081                prev_hash: LogHash::ZERO,
5082                hash: noop_hash,
5083            })
5084            .unwrap();
5085
5086        assert!(database.apply_entry(&stale).is_err());
5087        assert_ne!(
5088            prepared_install_path(&path),
5089            Some(PreparedInstallPath::Promoted)
5090        );
5091    }
5092
5093    #[test]
5094    fn reopening_forgets_the_prepared_target_and_preserves_fallback_receipts() {
5095        let dir = tempfile::tempdir().unwrap();
5096        let path = dir.path().join("state.sqlite");
5097        let command = SqlCommand {
5098            request_id: "restart-fallback".into(),
5099            statements: vec![SqlStatement {
5100                sql: "CREATE TABLE restarted(value INTEGER NOT NULL)".into(),
5101                parameters: vec![],
5102            }],
5103        };
5104        let request = encode_sql_command(&command).unwrap();
5105        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
5106        let payload =
5107            prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
5108        drop(database);
5109        let database = SqliteStateMachine::open_existing(&path).unwrap();
5110        let entry = command_entry(1, LogHash::ZERO, payload);
5111
5112        let outcome = database.apply_entry_with_result(&entry).unwrap();
5113
5114        assert_eq!(
5115            prepared_install_path(&path),
5116            Some(PreparedInstallPath::Patched)
5117        );
5118        assert_eq!(
5119            prepared_base_reuse_audit(&path),
5120            Some(PreparedBaseReuseAudit {
5121                second_checkpoint_count: 1,
5122            })
5123        );
5124        assert_eq!(
5125            database
5126                .check_sql_request("restart-fallback", &request)
5127                .unwrap()
5128                .unwrap(),
5129            (
5130                RequestOutcome::new(1, entry.hash),
5131                outcome.sql_result().cloned()
5132            )
5133        );
5134    }
5135
5136    #[test]
5137    fn detached_owner_closes_canonical_database_for_strict_reopen() {
5138        let dir = tempfile::tempdir().unwrap();
5139        let path = dir.path().join("state.sqlite");
5140        let command = SqlCommand {
5141            request_id: "handoff".into(),
5142            statements: vec![SqlStatement {
5143                sql: "CREATE TABLE handed_off(value INTEGER NOT NULL)".into(),
5144                parameters: vec![],
5145            }],
5146        };
5147        let request = encode_sql_command(&command).unwrap();
5148        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
5149        let payload =
5150            prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
5151        let entry = command_entry(1, LogHash::ZERO, payload);
5152        database.apply_entry(&entry).unwrap();
5153
5154        database.close_for_handoff().unwrap();
5155
5156        assert!(sqlite_sidecars_absent(&path).unwrap());
5157        let reopened = SqliteStateMachine::open_existing(&path).unwrap();
5158        assert_eq!(
5159            reopened.applied_tip_value().unwrap(),
5160            (entry.index, entry.hash)
5161        );
5162    }
5163
5164    #[test]
5165    fn fenced_reopen_removes_only_empty_wal_sidecars() {
5166        let dir = tempfile::tempdir().unwrap();
5167        let path = dir.path().join("state.sqlite");
5168        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
5169        drop(database);
5170        let wal = sqlite_sidecar_path(&path, "-wal");
5171        let shm = sqlite_sidecar_path(&path, "-shm");
5172        File::create(&wal).unwrap();
5173        File::create(&shm)
5174            .unwrap()
5175            .write_all(b"empty-wal-shm")
5176            .unwrap();
5177
5178        cleanup_empty_wal_sidecars_after_owner_fence(&path).unwrap();
5179
5180        assert!(sqlite_sidecars_absent(&path).unwrap());
5181        SqliteStateMachine::open_existing(&path).unwrap();
5182    }
5183
5184    #[test]
5185    fn replay_after_legacy_pending_with_canonical_base_patches_target_and_receipt() {
5186        let dir = tempfile::tempdir().unwrap();
5187        let path = dir.path().join("state.sqlite");
5188        let command = SqlCommand {
5189            request_id: "pending-base-replay".into(),
5190            statements: vec![
5191                SqlStatement {
5192                    sql: "CREATE TABLE base_recovery(value TEXT NOT NULL)".into(),
5193                    parameters: vec![],
5194                },
5195                SqlStatement {
5196                    sql: "INSERT INTO base_recovery VALUES ('recovered') RETURNING value".into(),
5197                    parameters: vec![],
5198                },
5199            ],
5200        };
5201        let request = encode_sql_command(&command).unwrap();
5202        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
5203        let payload =
5204            prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
5205        let effect = decode_qwal_v3(&payload).unwrap();
5206        let expected_result = decode_sql_result(&effect.receipts[0].result_blob).unwrap();
5207        let entry = command_entry(1, LogHash::ZERO, payload);
5208        let different_command = SqlCommand {
5209            request_id: "different-pending-base".into(),
5210            statements: vec![SqlStatement {
5211                sql: "CREATE TABLE wrong_pending_winner(value INTEGER NOT NULL)".into(),
5212                parameters: vec![],
5213            }],
5214        };
5215        let different_request = encode_sql_command(&different_command).unwrap();
5216        let different_payload = prepare_single_sql_effect(
5217            &database,
5218            &different_command,
5219            &different_request,
5220            0,
5221            LogHash::ZERO,
5222        )
5223        .unwrap();
5224        let different_entry = command_entry(1, LogHash::ZERO, different_payload);
5225        let pending = pending_for(&entry, &effect);
5226        database.control.begin_pending(&pending).unwrap();
5227        let canonical_before_replay = fs::read(&path).unwrap();
5228        drop(database);
5229
5230        let database = SqliteStateMachine::open_existing(&path).unwrap();
5231        assert!(database.pending_fence.load(Ordering::Acquire));
5232        assert!(matches!(
5233            database.get_value("blocked"),
5234            Err(Error::InvalidEntry(message)) if message.contains("pending")
5235        ));
5236        assert!(database
5237            .query_sql(
5238                &SqlStatement {
5239                    sql: "SELECT 1".into(),
5240                    parameters: vec![],
5241                },
5242                1,
5243                64,
5244            )
5245            .is_err());
5246        assert!(database
5247            .check_request("pending-base-replay", &request)
5248            .is_err());
5249        assert!(database.canonical_db_digest().is_err());
5250        assert!(database.create_snapshot(0).is_err());
5251        assert!(database
5252            .prepare_sql_batch_effect(
5253                &[SqlBatchMember {
5254                    command: &command,
5255                    request_payload: &request,
5256                }],
5257                0,
5258                LogHash::ZERO,
5259            )
5260            .is_err());
5261        assert!(matches!(
5262            database.apply_entry_with_result(&different_entry),
5263            Err(Error::InvalidEntry(_))
5264        ));
5265        assert_eq!(fs::read(&path).unwrap(), canonical_before_replay);
5266        assert_eq!(
5267            rebuild_page_state(&path).unwrap().identity(),
5268            effect.base_state
5269        );
5270        assert!(database.pending_fence.load(Ordering::Acquire));
5271        assert!(database.get_value("still-blocked").is_err());
5272        let outcome = database.apply_entry_with_result(&entry).unwrap();
5273
5274        assert!(!database.pending_fence.load(Ordering::Acquire));
5275        assert_eq!(outcome.sql_result(), Some(&expected_result));
5276        assert_eq!(
5277            rebuild_page_state(&path).unwrap().identity(),
5278            effect.target_state
5279        );
5280        assert_eq!(
5281            prepared_install_path(&path),
5282            Some(PreparedInstallPath::Patched)
5283        );
5284        assert_eq!(
5285            prepared_base_reuse_audit(&path),
5286            Some(PreparedBaseReuseAudit {
5287                second_checkpoint_count: 1,
5288            })
5289        );
5290        assert_eq!(
5291            database
5292                .check_sql_request("pending-base-replay", &request)
5293                .unwrap()
5294                .unwrap(),
5295            (RequestOutcome::new(1, entry.hash), Some(expected_result))
5296        );
5297    }
5298
5299    #[test]
5300    fn open_rejects_pending_that_no_longer_extends_the_committed_tip() {
5301        let dir = tempfile::tempdir().unwrap();
5302        let path = dir.path().join("state.sqlite");
5303        let command = SqlCommand {
5304            request_id: "corrupt-pending".into(),
5305            statements: vec![SqlStatement {
5306                sql: "CREATE TABLE corrupt_pending(id INTEGER PRIMARY KEY)".into(),
5307                parameters: vec![],
5308            }],
5309        };
5310        let request = encode_sql_command(&command).unwrap();
5311        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
5312        let payload =
5313            prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
5314        let effect = decode_qwal_v3(&payload).unwrap();
5315        let entry = command_entry(1, LogHash::ZERO, payload);
5316        database
5317            .control
5318            .begin_pending(&pending_for(&entry, &effect))
5319            .unwrap();
5320        drop(database);
5321
5322        Connection::open(control_sidecar_path(&path))
5323            .unwrap()
5324            .execute(
5325                "UPDATE pending_apply SET base_index = 1 WHERE singleton = 1",
5326                [],
5327            )
5328            .unwrap();
5329
5330        assert!(matches!(
5331            SqliteStateMachine::open_existing(&path),
5332            Err(Error::InvalidEntry(_))
5333        ));
5334    }
5335
5336    #[test]
5337    fn replay_after_promoted_target_without_receipt_commits_idempotently() {
5338        let dir = tempfile::tempdir().unwrap();
5339        let path = dir.path().join("state.sqlite");
5340        let command = SqlCommand {
5341            request_id: "pending-target-replay".into(),
5342            statements: vec![
5343                SqlStatement {
5344                    sql: "CREATE TABLE target_recovery(value TEXT NOT NULL)".into(),
5345                    parameters: vec![],
5346                },
5347                SqlStatement {
5348                    sql: "INSERT INTO target_recovery VALUES ('already-promoted') RETURNING value"
5349                        .into(),
5350                    parameters: vec![],
5351                },
5352            ],
5353        };
5354        let request = encode_sql_command(&command).unwrap();
5355        let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
5356        let payload =
5357            prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
5358        let effect = decode_qwal_v3(&payload).unwrap();
5359        let expected_result = decode_sql_result(&effect.receipts[0].result_blob).unwrap();
5360        let entry = command_entry(1, LogHash::ZERO, payload);
5361        let pending = pending_for(&entry, &effect);
5362        database.control.begin_pending(&pending).unwrap();
5363        {
5364            let _lifecycle = database.lock_lifecycle().unwrap();
5365            database.with_connection(checkpoint_truncate).unwrap();
5366            database.close_connection().unwrap();
5367            let prepared = database
5368                .take_matching_prepared_target(&effect, &entry.payload)
5369                .unwrap()
5370                .unwrap();
5371            assert!(database
5372                .promote_prepared_target(&prepared, &effect)
5373                .unwrap());
5374        }
5375        drop(database);
5376
5377        let database = SqliteStateMachine::open_existing(&path).unwrap();
5378        let outcome = database.apply_entry_with_result(&entry).unwrap();
5379
5380        assert_eq!(outcome.sql_result(), Some(&expected_result));
5381        assert_eq!(
5382            rebuild_page_state(&path).unwrap().identity(),
5383            effect.target_state
5384        );
5385        assert_eq!(prepared_install_path(&path), None);
5386        assert_eq!(
5387            database
5388                .check_sql_request("pending-target-replay", &request)
5389                .unwrap()
5390                .unwrap(),
5391            (RequestOutcome::new(1, entry.hash), Some(expected_result))
5392        );
5393    }
5394
5395    fn pending_for(entry: &LogEntry, effect: &QwalEnvelopeV3) -> PendingApply {
5396        PendingApply::new(
5397            LogAnchor::new(effect.base_index, effect.base_hash),
5398            LogAnchor::new(entry.index, entry.hash),
5399            effect.base_state,
5400            effect.target_state,
5401        )
5402    }
5403
5404    fn command_entry(index: u64, prev_hash: LogHash, payload: Vec<u8>) -> LogEntry {
5405        let hash = LogEntry::calculate_hash(
5406            "cluster-a",
5407            index,
5408            1,
5409            1,
5410            EntryType::Command,
5411            prev_hash,
5412            &payload,
5413        );
5414        LogEntry {
5415            cluster_id: "cluster-a".into(),
5416            epoch: 1,
5417            config_id: 1,
5418            index,
5419            entry_type: EntryType::Command,
5420            payload,
5421            prev_hash,
5422            hash,
5423        }
5424    }
5425
5426    #[test]
5427    fn result_decoder_rejects_semantically_oversized_returning_rows() {
5428        let result = SqlCommandResult {
5429            statement_results: vec![SqlStatementResult {
5430                rows_affected: 0,
5431                returning: Some(SqlQueryResult {
5432                    columns: vec!["value".into()],
5433                    rows: (0..=MAX_RETURNING_ROWS)
5434                        .map(|_| vec![SqlValue::Integer(1)])
5435                        .collect(),
5436                }),
5437            }],
5438        };
5439        let mut blob = SQL_RESULT_V1_MAGIC.to_vec();
5440        blob.extend_from_slice(&serde_json::to_vec(&result).unwrap());
5441
5442        assert!(matches!(
5443            decode_sql_result(&blob),
5444            Err(Error::InvalidCommand(_))
5445        ));
5446    }
5447}