fix(store): preserve audit tables and real TLS modes during salvage #270
@@ -6,6 +6,11 @@ use std::path::{Path, PathBuf};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Raw row shape for an account configuration record fetched from SQLite during salvage.
|
||||
/// The trailing two `String` columns are the raw `imap_tls_mode`/`smtp_tls_mode` text values
|
||||
/// (e.g. `"start_tls"`) -- carried through as the literal on-disk token rather than already
|
||||
/// parsed to [`nuncio_core::TlsMode`], so [`SqliteRecoveryEngine::salvage_accounts`] can log
|
||||
/// precisely which raw value it could not recognize, if any, instead of a decode failure
|
||||
/// having already been silently absorbed by this row type.
|
||||
type SalvagedAccountRow = (
|
||||
String,
|
||||
String,
|
||||
@@ -18,6 +23,8 @@ type SalvagedAccountRow = (
|
||||
i64,
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
String,
|
||||
String,
|
||||
);
|
||||
|
||||
/// Summary report of database self-healing recovery output.
|
||||
@@ -33,6 +40,10 @@ pub struct RecoverySummary {
|
||||
pub salvaged_conditions_count: usize,
|
||||
/// Number of salvaged filter action specifications.
|
||||
pub salvaged_actions_count: usize,
|
||||
/// Number of salvaged WORM audit ledger records.
|
||||
pub salvaged_audit_records_count: usize,
|
||||
/// Number of salvaged filter execution log entries.
|
||||
pub salvaged_execution_logs_count: usize,
|
||||
/// Indicates whether remote protocol resynchronization was initiated.
|
||||
pub resync_triggered: bool,
|
||||
}
|
||||
@@ -191,21 +202,41 @@ impl SqliteRecoveryEngine {
|
||||
|
||||
// Step 2: Attempt reading valid records from preserved backup file
|
||||
let backup_url = format!("sqlite://{}", backup_path.to_string_lossy());
|
||||
let (salvaged_accounts, salvaged_rules, salvaged_conditions, salvaged_actions) = if let Ok(
|
||||
pool,
|
||||
) =
|
||||
sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(&backup_url)
|
||||
.await
|
||||
let (
|
||||
salvaged_accounts,
|
||||
salvaged_rules,
|
||||
salvaged_conditions,
|
||||
salvaged_actions,
|
||||
salvaged_audit_records,
|
||||
salvaged_execution_logs,
|
||||
) = if let Ok(pool) = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(&backup_url)
|
||||
.await
|
||||
{
|
||||
let accounts = Self::salvage_accounts(&pool).await;
|
||||
let (rules, conditions, actions) = Self::salvage_filter_tables(&pool).await;
|
||||
let audit_records = Self::salvage_worm_audit_records(&pool).await;
|
||||
let execution_logs = Self::salvage_filter_execution_logs(&pool).await;
|
||||
pool.close().await;
|
||||
(accounts, rules, conditions, actions)
|
||||
(
|
||||
accounts,
|
||||
rules,
|
||||
conditions,
|
||||
actions,
|
||||
audit_records,
|
||||
execution_logs,
|
||||
)
|
||||
} else {
|
||||
warn!("Failed to open backup connection for salvage; proceeding with clean database reset.");
|
||||
(Vec::new(), Vec::new(), Vec::new(), Vec::new())
|
||||
(
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
)
|
||||
};
|
||||
|
||||
// Step 3: Remove or truncate damaged database files at target_db_path
|
||||
@@ -287,12 +318,62 @@ impl SqliteRecoveryEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the WORM audit ledger and filter execution log ledger verbatim, including
|
||||
// their hash-chain columns (`record_hmac`/`hash`, `previous_block_hash`/`prev_hash`).
|
||||
// These are re-signed with the same HMAC key material the corrupted database used
|
||||
// (both engines are provisioned from the same `secrets` vault), so a chain that
|
||||
// verified before corruption still verifies after salvage -- salvage restores the
|
||||
// ledger's tamper-evidence, it does not merely copy rows that happen to look right.
|
||||
let mut restored_audit_records_count = 0;
|
||||
for (sequence, timestamp_ns, actor, action, data_hash, previous_block_hash, record_hmac) in
|
||||
&salvaged_audit_records
|
||||
{
|
||||
let res = sqlx::query(
|
||||
"INSERT INTO worm_audit_records (sequence, timestamp_ns, actor, action, data_hash, previous_block_hash, record_hmac) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(sequence)
|
||||
.bind(timestamp_ns)
|
||||
.bind(actor)
|
||||
.bind(action)
|
||||
.bind(data_hash)
|
||||
.bind(previous_block_hash)
|
||||
.bind(record_hmac)
|
||||
.execute(fresh_engine.pool())
|
||||
.await;
|
||||
if res.is_ok() {
|
||||
restored_audit_records_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut restored_execution_logs_count = 0;
|
||||
for (id, rule_id, message_id, action_taken, matched_at, prev_hash, hash) in
|
||||
&salvaged_execution_logs
|
||||
{
|
||||
let res = sqlx::query(
|
||||
"INSERT INTO filter_execution_logs (id, rule_id, message_id, action_taken, matched_at, prev_hash, hash) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(id)
|
||||
.bind(rule_id)
|
||||
.bind(message_id)
|
||||
.bind(action_taken)
|
||||
.bind(matched_at)
|
||||
.bind(prev_hash)
|
||||
.bind(hash)
|
||||
.execute(fresh_engine.pool())
|
||||
.await;
|
||||
if res.is_ok() {
|
||||
restored_execution_logs_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let summary = RecoverySummary {
|
||||
backup_path,
|
||||
salvaged_accounts_count: restored_accounts_count,
|
||||
salvaged_rules_count: restored_rules_count,
|
||||
salvaged_conditions_count: restored_conditions_count,
|
||||
salvaged_actions_count: restored_actions_count,
|
||||
salvaged_audit_records_count: restored_audit_records_count,
|
||||
salvaged_execution_logs_count: restored_execution_logs_count,
|
||||
resync_triggered: true,
|
||||
};
|
||||
|
||||
@@ -302,7 +383,7 @@ impl SqliteRecoveryEngine {
|
||||
|
||||
async fn salvage_accounts(pool: &sqlx::SqlitePool) -> Vec<nuncio_core::AccountConfig> {
|
||||
let rows: Result<Vec<SalvagedAccountRow>, _> = sqlx::query_as(
|
||||
"SELECT id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs, smtp_host, smtp_port FROM accounts"
|
||||
"SELECT id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs, smtp_host, smtp_port, imap_tls_mode, smtp_tls_mode FROM accounts"
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await;
|
||||
@@ -323,6 +404,8 @@ impl SqliteRecoveryEngine {
|
||||
sync_interval_secs,
|
||||
smtp_host,
|
||||
smtp_port,
|
||||
imap_tls_mode_raw,
|
||||
smtp_tls_mode_raw,
|
||||
)| {
|
||||
let protocol = serde_json::from_str(&protocol_str)
|
||||
.unwrap_or(nuncio_core::AccountProtocol::ImapSmtp);
|
||||
@@ -335,6 +418,10 @@ impl SqliteRecoveryEngine {
|
||||
let resolved_smtp_host = smtp_host.unwrap_or_else(|| server_host.clone());
|
||||
let resolved_smtp_port =
|
||||
smtp_port.map(|p| p as u16).unwrap_or(server_port as u16);
|
||||
let imap_tls_mode =
|
||||
Self::parse_salvaged_tls_mode(&id, "imap_tls_mode", &imap_tls_mode_raw);
|
||||
let smtp_tls_mode =
|
||||
Self::parse_salvaged_tls_mode(&id, "smtp_tls_mode", &smtp_tls_mode_raw);
|
||||
nuncio_core::AccountConfig {
|
||||
id,
|
||||
name,
|
||||
@@ -345,8 +432,8 @@ impl SqliteRecoveryEngine {
|
||||
smtp_host: resolved_smtp_host,
|
||||
smtp_port: resolved_smtp_port,
|
||||
use_tls: use_tls != 0,
|
||||
imap_tls_mode: nuncio_core::TlsMode::ImplicitTls,
|
||||
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
|
||||
imap_tls_mode,
|
||||
smtp_tls_mode,
|
||||
keyring_secret_key,
|
||||
sync_interval_secs: sync_interval_secs as u64,
|
||||
}
|
||||
@@ -357,6 +444,64 @@ impl SqliteRecoveryEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a salvaged account's raw `imap_tls_mode`/`smtp_tls_mode` column text back into
|
||||
/// [`nuncio_core::TlsMode`], preserving the account's real configured security posture
|
||||
/// (implicit TLS, STARTTLS, or plaintext) rather than hardcoding every salvaged account
|
||||
/// back to implicit TLS regardless of how it was actually configured -- silently
|
||||
/// upgrading a STARTTLS account or downgrading a plaintext one to "implicit TLS" on
|
||||
/// recovery would misrepresent the account's security posture to the operator.
|
||||
///
|
||||
/// A raw value that does not match one of the three known on-disk tokens (a row from a
|
||||
/// build that predates this column, or a distinct new corruption) cannot be honestly
|
||||
/// recovered. Rather than silently guessing at one of the other two modes, this WARN-logs
|
||||
/// the account id and the unrecognized raw value, and falls back to the safest mode
|
||||
/// (implicit TLS) -- the operator can see in the daemon's logs exactly which account
|
||||
/// needs its TLS mode re-confirmed, instead of a quiet, invisible substitution.
|
||||
fn parse_salvaged_tls_mode(account_id: &str, column: &str, raw: &str) -> nuncio_core::TlsMode {
|
||||
match raw {
|
||||
"implicit_tls" => nuncio_core::TlsMode::ImplicitTls,
|
||||
"start_tls" => nuncio_core::TlsMode::StartTls,
|
||||
"plain" => nuncio_core::TlsMode::Plain,
|
||||
other => {
|
||||
warn!(
|
||||
"Salvage could not recognize account {account_id}'s {column} value {other:?}; \
|
||||
defaulting to ImplicitTls and flagging for operator review rather than \
|
||||
silently guessing its prior TLS mode."
|
||||
);
|
||||
nuncio_core::TlsMode::ImplicitTls
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read back valid rows from `worm_audit_records` in the backup connection, preserving
|
||||
/// every column including the hash-chain fields (`previous_block_hash`/`record_hmac`) so
|
||||
/// the restored ledger's tamper-evidence carries through salvage rather than being
|
||||
/// silently dropped.
|
||||
async fn salvage_worm_audit_records(
|
||||
pool: &sqlx::SqlitePool,
|
||||
) -> Vec<(i64, i64, String, String, String, String, String)> {
|
||||
sqlx::query_as::<_, (i64, i64, String, String, String, String, String)>(
|
||||
"SELECT sequence, timestamp_ns, actor, action, data_hash, previous_block_hash, record_hmac FROM worm_audit_records ORDER BY sequence ASC"
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read back valid rows from `filter_execution_logs` in the backup connection, preserving
|
||||
/// every column including the hash-chain fields (`prev_hash`/`hash`) so the restored
|
||||
/// ledger's tamper-evidence carries through salvage rather than being silently dropped.
|
||||
async fn salvage_filter_execution_logs(
|
||||
pool: &sqlx::SqlitePool,
|
||||
) -> Vec<(i64, String, String, String, i64, String, String)> {
|
||||
sqlx::query_as::<_, (i64, String, String, String, i64, String, String)>(
|
||||
"SELECT id, rule_id, message_id, action_taken, matched_at, prev_hash, hash FROM filter_execution_logs ORDER BY id ASC"
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read back valid rows from `filter_rules` / `filter_conditions` / `filter_actions` in the
|
||||
/// backup connection, using column names/order matching the live schema (see
|
||||
/// `DatabaseEngine::migrate`). `filter_conditions.value`/`filter_actions.target` are the
|
||||
@@ -558,6 +703,131 @@ mod tests {
|
||||
assert_eq!(rules[0].name, "Spam Filter");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn salvage_preserves_worm_audit_and_filter_execution_log_chains() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let db_path = dir.path().join("ledger_salvage_test.db");
|
||||
let backup_dir = dir.path().join("corrupted_backups");
|
||||
let secrets = crate::vault::SecretManager::mock();
|
||||
|
||||
// Step 1: Seed a WORM audit record and a filter execution log entry, each of which
|
||||
// is only valid because it is cryptographically hash-chained to its predecessor.
|
||||
{
|
||||
let engine = DatabaseEngine::connect_file(&db_path, &secrets)
|
||||
.await
|
||||
.unwrap();
|
||||
engine
|
||||
.append_worm_audit_record("system.test", "account.created", b"payload-1")
|
||||
.await
|
||||
.unwrap();
|
||||
engine
|
||||
.append_worm_audit_record("system.test", "account.updated", b"payload-2")
|
||||
.await
|
||||
.unwrap();
|
||||
engine
|
||||
.save_filter_execution_log("rule-1", "msg-1", "delete")
|
||||
.await
|
||||
.unwrap();
|
||||
engine
|
||||
.save_filter_execution_log("rule-1", "msg-2", "archive")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(engine.verify_worm_audit_chain().await.is_ok());
|
||||
assert!(engine.verify_execution_log_chain().await.unwrap());
|
||||
}
|
||||
|
||||
// Step 2: Perform salvage recovery, reusing the same secrets vault -- the WORM/ledger
|
||||
// HMAC keys are provisioned from `secrets`, so the restored ledgers verify against
|
||||
// the same key material the corrupted database used, exactly as in the real recovery
|
||||
// flow (the daemon always re-provisions salvage from the account's existing vault).
|
||||
let summary = SqliteRecoveryEngine::salvage(&db_path, &db_path, &backup_dir, &secrets)
|
||||
.await
|
||||
.expect("salvage succeeds");
|
||||
|
||||
assert_eq!(
|
||||
summary.salvaged_audit_records_count, 2,
|
||||
"salvage must carry worm_audit_records through recovery instead of silently \
|
||||
dropping the tamper-evident audit ledger"
|
||||
);
|
||||
assert_eq!(
|
||||
summary.salvaged_execution_logs_count, 2,
|
||||
"salvage must carry filter_execution_logs through recovery instead of silently \
|
||||
dropping the filter ledger"
|
||||
);
|
||||
|
||||
// Step 3: The restored ledgers must still be present AND still pass hash-chain
|
||||
// verification -- proving the chain-linking columns (`previous_block_hash`/
|
||||
// `record_hmac`, `prev_hash`/`hash`) were preserved verbatim, not just the row data.
|
||||
let fresh = DatabaseEngine::connect_file(&db_path, &secrets)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let audit_records = fresh.list_worm_audit_records(100, 0).await.unwrap();
|
||||
assert_eq!(audit_records.len(), 2);
|
||||
assert!(
|
||||
fresh.verify_worm_audit_chain().await.is_ok(),
|
||||
"restored WORM audit chain must still verify after salvage"
|
||||
);
|
||||
|
||||
let execution_logs = fresh.list_filter_execution_logs(100).await.unwrap();
|
||||
assert_eq!(execution_logs.len(), 2);
|
||||
assert!(
|
||||
fresh.verify_execution_log_chain().await.unwrap(),
|
||||
"restored filter execution log chain must still verify after salvage"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn salvage_preserves_real_tls_mode_instead_of_downgrading_to_implicit() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let db_path = dir.path().join("tls_mode_salvage_test.db");
|
||||
let backup_dir = dir.path().join("corrupted_backups");
|
||||
let secrets = crate::vault::SecretManager::mock();
|
||||
|
||||
// Step 1: Seed an account configured for STARTTLS on IMAP and plaintext on SMTP --
|
||||
// deliberately neither is `ImplicitTls`, so a salvage that hardcodes the TLS mode
|
||||
// back to `ImplicitTls` is caught by asserting the real modes survive.
|
||||
{
|
||||
let engine = DatabaseEngine::connect_file(&db_path, &secrets)
|
||||
.await
|
||||
.unwrap();
|
||||
let acct = AccountConfig {
|
||||
id: "acct-starttls-1".to_string(),
|
||||
name: "STARTTLS Account".to_string(),
|
||||
email_address: "starttls@nuncio.mx".to_string(),
|
||||
protocol: nuncio_core::AccountProtocol::ImapSmtp,
|
||||
server_host: "imap.nuncio.mx".to_string(),
|
||||
server_port: 143,
|
||||
smtp_host: "smtp.nuncio.mx".to_string(),
|
||||
smtp_port: 587,
|
||||
use_tls: true,
|
||||
imap_tls_mode: nuncio_core::TlsMode::StartTls,
|
||||
smtp_tls_mode: nuncio_core::TlsMode::Plain,
|
||||
keyring_secret_key: "nuncio/acct-starttls-1".to_string(),
|
||||
sync_interval_secs: 60,
|
||||
};
|
||||
engine.save_account(&acct).await.unwrap();
|
||||
}
|
||||
|
||||
// Step 2: Perform salvage recovery.
|
||||
let summary = SqliteRecoveryEngine::salvage(&db_path, &db_path, &backup_dir, &secrets)
|
||||
.await
|
||||
.expect("salvage succeeds");
|
||||
assert_eq!(summary.salvaged_accounts_count, 1);
|
||||
|
||||
// Step 3: The salvaged account must keep its real configured TLS modes, not be
|
||||
// silently reset to `ImplicitTls` -- that would misrepresent the account's actual,
|
||||
// intentionally-configured security posture (STARTTLS on IMAP, plaintext on SMTP).
|
||||
let fresh = DatabaseEngine::connect_file(&db_path, &secrets)
|
||||
.await
|
||||
.unwrap();
|
||||
let accounts = fresh.list_accounts().await.unwrap();
|
||||
assert_eq!(accounts.len(), 1);
|
||||
assert_eq!(accounts[0].imap_tls_mode, nuncio_core::TlsMode::StartTls);
|
||||
assert_eq!(accounts[0].smtp_tls_mode, nuncio_core::TlsMode::Plain);
|
||||
}
|
||||
|
||||
// NOTE: there is deliberately no test combining *header* corruption (bytes 0-16 -- the
|
||||
// fixed "SQLite format 3\0" magic string) with a nonzero salvaged-rule-count assertion.
|
||||
// A corrupted header makes the file entirely unopenable by SQLite (`SQLITE_NOTADB`), by
|
||||
|
||||
Reference in new issue
Block a user