use std::path::Path;
use super::super::super::backup_table_policy;
use super::super::{RecoveryReadSnapshot, RestoreInventory};
use super::{HistoryExpectation, recovery_error, storage_error};
use crate::db::{DatabaseConfig, DbConnection};
use crate::models::DomainError;
impl HistoryExpectation {
pub(in crate::core::backup) fn verify_before_publication(
&self,
path: &Path,
inventory: &RestoreInventory,
) -> Result<(), DomainError> {
let db = DbConnection::open(DatabaseConfig::read_only_file(path.to_path_buf()))
.map_err(storage_error)?;
let result = (|| {
let snapshot = RecoveryReadSnapshot::begin(&db)?;
verify_storage_graph(&db)?;
inventory.verify_rows(&db)?;
self.verify_connection(&db)?;
snapshot.finish()
})();
let closed = db.close().map(|_| ()).map_err(storage_error);
result.and(closed)
}
}
fn verify_storage_graph(db: &DbConnection) -> Result<(), DomainError> {
for table in db.list_user_tables().map_err(storage_error)? {
if !backup_table_policy(&table).schema_covered() {
return Err(recovery_error(
"Restored database contains an unsupported recovery table; the restored store was not published",
));
}
}
let integrity = db.check_foreign_keys().map_err(storage_error)?;
if !integrity.passed || !integrity.violations.is_empty() {
return Err(recovery_error(
"Restored database contains broken foreign-key relationships; the restored store was not published",
));
}
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::*;
use crate::core::backup::{
BackupCreateOptions, BackupRestoreOptions, BackupVerifyOptions, create_backup,
restore_backup_to_side_path, restore_backup_to_side_path_with_recovery_hooks,
verify_backup,
};
use crate::models::RedactionLevel;
use std::path::PathBuf;
type TestResult = Result<(), String>;
fn error_text(error: impl std::fmt::Display) -> String {
error.to_string()
}
fn read_storage_graph(path: &Path) -> Result<(), DomainError> {
let db = DbConnection::open(DatabaseConfig::read_only_file(path.to_path_buf()))
.map_err(storage_error)?;
let result = (|| {
let snapshot = RecoveryReadSnapshot::begin(&db)?;
verify_storage_graph(&db)?;
snapshot.finish()
})();
let closed = db.close().map(|_| ()).map_err(storage_error);
result.and(closed)
}
fn backup_fixture() -> Result<(tempfile::TempDir, PathBuf, BackupRestoreOptions), String> {
let (root, workspace, database) =
crate::core::backup::tests::fixture().map_err(error_text)?;
let backup = create_backup(&BackupCreateOptions {
workspace_path: workspace.clone(),
database_path: Some(database.clone()),
output_dir: None,
label: None,
redaction_level: RedactionLevel::Standard,
include_derived: false,
include_graph_cache: false,
dry_run: false,
})
.map_err(error_text)?;
assert!(backup.recovery_inventory.snapshot_coverage_complete);
let options = BackupRestoreOptions {
workspace_path: workspace,
backup_path: PathBuf::from(backup.backup_path),
side_path: root
.path()
.canonicalize()
.map_err(error_text)?
.join("restored"),
restore_graph_cache: false,
dry_run: false,
};
Ok((root, database, options))
}
#[test]
fn migrated_store_passes_the_read_only_relational_check() -> TestResult {
let (_root, _workspace, database) =
crate::core::backup::tests::fixture().map_err(error_text)?;
read_storage_graph(&database).map_err(error_text)?;
let db = DbConnection::open_file(&database).map_err(error_text)?;
assert!(db.check_foreign_keys().map_err(error_text)?.passed);
db.close().map_err(error_text)
}
#[test]
fn orphaned_tag_is_rejected_even_when_row_counts_are_unchanged() -> TestResult {
let (_root, _workspace, database) =
crate::core::backup::tests::fixture().map_err(error_text)?;
let db = DbConnection::open_file(&database).map_err(error_text)?;
let before = db.count_table_rows("memory_tags").map_err(error_text)?;
assert!(before > 0, "the corruption must change a real tag row");
db.execute_raw("PRAGMA foreign_keys = OFF")
.map_err(error_text)?;
db.execute_raw("UPDATE memory_tags SET memory_id = 'mem_00000000000000000000009999'")
.map_err(error_text)?;
assert_eq!(
before,
db.count_table_rows("memory_tags").map_err(error_text)?
);
assert!(!db.check_foreign_keys().map_err(error_text)?.passed);
db.close().map_err(error_text)?;
let error = read_storage_graph(&database).expect_err("dangling tag must be rejected");
assert!(
error
.to_string()
.contains("broken foreign-key relationships")
);
assert!(!error.to_string().contains("00009999"));
let db = DbConnection::open_file(&database).map_err(error_text)?;
assert_eq!(
before,
db.count_table_rows("memory_tags").map_err(error_text)?
);
assert!(!db.check_foreign_keys().map_err(error_text)?.passed);
db.close().map_err(error_text)
}
#[test]
fn unsupported_empty_table_is_not_hidden_by_complete_counts() -> TestResult {
let (_root, _workspace, database) =
crate::core::backup::tests::fixture().map_err(error_text)?;
let db = DbConnection::open_file(&database).map_err(error_text)?;
db.execute_raw("CREATE TABLE private_recovery_schema_canary (id TEXT PRIMARY KEY)")
.map_err(error_text)?;
assert!(db.check_foreign_keys().map_err(error_text)?.passed);
db.close().map_err(error_text)?;
let error = read_storage_graph(&database).expect_err("unknown schema must be rejected");
assert!(error.to_string().contains("unsupported recovery table"));
assert!(!error.to_string().contains("private_recovery_schema_canary"));
Ok(())
}
#[test]
fn relational_check_does_not_release_its_callers_snapshot() -> TestResult {
let (_root, _workspace, database) =
crate::core::backup::tests::fixture().map_err(error_text)?;
let db =
DbConnection::open(DatabaseConfig::read_only_file(database)).map_err(error_text)?;
let snapshot = RecoveryReadSnapshot::begin(&db).map_err(error_text)?;
verify_storage_graph(&db).map_err(error_text)?;
assert!(
db.begin_read_snapshot().is_err(),
"the caller still owns the snapshot"
);
snapshot.finish().map_err(error_text)?;
let next = RecoveryReadSnapshot::begin(&db).map_err(error_text)?;
next.finish().map_err(error_text)?;
db.close().map_err(error_text)
}
#[test]
fn complete_restore_still_rebuilds_and_publishes_a_valid_storage_graph() -> TestResult {
let (_root, _database, options) = backup_fixture()?;
let restored = restore_backup_to_side_path(&options).map_err(error_text)?;
assert!(options.side_path.join(".ee").is_dir());
read_storage_graph(Path::new(&restored.restored_database_path)).map_err(error_text)
}
#[test]
fn schema_drift_after_rebuild_prevents_publication_and_preserves_the_backup() -> TestResult {
let (_root, database, options) = backup_fixture()?;
let error = restore_backup_to_side_path_with_recovery_hooks(
&options,
|_| Ok(()),
|path| {
let db = DbConnection::open_file(path).map_err(storage_error)?;
db.execute_raw("CREATE TABLE private_recovery_schema_canary (id TEXT PRIMARY KEY)")
.map_err(storage_error)?;
db.close().map_err(storage_error)
},
)
.expect_err("an unclassified staged table must prevent publication");
assert!(error.to_string().contains("unsupported recovery table"));
assert!(!error.to_string().contains("private_recovery_schema_canary"));
assert!(!options.side_path.join(".ee").exists());
read_storage_graph(&database).map_err(error_text)?;
let verified = verify_backup(&BackupVerifyOptions {
workspace_path: options.workspace_path.clone(),
backup_path: options.backup_path.clone(),
})
.map_err(error_text)?;
assert_eq!(verified.status, "verified");
let mut retry = options;
retry.side_path.set_file_name("restored-retry");
let restored = restore_backup_to_side_path(&retry).map_err(error_text)?;
read_storage_graph(Path::new(&restored.restored_database_path)).map_err(error_text)
}
}