lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 2c967a9d5de11b1985e16a6a001752e11fd1c3f2
parent 1535f4fd89b82c553ce040602fe60bf6a357c19c
Author: triesap <tyson@radroots.org>
Date:   Mon, 20 Jul 2026 21:22:55 +0000

storage: verify durable WAL activation

- validate the outbox main journal mode before migration
- reject successful non-WAL results through typed errors
- verify signer journal-mode result cardinality and value
- cover immutable refusal, file reopen, and memory modes

Diffstat:
Mcrates/nostr_signer/src/error.rs | 11+++++++++++
Mcrates/nostr_signer/src/sqlite.rs | 91++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mcrates/outbox/src/error.rs | 3+++
Mcrates/outbox/src/store.rs | 47++++++++++++++++++++++++++++++++++++++++++++---
4 files changed, 134 insertions(+), 18 deletions(-)

diff --git a/crates/nostr_signer/src/error.rs b/crates/nostr_signer/src/error.rs @@ -45,6 +45,17 @@ pub enum RadrootsNostrSignerError { #[error("publish workflow not found: {0}")] PublishWorkflowNotFound(String), + + #[error("SQLite signer journal-mode query returned {actual_rows} rows; expected exactly one")] + SqliteJournalModeResultCardinality { actual_rows: usize }, + + #[error( + "SQLite signer connection did not enter `{expected}` journal mode; reported `{actual}`" + )] + SqliteJournalModeMismatch { + expected: &'static str, + actual: String, + }, } impl From<radroots_runtime::RuntimeJsonError> for RadrootsNostrSignerError { diff --git a/crates/nostr_signer/src/sqlite.rs b/crates/nostr_signer/src/sqlite.rs @@ -1,8 +1,14 @@ use crate::error::RadrootsNostrSignerError; use crate::migrations; use radroots_sql_core::{SqlExecutor, SqlxSqliteExecutor}; +use serde::Deserialize; use std::path::Path; +#[derive(Deserialize)] +struct SqliteJournalModeRow { + journal_mode: String, +} + pub struct RadrootsNostrSignerSqliteDb { executor: SqlxSqliteExecutor, file_backed: bool, @@ -66,20 +72,41 @@ impl RadrootsNostrSignerSqliteDb { PRAGMA temp_store = MEMORY;" }; let _ = self.executor.exec(pragma_batch, "[]")?; - if self.file_backed { - let _ = self.executor.query_raw("PRAGMA journal_mode = WAL", "[]")?; + let (journal_mode_sql, expected_journal_mode) = if self.file_backed { + ("PRAGMA main.journal_mode = WAL", "wal") } else { - let _ = self - .executor - .query_raw("PRAGMA journal_mode = MEMORY", "[]")?; - } - Ok(()) + ("PRAGMA main.journal_mode = MEMORY", "memory") + }; + let result = self.executor.query_raw(journal_mode_sql, "[]")?; + validate_journal_mode_result(&result, expected_journal_mode) } } +fn validate_journal_mode_result( + result: &str, + expected: &'static str, +) -> Result<(), RadrootsNostrSignerError> { + let rows: Vec<SqliteJournalModeRow> = serde_json::from_str(result)?; + let [row] = rows.as_slice() else { + return Err( + RadrootsNostrSignerError::SqliteJournalModeResultCardinality { + actual_rows: rows.len(), + }, + ); + }; + if row.journal_mode != expected { + return Err(RadrootsNostrSignerError::SqliteJournalModeMismatch { + expected, + actual: row.journal_mode.clone(), + }); + } + Ok(()) +} + #[cfg(test)] mod tests { - use super::RadrootsNostrSignerSqliteDb; + use super::{RadrootsNostrSignerSqliteDb, validate_journal_mode_result}; + use crate::error::RadrootsNostrSignerError; use radroots_sql_core::SqlExecutor; use serde_json::Value; @@ -113,6 +140,10 @@ mod tests { fn open_memory_bootstraps_schema_and_migrations_idempotently() { let db = RadrootsNostrSignerSqliteDb::open_memory().expect("open memory db"); db.migrate_up().expect("rerun migrations"); + assert_eq!( + query_single_text(&db, "PRAGMA main.journal_mode", "journal_mode"), + "memory" + ); let tables = query_values( &db, @@ -187,17 +218,47 @@ mod tests { #[test] fn file_database_uses_wal_and_foreign_keys() { let temp = tempfile::tempdir().expect("tempdir"); - let db = RadrootsNostrSignerSqliteDb::open(temp.path().join("signer.sqlite")) - .expect("open sqlite file db"); + let path = temp.path().join("signer.sqlite"); + { + let db = RadrootsNostrSignerSqliteDb::open(&path).expect("open sqlite file db"); + assert_eq!( + query_single_text(&db, "PRAGMA main.journal_mode", "journal_mode"), + "wal" + ); + assert_eq!( + query_single_i64(&db, "PRAGMA foreign_keys", "foreign_keys"), + 1 + ); + } + + let reopened = RadrootsNostrSignerSqliteDb::open(&path).expect("reopen sqlite file db"); assert_eq!( - query_single_text(&db, "PRAGMA journal_mode", "journal_mode"), + query_single_text(&reopened, "PRAGMA main.journal_mode", "journal_mode"), "wal" ); - assert_eq!( - query_single_i64(&db, "PRAGMA foreign_keys", "foreign_keys"), - 1 - ); + } + + #[test] + fn journal_mode_result_validation_fails_closed() { + assert!(matches!( + validate_journal_mode_result(r#"[{"journal_mode":"delete"}]"#, "wal"), + Err(RadrootsNostrSignerError::SqliteJournalModeMismatch { + expected: "wal", + actual, + }) if actual == "delete" + )); + assert!(matches!( + validate_journal_mode_result("[]", "wal"), + Err(RadrootsNostrSignerError::SqliteJournalModeResultCardinality { actual_rows: 0 }) + )); + assert!(matches!( + validate_journal_mode_result( + r#"[{"journal_mode":"wal"},{"journal_mode":"delete"}]"#, + "wal" + ), + Err(RadrootsNostrSignerError::SqliteJournalModeResultCardinality { actual_rows: 2 }) + )); } #[test] diff --git a/crates/outbox/src/error.rs b/crates/outbox/src/error.rs @@ -43,6 +43,9 @@ pub enum RadrootsOutboxError { )] SqlitePoolBackingMismatch { file_backed: bool, filename: String }, + #[error("SQLite outbox file connection did not enter WAL journal mode; reported `{actual}`")] + SqliteFileJournalModeNotWal { actual: String }, + #[error( "trade mutation outbox metadata does not match the canonical mutation content: {field}" )] diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs @@ -35,6 +35,8 @@ use radroots_transport::{ use serde::Serialize; use sha2::{Digest, Sha256}; use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteQueryResult}; +#[cfg(test)] +use sqlx::{Connection, SqliteConnection}; use sqlx::{Row, SqlitePool}; use std::collections::BTreeSet; use std::path::Path; @@ -97,7 +99,7 @@ impl RadrootsOutbox { } pub async fn pragma_journal_mode(&self) -> Result<String, RadrootsOutboxError> { - query_string(&self.pool, "PRAGMA journal_mode").await + query_string(&self.pool, "PRAGMA main.journal_mode").await } pub async fn status_summary( @@ -1843,14 +1845,40 @@ async fn configure_pool(pool: &SqlitePool, file_backed: bool) -> Result<(), Radr .execute(&mut **connection) .await?; if file_backed { - sqlx::query("PRAGMA journal_mode = WAL") - .execute(&mut **connection) + let actual = sqlx::query_scalar::<_, String>("PRAGMA main.journal_mode = WAL") + .fetch_one(&mut **connection) .await?; + if actual != "wal" { + return Err(RadrootsOutboxError::SqliteFileJournalModeNotWal { actual }); + } } } Ok(()) } +#[cfg(test)] +async fn file_pool_with_immutable_delete_journal(path: &Path) -> SqlitePool { + let mut writer = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true), + ) + .await + .expect("writer connection"); + let mode: String = sqlx::query_scalar("PRAGMA main.journal_mode = DELETE") + .fetch_one(&mut writer) + .await + .expect("delete journal mode"); + assert_eq!(mode, "delete"); + writer.close().await.expect("close writer"); + + SqlitePoolOptions::new() + .max_connections(1) + .connect_with(SqliteConnectOptions::new().filename(path).immutable(true)) + .await + .expect("immutable pool") +} + #[cfg_attr(coverage_nightly, coverage(off))] async fn apply_up(pool: &SqlitePool) -> Result<(), RadrootsOutboxError> { sqlx::raw_sql(OUTBOX_MIGRATION_UP).execute(pool).await?; @@ -4219,6 +4247,19 @@ mod tests { } #[tokio::test] + async fn file_pool_rejects_successful_non_wal_journal_result() { + let directory = tempfile::tempdir().expect("tempdir"); + let path = directory.path().join("immutable-delete.sqlite"); + let pool = file_pool_with_immutable_delete_journal(&path).await; + + assert!(matches!( + RadrootsOutbox::open_pool(pool, true).await, + Err(RadrootsOutboxError::SqliteFileJournalModeNotWal { actual }) + if actual == "delete" + )); + } + + #[tokio::test] async fn constructors_preflight_and_transactional_enqueue_cover_public_storage_surfaces() { let directory = tempfile::tempdir().expect("tempdir"); let file_outbox = RadrootsOutbox::open_file(directory.path().join("outbox.sqlite"))