Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 133 additions & 1 deletion src/cli/generate/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ pub struct Generate {
#[arg(long)]
pub dry_run: bool,

/// Skip confirmation prompt for destructive changes
/// Skip confirmation prompts and integrity verification (dangerous)
#[arg(long)]
pub force: bool,

Expand Down Expand Up @@ -161,6 +161,11 @@ impl Generate {
let backend = load_backend(self.path.as_deref());
ensure_backend_initialized(&backend).await?;

// Verify migration chain integrity (unless --force)
if !self.force {
self.verify_chain_integrity(&backend).await?;
}

// Load the source state (current state from migrations)
let source = backend.get_current_state().await.into_diagnostic()?;
let source_hash = StateHash::from_namespace(&source);
Expand Down Expand Up @@ -302,6 +307,37 @@ impl Generate {

Ok(())
}

/// Verifies the integrity of the local migration chain.
///
/// Checks that each migration's parent_state_hash matches the previous
/// migration's resulting_state_hash.
async fn verify_chain_integrity<B: StateBackend>(&self, backend: &B) -> miette::Result<()> {
use crate::db::state::StateError;

match backend.verify_chain().await {
Ok(()) => Ok(()),
Err(StateError::BrokenChain {
id,
parent,
expected,
}) => Err(miette::miette!(
"Migration chain integrity check failed\n\n\
Migration {} has an invalid parent hash.\n\n\
Expected parent: {}...\n\
Actual parent: {}...\n\n\
This indicates the migration file was modified or corrupted.\n\n\
To resolve:\n \
Option 1: Restore the migration from version control\n \
Option 2: Run 'tern verify chain' for detailed diagnostics\n \
Option 3: Use --force to skip verification (dangerous)",
&id.to_hex()[..16.min(id.to_hex().len())],
&expected.to_hex()[..16.min(expected.to_hex().len())],
&parent.to_hex()[..16.min(parent.to_hex().len())],
)),
Err(e) => Err(miette::miette!("Failed to verify migration chain: {}", e)),
}
}
}

/// Prompts the user to confirm destructive changes.
Expand Down Expand Up @@ -380,4 +416,100 @@ mod tests {
assert!(json.contains("\"migration_id\":\"abc\""));
assert!(json.contains("\"description\":\"Test\""));
}

mod chain_verification_tests {
use crate::db::model::Namespace;
use crate::db::state::{InMemoryBackend, Migration, StateBackend, StateHash};

use super::Generate;

#[tokio::test]
async fn verify_chain_integrity_passes_for_valid_chain() {
let backend = InMemoryBackend::new();
backend.initialize().await.unwrap();

let ns = Namespace::empty("public");
let m1 = Migration::baseline(ns);
backend.save_migration(&m1).await.unwrap();

let m2 = Migration::new(
"Second",
vec![],
vec![],
m1.resulting_state_hash,
StateHash::from_bytes([22u8; 32]),
vec![],
);
backend.save_migration(&m2).await.unwrap();

let generate = Generate {
description: "Test".to_string(),
schema: None,
path: None,
dry_run: false,
force: false,
format: crate::cli::OutputFormat::Text,
};

// Should pass without error
let result = generate.verify_chain_integrity(&backend).await;
assert!(result.is_ok());
}

#[tokio::test]
async fn verify_chain_integrity_fails_for_broken_chain() {
let backend = InMemoryBackend::new();
backend.initialize().await.unwrap();

let ns = Namespace::empty("public");
let m1 = Migration::baseline(ns);
backend.save_migration(&m1).await.unwrap();

// Create a migration with wrong parent hash
let m2 = Migration::new(
"Second",
vec![],
vec![],
StateHash::from_bytes([99u8; 32]), // Wrong parent hash
StateHash::from_bytes([22u8; 32]),
vec![],
);
backend.save_migration(&m2).await.unwrap();

let generate = Generate {
description: "Test".to_string(),
schema: None,
path: None,
dry_run: false,
force: false,
format: crate::cli::OutputFormat::Text,
};

// Should fail with error message
let result = generate.verify_chain_integrity(&backend).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Migration chain integrity check failed"));
assert!(err.contains("invalid parent hash"));
}

#[tokio::test]
async fn verify_chain_integrity_passes_for_empty_history() {
let backend = InMemoryBackend::new();
backend.initialize().await.unwrap();

let generate = Generate {
description: "Test".to_string(),
schema: None,
path: None,
dry_run: false,
force: false,
format: crate::cli::OutputFormat::Text,
};

// Empty history should pass (nothing to verify)
let result = generate.verify_chain_integrity(&backend).await;
assert!(result.is_ok());
}
}
}
Loading