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