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