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:
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"))