Skip to main content

cortex_store/
lib.rs

1//! Persistence layer for SQLite migrations and repositories.
2#![warn(missing_docs)]
3
4use std::path::Path;
5
6use cortex_core::{CoreError, CoreResult};
7use rusqlite::{Connection, OpenFlags};
8
9pub mod migrate;
10pub mod migrate_v2;
11pub mod mirror;
12pub mod proof;
13pub mod repo;
14pub mod semantic_diff;
15pub mod verify;
16
17pub use proof::{temporal_authority_proof_report, verify_memory_proof_closure};
18pub use semantic_diff::{
19    semantic_snapshot_from_store, ArtifactKind, ContradictionState, DoctrineForce, DoctrineState,
20    KeyLifecycleState, KeyState, MemoryKind, MemoryState, PrincipalTrustState, PrincipleState,
21    ProofState as SemanticProofState, RestoreDecision, RuntimeMode as SemanticRuntimeMode,
22    SalienceDistribution, SalienceScore, SemanticChange, SemanticChangeKind, SemanticDiff,
23    SemanticSeverity, SemanticSnapshot, TrustKeyState, TrustTier, TruthCeiling, TruthCeilingState,
24    TruthState,
25};
26
27/// Initial SQLite schema for the MVP store (LANES T-2.A.1).
28pub const INITIAL_MIGRATION_SQL: &str = include_str!("../migrations/001_init.sql");
29
30/// Synchronous SQLite connection used by the store crate.
31pub type Pool = Connection;
32
33/// Crate-wide store result type.
34pub type StoreResult<T> = Result<T, StoreError>;
35
36/// Errors raised by the SQLite store boundary.
37#[derive(Debug, thiserror::Error)]
38pub enum StoreError {
39    /// SQLite operation failed.
40    #[error("sqlite error: {0}")]
41    Sqlite(#[from] rusqlite::Error),
42    /// JSON encoding or decoding failed.
43    #[error("json error: {0}")]
44    Json(#[from] serde_json::Error),
45    /// Timestamp parsing failed.
46    #[error("time parse error: {0}")]
47    Time(#[from] chrono::ParseError),
48    /// Filesystem operation failed.
49    #[error("io error: {0}")]
50    Io(#[from] std::io::Error),
51    /// Core type validation failed.
52    #[error("core error: {0}")]
53    Core(#[from] CoreError),
54    /// Store-specific validation failed.
55    #[error("validation failed: {0}")]
56    Validation(String),
57    /// Post-migrate row-count verification refused because
58    /// `pre.events.checked_add(SCHEMA_V1_TO_V2_EVENT_BOUNDARY_DELTA)` overflowed
59    /// `u64`. A pre-migrate count at the saturation boundary is itself a bug;
60    /// the verifier refuses honestly rather than masking the overflow with
61    /// `saturating_add`. Surfaces stable invariant
62    /// `verify.row_counts.checked_add_overflow` (RED_TEAM_FINDINGS phase B,
63    /// finding B3).
64    #[error(
65        "verify.row_counts.checked_add_overflow: table {table} pre-migrate row count {pre} plus boundary-append delta {delta} overflows u64"
66    )]
67    RowCountCheckedAddOverflow {
68        /// Counted table that overflowed (currently always `events`).
69        table: &'static str,
70        /// Pre-migrate row count from the backup manifest.
71        pre: u64,
72        /// Documented v1 -> v2 boundary-append delta that triggered overflow.
73        delta: u64,
74    },
75}
76
77impl StoreError {
78    /// Returns the stable invariant name for this error variant when one is
79    /// defined. Stable invariant names are how operators and tests match on
80    /// refusal classes without parsing free text.
81    #[must_use]
82    pub fn invariant(&self) -> Option<&'static str> {
83        match self {
84            StoreError::RowCountCheckedAddOverflow { .. } => {
85                Some(VERIFY_ROW_COUNTS_CHECKED_ADD_OVERFLOW_INVARIANT)
86            }
87            _ => None,
88        }
89    }
90}
91
92/// Stable invariant name surfaced by [`StoreError::RowCountCheckedAddOverflow`].
93///
94/// RED_TEAM_FINDINGS phase B, finding B3: a pre-migrate `events` count plus the
95/// documented v1 -> v2 boundary-append delta that overflows `u64` is itself an
96/// upstream bug. The verifier refuses with this stable invariant rather than
97/// `saturating_add`-ing back to `u64::MAX` and silently agreeing with a store
98/// that happens to share that count.
99pub const VERIFY_ROW_COUNTS_CHECKED_ADD_OVERFLOW_INVARIANT: &str =
100    "verify.row_counts.checked_add_overflow";
101
102/// Opens the SQLite database under `data_dir` and applies pending migrations.
103pub fn open(data_dir: &Path) -> StoreResult<Pool> {
104    std::fs::create_dir_all(data_dir)?;
105    let pool = Connection::open(data_dir.join("cortex.sqlite3"))?;
106    verify_sqlite_load_extension_disabled(&pool)?;
107    migrate::apply_pending(&pool)?;
108    Ok(pool)
109}
110
111/// Returns SQLite compile-time options reported by the active library.
112pub fn sqlite_compile_options(pool: &Pool) -> StoreResult<Vec<String>> {
113    let mut stmt = pool.prepare("PRAGMA compile_options;")?;
114    let options = stmt
115        .query_map([], |row| row.get(0))?
116        .collect::<Result<Vec<String>, _>>()?;
117    Ok(options)
118}
119
120/// Fails closed when SQLite was compiled with loadable-extension support.
121pub fn verify_sqlite_compile_options<I, S>(compile_options: I) -> StoreResult<()>
122where
123    I: IntoIterator<Item = S>,
124    S: AsRef<str>,
125{
126    for option in compile_options {
127        if is_load_extension_enabled_compile_option(option.as_ref()) {
128            return Err(StoreError::Validation(format!(
129                "sqlite compile option {option} is forbidden; Cortex refuses SQLite builds with loadable extension support",
130                option = option.as_ref()
131            )));
132        }
133    }
134    Ok(())
135}
136
137fn is_load_extension_enabled_compile_option(option: &str) -> bool {
138    let normalized = option
139        .trim()
140        .strip_prefix("SQLITE_")
141        .unwrap_or(option.trim());
142    let key = normalized
143        .split_once('=')
144        .map_or(normalized, |(key, _)| key);
145    key.eq_ignore_ascii_case("ENABLE_LOAD_EXTENSION")
146}
147
148/// Verifies SQL-level extension loading is not authorized on this connection.
149///
150/// The pinned bundled `libsqlite3-sys` build advertises
151/// `ENABLE_LOAD_EXTENSION`, but `rusqlite` does not enable its
152/// `load_extension` feature in this workspace and SQLite keeps SQL extension
153/// loading disabled by default. Cortex checks that runtime state before
154/// migrations instead of accepting compile options as proof of active authority.
155pub fn verify_sqlite_load_extension_disabled(pool: &Pool) -> StoreResult<()> {
156    match pool.query_row(
157        "SELECT load_extension('cortex_extension_loading_must_remain_disabled');",
158        [],
159        |row| row.get::<_, String>(0),
160    ) {
161        Ok(_) => Err(StoreError::Validation(
162            "sqlite load_extension unexpectedly executed".to_string(),
163        )),
164        Err(err) if sqlite_load_extension_is_disabled_error(&err) => Ok(()),
165        Err(err) => Err(StoreError::Validation(format!(
166            "sqlite load_extension preflight returned an unexpected error: {err}"
167        ))),
168    }
169}
170
171fn sqlite_load_extension_is_disabled_error(err: &rusqlite::Error) -> bool {
172    let message = err.to_string().to_ascii_lowercase();
173    message.contains("not authorized") || message.contains("no such function: load_extension")
174}
175
176/// Opens an existing SQLite database read-only.
177///
178/// Restore verification uses this to inspect candidate backup state without
179/// applying migrations or otherwise changing the artifact under review.
180pub fn open_existing_readonly(path: &Path) -> StoreResult<Pool> {
181    Ok(Connection::open_with_flags(
182        path,
183        OpenFlags::SQLITE_OPEN_READ_ONLY,
184    )?)
185}
186
187/// Compatibility stub retained for callers not yet moved to [`open`].
188pub fn open_stub(data_dir: &Path) -> CoreResult<()> {
189    open(data_dir).map_err(|err| CoreError::Validation(err.to_string()))?;
190    Ok(())
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn initial_migration_runs_cleanly_on_empty_db() {
199        let pool = Connection::open_in_memory().expect("open in-memory sqlite");
200        pool.execute_batch(INITIAL_MIGRATION_SQL)
201            .expect("initial migration runs");
202
203        let mut stmt = pool
204            .prepare(
205                "SELECT name FROM sqlite_master \
206                 WHERE type = 'table' \
207                 ORDER BY name;",
208            )
209            .expect("prepare table query");
210        let actual: Vec<String> = stmt
211            .query_map([], |row| row.get(0))
212            .expect("list tables")
213            .collect::<Result<_, _>>()
214            .expect("read table rows");
215        let expected = [
216            "audit_records",
217            "context_packs",
218            "contradictions",
219            "doctrine",
220            "episodes",
221            "events",
222            "memories",
223            "principles",
224            "trace_events",
225            "traces",
226        ];
227        assert_eq!(actual, expected);
228    }
229}