1use crate::config::Config;
16use crate::engine::RiskEngine;
17use crate::loader::load_file;
18use crate::parser;
19use crate::types::{ActorKind, GuardAuditLog, GuardDecision, MigrationReport, RiskLevel};
20use chrono::Utc;
21use colored::Colorize;
22use serde_json;
23use std::collections::HashMap;
24use std::io::{self, BufRead, Write};
25use std::path::Path;
26
27#[derive(Debug)]
33pub enum GuardOutcome {
34 Safe,
36 Approved(Vec<GuardDecision>),
38 Blocked {
40 reason: String,
41 operation: String,
42 impact: String,
43 },
44}
45
46impl GuardOutcome {
47 pub fn exit_code(&self) -> i32 {
49 match self {
50 GuardOutcome::Safe => 0,
51 GuardOutcome::Approved(_) => 0,
52 GuardOutcome::Blocked { .. } => 4,
53 }
54 }
55}
56
57pub fn detect_actor() -> ActorKind {
65 if std::env::var("SCHEMARISK_ACTOR")
67 .map(|v| v.to_lowercase() == "agent")
68 .unwrap_or(false)
69 {
70 return ActorKind::Agent;
71 }
72
73 if std::env::var("ANTHROPIC_API_KEY").is_ok()
75 || std::env::var("OPENAI_API_KEY").is_ok()
76 || std::env::var("OPENAI_API_BASE").is_ok()
77 {
78 return ActorKind::Agent;
79 }
80
81 if std::env::var("CI").is_ok()
83 || std::env::var("GITHUB_ACTIONS").is_ok()
84 || std::env::var("GITLAB_CI").is_ok()
85 || std::env::var("CIRCLECI").is_ok()
86 || std::env::var("JENKINS_URL").is_ok()
87 || std::env::var("BUILDKITE").is_ok()
88 {
89 return ActorKind::Ci;
90 }
91
92 ActorKind::Human
93}
94
95pub fn is_guarded_operation(desc: &str, score: u32) -> bool {
103 if score >= 40 {
104 return true;
105 }
106 let upper = desc.to_uppercase();
107 upper.contains("DROP TABLE")
108 || upper.contains("TRUNCATE")
109 || upper.contains("DROP DATABASE")
110 || upper.contains("DROP SCHEMA")
111 || upper.contains("DROP COLUMN")
112 || upper.contains("RENAME COLUMN")
113 || upper.contains("RENAME TO")
114}
115
116fn is_irreversible_operation(desc: &str) -> bool {
117 let upper = desc.to_uppercase();
118 upper.contains("DROP TABLE")
119 || upper.contains("DROP DATABASE")
120 || upper.contains("DROP SCHEMA")
121 || upper.contains("DROP COLUMN")
122 || upper.contains("TRUNCATE")
123}
124
125pub fn render_impact_panel(
131 report: &MigrationReport,
132 op_desc: &str,
133 risk: RiskLevel,
134 score: u32,
135 actor: &ActorKind,
136) {
137 let now = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
138 let divider = "-".repeat(78).dimmed().to_string();
139 let bullet = "•".dimmed();
140
141 let risk_str = match risk {
142 RiskLevel::Critical => "CRITICAL".red().bold().to_string(),
143 RiskLevel::High => "HIGH".truecolor(255, 140, 0).bold().to_string(),
144 RiskLevel::Medium => "MEDIUM".yellow().bold().to_string(),
145 RiskLevel::Low => "LOW".green().bold().to_string(),
146 };
147
148 let lock_type = if score >= 90 || op_desc.to_uppercase().contains("DROP TABLE") {
149 "ACCESS EXCLUSIVE"
150 } else if score >= 50 {
151 "SHARE"
152 } else {
153 "SHARE ROW EXCLUSIVE"
154 };
155 let desc_upper = op_desc.to_uppercase();
156
157 eprintln!();
158 eprintln!("{}", divider);
159 eprintln!("{}", "Dangerous migration operation detected".bold());
160 eprintln!("{}", divider);
161
162 eprintln!(" {} {}", "Operation:".bold(), op_desc);
163 eprintln!(
164 " {} {} {}",
165 "Risk:".bold(),
166 risk_str,
167 format!("(score: {score})").dimmed()
168 );
169 eprintln!(" {} {}", "Lock:".bold(), lock_type);
170
171 if let Some(secs) = report.estimated_lock_seconds {
172 let lock_range = if secs < 5 {
173 "< 5s".to_string()
174 } else if secs < 60 {
175 format!("~{}s", secs)
176 } else {
177 format!("~{}m", secs / 60)
178 };
179 eprintln!(" {} {}", "Estimated lock:".bold(), lock_range);
180 }
181
182 if is_irreversible_operation(op_desc) {
183 eprintln!(
184 "\n {} {}",
185 "Warning:".red().bold(),
186 "This operation is irreversible.".red()
187 );
188 }
189
190 if !report.affected_tables.is_empty() {
191 eprintln!("\n{}", "Database impact".bold());
192 for table in &report.affected_tables {
193 let impact_str = if desc_upper.contains("DROP TABLE") {
194 "DELETED"
195 } else if desc_upper.contains("TRUNCATE") {
196 "TRUNCATED"
197 } else {
198 "MODIFIED"
199 };
200 eprintln!(" {} {:<40} {}", bullet, shorten(table, 40), impact_str);
201 }
202 for fk in &report.fk_impacts {
203 if fk.cascade {
204 eprintln!(
205 " {} {:<40} CASCADE DELETE",
206 bullet,
207 shorten(&fk.from_table, 40)
208 );
209 }
210 }
211 }
212
213 eprintln!("\n{}", "Potential breakage".bold());
214 if desc_upper.contains("DROP TABLE") {
215 eprintln!(" {} All queries to the dropped table will fail", bullet);
216 eprintln!(
217 " {} Foreign keys with CASCADE may delete dependent rows",
218 bullet
219 );
220 eprintln!(
221 " {} Application code referencing this table will break",
222 bullet
223 );
224 } else if desc_upper.contains("DROP COLUMN") || desc_upper.contains("DROP COL") {
225 eprintln!(" {} Queries selecting this column will error", bullet);
226 eprintln!(" {} ORM models referencing this column will break", bullet);
227 } else if desc_upper.contains("RENAME") {
228 eprintln!(
229 " {} Queries using the old name will fail immediately",
230 bullet
231 );
232 eprintln!(
233 " {} Views, procedures, and constraints may need updates",
234 bullet
235 );
236 } else if desc_upper.contains("TRUNCATE") {
237 eprintln!(" {} Existing table data is permanently deleted", bullet);
238 eprintln!(
239 " {} Application behavior may change with empty tables",
240 bullet
241 );
242 } else if desc_upper.contains("ALTER COLUMN") && desc_upper.contains("TYPE") {
243 eprintln!(
244 " {} Table rewrite may block writes during migration",
245 bullet
246 );
247 eprintln!(
248 " {} Data conversion or truncation errors are possible",
249 bullet
250 );
251 } else {
252 eprintln!(
253 " {} Review migration impact carefully before continuing",
254 bullet
255 );
256 }
257
258 if desc_upper.contains("DROP TABLE") {
259 eprintln!("\n{}", "Safer rollout".bold());
260 eprintln!(
261 " {} Rename first (e.g., to *_deprecated), validate traffic, then drop later",
262 bullet
263 );
264 } else if desc_upper.contains("DROP COLUMN") {
265 eprintln!("\n{}", "Safer rollout".bold());
266 eprintln!(" {} Remove app references first", bullet);
267 eprintln!(" {} Deploy application changes", bullet);
268 eprintln!(" {} Drop the column in a follow-up migration", bullet);
269 }
270
271 eprintln!(
272 "\n {} {} {} {}",
273 "Actor:".bold(),
274 actor,
275 "Time:".bold(),
276 now
277 );
278 eprintln!("{}", divider);
279 eprintln!();
280}
281
282fn shorten(s: &str, max: usize) -> String {
283 if s.len() <= max {
284 s.to_string()
285 } else {
286 format!("{}…", &s[..max.saturating_sub(1)])
287 }
288}
289
290fn prompt_confirmation(risk: RiskLevel) -> bool {
300 let (required_phrase, hint) = match risk {
301 RiskLevel::Critical => (
302 "yes i am sure",
303 "Type \"yes I am sure\" to confirm, or press Enter/Ctrl-C to abort: ",
304 ),
305 _ => (
306 "yes",
307 "Type \"yes\" to confirm, or press Enter/Ctrl-C to abort: ",
308 ),
309 };
310
311 eprint!(" {}", hint.yellow().bold());
312 let _ = io::stderr().flush();
313
314 let mut line = String::new();
315 match io::stdin().lock().read_line(&mut line) {
316 Ok(0) | Err(_) => {
317 eprintln!("\n Aborted.");
319 return false;
320 }
321 Ok(_) => {}
322 }
323
324 let trimmed = line.trim().to_lowercase();
325 trimmed == required_phrase
326}
327
328fn agent_blocked(op_desc: &str, impact: &str) -> GuardOutcome {
334 let json = serde_json::json!({
335 "blocked": true,
336 "reason": "CRITICAL operation requires human confirmation",
337 "operation": op_desc,
338 "impact": impact,
339 "required_action": "A human must run: schema-risk guard <file> --interactive"
340 });
341 println!(
342 "{}",
343 serde_json::to_string_pretty(&json).unwrap_or_default()
344 );
345 GuardOutcome::Blocked {
346 reason: "Agent actor — automatic block enforced".to_string(),
347 operation: op_desc.to_string(),
348 impact: impact.to_string(),
349 }
350}
351
352fn write_audit_log(
357 file_path: &str,
358 actor: &ActorKind,
359 decisions: &[GuardDecision],
360 audit_path: &str,
361) {
362 let log = GuardAuditLog {
363 schemarisk_version: env!("CARGO_PKG_VERSION").to_string(),
364 file: file_path.to_string(),
365 timestamp: Utc::now().to_rfc3339(),
366 actor: actor.clone(),
367 decisions: decisions.to_vec(),
368 };
369 match serde_json::to_string_pretty(&log) {
370 Ok(json) => {
371 if let Err(e) = std::fs::write(audit_path, &json) {
372 eprintln!("warning: failed to write audit log to {audit_path}: {e}");
373 } else {
374 eprintln!(
375 "\n {} Confirmation log written to {}",
376 "⚡".cyan(),
377 audit_path.cyan()
378 );
379 }
380 }
381 Err(e) => eprintln!("warning: failed to serialize audit log: {e}"),
382 }
383}
384
385#[derive(Default)]
391pub struct GuardOptions {
392 pub dry_run: bool,
394 pub non_interactive: bool,
396 pub row_counts: HashMap<String, u64>,
398 pub config: Config,
400}
401
402pub fn run_guard(path: &Path, opts: GuardOptions) -> crate::error::Result<GuardOutcome> {
415 let actor = detect_actor();
416 let migration = load_file(path)?;
417 let stmts = parser::parse(&migration.sql)?;
418 let engine = RiskEngine::new(opts.row_counts.clone());
419 let report = engine.analyze(&migration.name, &stmts);
420
421 let guarded_ops: Vec<_> = report
423 .operations
424 .iter()
425 .filter(|op| is_guarded_operation(&op.description, op.score))
426 .collect();
427
428 if guarded_ops.is_empty() {
429 eprintln!(
430 " {} Safe to run — no dangerous operations detected.",
431 "✅".green()
432 );
433 return Ok(GuardOutcome::Safe);
434 }
435
436 if opts.dry_run {
438 for op in &guarded_ops {
439 render_impact_panel(&report, &op.description, op.risk_level, op.score, &actor);
440 }
441 let max_risk = guarded_ops
442 .iter()
443 .map(|o| o.risk_level)
444 .max()
445 .unwrap_or(RiskLevel::Low);
446 let exit_code = match max_risk {
447 RiskLevel::Critical => 2,
448 RiskLevel::High => 1,
449 _ => 0,
450 };
451 if exit_code > 0 {
453 return Ok(GuardOutcome::Blocked {
454 reason: format!("dry-run: {} risk detected", max_risk),
455 operation: guarded_ops[0].description.clone(),
456 impact: format!("{} operations require confirmation", guarded_ops.len()),
457 });
458 }
459 return Ok(GuardOutcome::Safe);
460 }
461
462 if actor == ActorKind::Agent && opts.config.guard.block_agents {
464 let op = &guarded_ops[0];
465 let impact = format!(
466 "{} dangerous operations require human confirmation",
467 guarded_ops.len()
468 );
469 return Ok(agent_blocked(&op.description, &impact));
470 }
471
472 if (actor == ActorKind::Ci || opts.non_interactive) && opts.config.guard.block_ci {
474 for op in &guarded_ops {
475 render_impact_panel(&report, &op.description, op.risk_level, op.score, &actor);
476 }
477 eprintln!(
478 " {} CI mode: dangerous operations blocked. Set block_ci: false to allow.",
479 "⛔".red()
480 );
481 return Ok(GuardOutcome::Blocked {
482 reason: "CI pipeline — non-interactive block".to_string(),
483 operation: guarded_ops[0].description.clone(),
484 impact: format!("{} operations require confirmation", guarded_ops.len()),
485 });
486 }
487
488 if actor == ActorKind::Ci || opts.non_interactive {
490 for op in &guarded_ops {
491 render_impact_panel(&report, &op.description, op.risk_level, op.score, &actor);
492 }
493 eprintln!(
494 " {} Non-interactive mode: cannot prompt. Blocking.",
495 "⛔".red()
496 );
497 return Ok(GuardOutcome::Blocked {
498 reason: "Non-interactive mode — cannot prompt for confirmation".to_string(),
499 operation: guarded_ops[0].description.clone(),
500 impact: format!("{} operations require confirmation", guarded_ops.len()),
501 });
502 }
503
504 let mut decisions: Vec<GuardDecision> = Vec::new();
506
507 for op in &guarded_ops {
508 render_impact_panel(&report, &op.description, op.risk_level, op.score, &actor);
509
510 let irreversible = is_irreversible_operation(&op.description);
511 if irreversible {
512 eprintln!(
513 " {}",
514 "This operation is irreversible. Proceed only with a rollback strategy."
515 .red()
516 .bold()
517 );
518 eprintln!();
519 }
520
521 let confirmed = if opts.config.guard.require_typed_confirmation {
522 prompt_confirmation(op.risk_level)
523 } else {
524 prompt_confirmation(RiskLevel::Medium) };
526
527 let typed_phrase = if confirmed {
528 match op.risk_level {
529 RiskLevel::Critical => Some("yes i am sure".to_string()),
530 _ => Some("yes".to_string()),
531 }
532 } else {
533 None
534 };
535
536 let decision = GuardDecision {
537 operation: op.description.clone(),
538 risk_level: op.risk_level,
539 score: op.score,
540 impact_summary: build_impact_summary(&report, &op.description),
541 confirmed,
542 typed_phrase,
543 timestamp: Utc::now().to_rfc3339(),
544 actor: actor.clone(),
545 };
546
547 if !confirmed {
548 decisions.push(decision);
549 eprintln!(" {} Aborted. Migration will NOT run.", "⛔".red().bold());
550 write_audit_log(
552 &migration.name,
553 &actor,
554 &decisions,
555 &opts.config.guard.audit_log,
556 );
557 return Ok(GuardOutcome::Blocked {
558 reason: "User declined confirmation".to_string(),
559 operation: op.description.clone(),
560 impact: build_impact_summary(&report, &op.description),
561 });
562 }
563
564 decisions.push(decision);
565 eprintln!(" {} Confirmed — proceeding to next check...", "✓".green());
566 }
567
568 eprintln!(
570 "\n {} Proceeding. All {} operation(s) confirmed.",
571 "⚡".cyan(),
572 guarded_ops.len()
573 );
574 write_audit_log(
575 &migration.name,
576 &actor,
577 &decisions,
578 &opts.config.guard.audit_log,
579 );
580
581 Ok(GuardOutcome::Approved(decisions))
582}
583
584fn build_impact_summary(report: &MigrationReport, op_desc: &str) -> String {
586 let tables_str = if report.affected_tables.is_empty() {
587 String::new()
588 } else {
589 format!(
590 " {} table(s): {}",
591 report.affected_tables.len(),
592 report
593 .affected_tables
594 .iter()
595 .take(3)
596 .cloned()
597 .collect::<Vec<_>>()
598 .join(", ")
599 )
600 };
601
602 let cascade_count = report.fk_impacts.iter().filter(|fk| fk.cascade).count();
603 let cascade_str = if cascade_count > 0 {
604 format!(", cascades to {} child table(s)", cascade_count)
605 } else {
606 String::new()
607 };
608
609 format!("{}{}{}", shorten(op_desc, 60), tables_str, cascade_str)
610}
611
612use crate::impact::{ExtractedSql, SqlExtractionReport, SqlExtractor};
617
618#[derive(Default)]
620pub struct CodeGuardOptions {
621 pub base: GuardOptions,
623 pub scan_dir: std::path::PathBuf,
625 pub extensions: Vec<String>,
627}
628
629#[derive(Debug, Clone)]
631pub struct DangerousQuery {
632 pub source: ExtractedSql,
634 pub report: crate::types::MigrationReport,
636 pub guarded_operations: Vec<crate::types::DetectedOperation>,
638}
639
640#[derive(Debug)]
642pub struct CodeGuardReport {
643 pub extraction_report: SqlExtractionReport,
645 pub dangerous_queries: Vec<DangerousQuery>,
647 pub overall_outcome: GuardOutcome,
649 pub stats: CodeGuardStats,
651}
652
653#[derive(Debug, Default, Clone)]
655pub struct CodeGuardStats {
656 pub files_scanned: usize,
658 pub total_sql_found: usize,
660 pub dangerous_count: usize,
662 pub by_context: std::collections::HashMap<String, usize>,
664}
665
666pub fn guard_code_sql(opts: CodeGuardOptions) -> crate::error::Result<CodeGuardReport> {
674 let _actor = detect_actor(); let extractor = SqlExtractor::new();
676 let extraction_report = extractor.scan_directory(&opts.scan_dir);
677
678 let engine = RiskEngine::new(opts.base.row_counts.clone());
679 let mut dangerous_queries: Vec<DangerousQuery> = Vec::new();
680
681 for sql_item in &extraction_report.extracted {
683 let stmts = match parser::parse(&sql_item.sql) {
685 Ok(s) => s,
686 Err(_) => continue, };
688
689 let report = engine.analyze(&sql_item.source_file, &stmts);
691
692 let guarded_ops: Vec<_> = report
694 .operations
695 .iter()
696 .filter(|op| is_guarded_operation(&op.description, op.score))
697 .cloned()
698 .collect();
699
700 if !guarded_ops.is_empty() {
701 dangerous_queries.push(DangerousQuery {
702 source: sql_item.clone(),
703 report,
704 guarded_operations: guarded_ops,
705 });
706 }
707 }
708
709 let stats = CodeGuardStats {
711 files_scanned: extraction_report.files_scanned,
712 total_sql_found: extraction_report.extracted.len(),
713 dangerous_count: dangerous_queries.len(),
714 by_context: extraction_report.by_context.clone(),
715 };
716
717 let overall_outcome = if dangerous_queries.is_empty() {
719 GuardOutcome::Safe
720 } else {
721 GuardOutcome::Blocked {
722 reason: format!(
723 "Found {} dangerous SQL statement(s) in source code",
724 dangerous_queries.len()
725 ),
726 operation: dangerous_queries
727 .first()
728 .map(|d| d.source.sql.chars().take(60).collect())
729 .unwrap_or_default(),
730 impact: format!(
731 "{} file(s) contain dangerous queries",
732 dangerous_queries
733 .iter()
734 .map(|d| &d.source.source_file)
735 .collect::<std::collections::HashSet<_>>()
736 .len()
737 ),
738 }
739 };
740
741 Ok(CodeGuardReport {
742 extraction_report,
743 dangerous_queries,
744 overall_outcome,
745 stats,
746 })
747}
748
749pub fn render_code_guard_report(report: &CodeGuardReport, actor: &ActorKind) {
751 use colored::Colorize;
752
753 let divider = "-".repeat(78).dimmed().to_string();
754
755 eprintln!();
756 eprintln!("{}", divider);
757 eprintln!("{}", "SchemaRisk Code SQL Scanner".bold());
758 eprintln!("{}", divider);
759 eprintln!();
760
761 eprintln!(
762 " {} files scanned, {} SQL statements found",
763 report.stats.files_scanned.to_string().cyan(),
764 report.stats.total_sql_found.to_string().cyan()
765 );
766
767 if !report.stats.by_context.is_empty() {
768 eprintln!();
769 eprintln!(" {} SQL by ORM/Framework:", "ℹ".cyan());
770 for (ctx, count) in &report.stats.by_context {
771 eprintln!(" • {}: {}", ctx, count);
772 }
773 }
774
775 eprintln!();
776
777 if report.dangerous_queries.is_empty() {
778 eprintln!(
779 " {} No dangerous SQL found in source code",
780 "✅".green().bold()
781 );
782 } else {
783 eprintln!(
784 " {} Found {} dangerous SQL statement(s)",
785 "⚠".yellow().bold(),
786 report.dangerous_queries.len()
787 );
788 eprintln!();
789
790 for (idx, dq) in report.dangerous_queries.iter().enumerate() {
791 let risk_str = match dq.report.overall_risk {
792 RiskLevel::Critical => "CRITICAL".red().bold().to_string(),
793 RiskLevel::High => "HIGH".truecolor(255, 140, 0).bold().to_string(),
794 RiskLevel::Medium => "MEDIUM".yellow().bold().to_string(),
795 RiskLevel::Low => "LOW".green().bold().to_string(),
796 };
797
798 eprintln!(" [{}] {} ({})", idx + 1, risk_str, dq.source.context);
799 eprintln!(
800 " File: {}:{}",
801 dq.source.source_file.cyan(),
802 dq.source.line
803 );
804 eprintln!(
805 " SQL: {}",
806 dq.source.sql.chars().take(60).collect::<String>().dimmed()
807 );
808
809 for op in &dq.guarded_operations {
810 eprintln!(" → {}", op.description);
811 }
812 eprintln!();
813 }
814 }
815
816 let now = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
817 eprintln!(
818 " {} {} {} {}",
819 "Actor:".bold(),
820 actor,
821 "Time:".bold(),
822 now
823 );
824 eprintln!("{}", divider);
825 eprintln!();
826}
827
828#[cfg(test)]
833mod tests {
834 use super::*;
835
836 #[test]
837 fn guarded_for_high_score() {
838 assert!(is_guarded_operation(
839 "ALTER TABLE x ALTER COLUMN y TYPE bigint",
840 80
841 ));
842 }
843
844 #[test]
845 fn guarded_for_drop_table_regardless_of_score() {
846 assert!(is_guarded_operation("DROP TABLE sessions", 5));
847 }
848
849 #[test]
850 fn not_guarded_for_create_table() {
851 assert!(!is_guarded_operation("CREATE TABLE new_table", 2));
852 }
853
854 #[test]
855 fn not_guarded_for_low_score_add_column() {
856 assert!(!is_guarded_operation(
857 "ALTER TABLE users ADD COLUMN last_seen timestamptz",
858 5
859 ));
860 }
861
862 #[test]
863 fn agent_detection_via_env() {
864 std::env::remove_var("SCHEMARISK_ACTOR");
866 std::env::remove_var("ANTHROPIC_API_KEY");
867 std::env::remove_var("OPENAI_API_KEY");
868 std::env::remove_var("CI");
869 std::env::remove_var("GITHUB_ACTIONS");
870 let actor = detect_actor();
873 let _ = actor;
875 }
876}