1use crate::_internal::analysis::evidence::EvidenceLocation;
2use crate::_internal::analysis::mutations::Mutation;
3use crate::_internal::analysis::outcome::AnalysisOutcome;
4use crate::_internal::analysis::resolver::Resolver;
5use crate::_internal::analysis::state::{AnalysisState, PreState};
6use crate::_internal::ast::visitor::AstVisitor;
7use crate::_internal::engine::config::Config;
8use crate::_internal::report::violations::{ReportFinding, SourceLocation, Violation};
9use crate::_internal::rules::registry;
10use crate::_internal::rules::{Rule, RuleContext};
11use squawk_syntax::{
12 Parse, SyntaxKind,
13 ast::{AstNode, SourceFile},
14};
15use std::collections::HashSet;
16
17enum StatementCheckpoint {
18 Full(Option<Box<AnalysisState>>),
19 TransactionUndo {
20 transaction_depth: usize,
21 undo_len: usize,
22 },
23}
24
25impl StatementCheckpoint {
26 fn capture(state: &AnalysisState, mutations: &[Mutation]) -> Self {
27 let changes_transaction_structure = mutations.iter().any(|mutation| {
28 matches!(
29 mutation,
30 Mutation::BeginTransaction
31 | Mutation::CommitTransaction
32 | Mutation::CommitAndChain
33 | Mutation::RollbackTransaction
34 | Mutation::RollbackAndChain
35 | Mutation::RollbackToSavepoint(_)
36 | Mutation::Savepoint(_)
37 | Mutation::ReleaseSavepoint(_)
38 )
39 });
40 if !changes_transaction_structure
41 && let Some((transaction_depth, undo_len)) = state.transaction_undo_checkpoint()
42 {
43 Self::TransactionUndo {
44 transaction_depth,
45 undo_len,
46 }
47 } else {
48 Self::Full(Some(Box::new(state.clone())))
49 }
50 }
51
52 fn restore(&mut self, state: &mut AnalysisState) -> Result<(), String> {
53 match self {
54 Self::Full(checkpoint) => {
55 let Some(checkpoint) = checkpoint.take() else {
56 return Err("statement checkpoint was already restored".to_string());
57 };
58 *state = *checkpoint;
59 Ok(())
60 }
61 Self::TransactionUndo {
62 transaction_depth,
63 undo_len,
64 } => state
65 .rollback_to_transaction_undo_checkpoint(*transaction_depth, *undo_len)
66 .map_err(str::to_string),
67 }
68 }
69}
70
71pub struct SafeMigrateEngine {
72 config: Config,
73 rules: Vec<Box<dyn Rule>>,
74}
75
76impl SafeMigrateEngine {
77 pub fn new(config: Config) -> Self {
78 Self {
79 config,
80 rules: registry::build_primary_rules(),
81 }
82 }
83
84 pub fn primary_rule_ids(&self) -> Vec<&'static str> {
86 registry::primary_rule_ids().collect()
87 }
88
89 pub fn analyze_chain(
90 &self,
91 files: &[(String, String)],
92 state: &mut AnalysisState,
93 ) -> Result<Vec<Violation>, Vec<String>> {
94 let mut all_violations = Vec::new();
95 for (filename, sql) in files {
96 let violations = self.analyze_single_file(filename, sql, state)?;
97 all_violations.extend(violations);
98 }
99 all_violations.sort_by(|a, b| {
101 a.tier
102 .cmp(&b.tier)
103 .then_with(|| match (&a.source_range, &b.source_range) {
104 (Some(ar), Some(br)) => ar
105 .start()
106 .cmp(&br.start())
107 .then_with(|| ar.end().cmp(&br.end())),
108 (Some(_), None) => std::cmp::Ordering::Less,
109 (None, Some(_)) => std::cmp::Ordering::Greater,
110 (None, None) => std::cmp::Ordering::Equal,
111 })
112 .then_with(|| a.object_name.cmp(&b.object_name))
113 .then_with(|| a.rule_id.cmp(b.rule_id))
114 });
115 Ok(all_violations)
116 }
117
118 pub fn analyze(
119 &self,
120 sql: &str,
121 state: &mut AnalysisState,
122 ) -> Result<Vec<Violation>, Vec<String>> {
123 self.analyze_chain(&[("<inline>".to_string(), sql.to_string())], state)
124 }
125
126 pub fn analyze_chain_with_locations(
130 &self,
131 files: &[(String, String)],
132 state: &mut AnalysisState,
133 ) -> Result<Vec<ReportFinding>, Vec<String>> {
134 let mut findings = Vec::new();
135
136 for (file_index, (filename, sql)) in files.iter().enumerate() {
137 let normalized_sql = Self::normalize_execute(sql);
138 let parsed = SourceFile::parse(&normalized_sql);
139 let statement_ranges: Vec<_> = parsed
140 .tree()
141 .stmts()
142 .map(|statement| statement.syntax().text_range())
143 .collect();
144 let violations = self.analyze_parsed_file(filename, &normalized_sql, &parsed, state)?;
145 findings.extend(
146 violations
147 .into_iter()
148 .map(|violation| ReportFinding {
149 location: Self::source_location(
150 filename,
151 &normalized_sql,
152 violation.source_range,
153 ),
154 statement_index: violation.source_range.and_then(|range| {
155 statement_ranges
156 .iter()
157 .position(|statement| statement.contains_range(range))
158 .map(|index| index + 1)
159 }),
160 violation,
161 })
162 .map(|finding| (file_index, finding)),
163 );
164 }
165
166 findings.sort_by(|(a_index, a), (b_index, b)| {
167 a.violation
168 .tier
169 .cmp(&b.violation.tier)
170 .then_with(|| a_index.cmp(b_index))
171 .then_with(|| match (&a.location, &b.location) {
172 (Some(a_location), Some(b_location)) => a_location
173 .line
174 .cmp(&b_location.line)
175 .then_with(|| a_location.column.cmp(&b_location.column)),
176 (Some(_), None) => std::cmp::Ordering::Less,
177 (None, Some(_)) => std::cmp::Ordering::Greater,
178 (None, None) => std::cmp::Ordering::Equal,
179 })
180 .then_with(|| a.violation.object_name.cmp(&b.violation.object_name))
181 .then_with(|| a.violation.rule_id.cmp(b.violation.rule_id))
182 });
183
184 Ok(findings.into_iter().map(|(_, finding)| finding).collect())
185 }
186
187 pub fn analyze_with_locations(
188 &self,
189 filename: String,
190 sql: String,
191 state: &mut AnalysisState,
192 ) -> Result<Vec<ReportFinding>, Vec<String>> {
193 self.analyze_chain_with_locations(&[(filename, sql)], state)
194 }
195
196 pub fn analyze_chain_outcome_with_locations(
199 &self,
200 files: &[(String, String)],
201 state: &mut AnalysisState,
202 ) -> Result<AnalysisOutcome<ReportFinding>, Vec<String>> {
203 let findings = self.analyze_chain_with_locations(files, state)?;
204 Ok(AnalysisOutcome::new(
205 findings,
206 state.confidence().clone(),
207 state.evidence().to_vec(),
208 ))
209 }
210
211 pub fn analyze_outcome_with_locations(
214 &self,
215 filename: String,
216 sql: String,
217 state: &mut AnalysisState,
218 ) -> Result<AnalysisOutcome<ReportFinding>, Vec<String>> {
219 self.analyze_chain_outcome_with_locations(&[(filename, sql)], state)
220 }
221
222 fn analyze_single_file(
223 &self,
224 filename: &str,
225 sql: &str,
226 state: &mut AnalysisState,
227 ) -> Result<Vec<Violation>, Vec<String>> {
228 let sql = Self::normalize_execute(sql);
229 self.analyze_normalized_file(filename, &sql, state)
230 }
231
232 fn analyze_normalized_file(
233 &self,
234 filename: &str,
235 sql: &str,
236 state: &mut AnalysisState,
237 ) -> Result<Vec<Violation>, Vec<String>> {
238 let parsed = SourceFile::parse(sql);
239 self.analyze_parsed_file(filename, sql, &parsed, state)
240 }
241
242 fn analyze_parsed_file(
243 &self,
244 filename: &str,
245 sql: &str,
246 parsed: &Parse<SourceFile>,
247 state: &mut AnalysisState,
248 ) -> Result<Vec<Violation>, Vec<String>> {
249 let errors: Vec<String> = parsed.errors().iter().map(|e| e.to_string()).collect();
250 if !errors.is_empty() {
251 return Err(errors);
252 }
253
254 let mut all_violations = Vec::new();
255 let mut warned_keys = HashSet::new();
256 let mut pre_state = PreState::default();
257
258 let mut file_ignores = HashSet::new();
259 for token in parsed
260 .tree()
261 .syntax()
262 .descendants_with_tokens()
263 .filter_map(|it| it.into_token())
264 .filter(|token| token.kind() == SyntaxKind::COMMENT)
265 {
266 let mut dummy = HashSet::new();
267 Self::parse_directives(token.text(), &mut file_ignores, &mut dummy);
268 }
269
270 for (statement_offset, stmt) in parsed.tree().stmts().enumerate() {
271 state.set_evidence_location(Some(EvidenceLocation {
272 file: filename.to_string(),
273 statement_index: statement_offset + 1,
274 }));
275 let mut stmt_ignores = HashSet::new();
276
277 let mut prev = stmt.syntax().prev_sibling_or_token();
278 while let Some(element) = prev {
279 if element.as_node().is_some() {
280 break;
281 }
282 if let Some(token) = element.as_token()
283 && token.kind() == SyntaxKind::COMMENT
284 {
285 let mut dummy = HashSet::new();
286 Self::parse_directives(token.text(), &mut dummy, &mut stmt_ignores);
287 }
288 prev = element.prev_sibling_or_token();
289 }
290
291 for token in stmt
292 .syntax()
293 .descendants_with_tokens()
294 .filter_map(|it| it.into_token())
295 .filter(|token| token.kind() == SyntaxKind::COMMENT)
296 {
297 let mut dummy = HashSet::new();
298 Self::parse_directives(token.text(), &mut dummy, &mut stmt_ignores);
299 }
300
301 let stmt_text = Self::strip_sql_leading_comments(&stmt.syntax().text().to_string());
303
304 let statement_confidence = state.confidence().clone();
311 let mut statement_violations = Vec::new();
312 let mut statement_warned_keys = HashSet::new();
313 let mut mutations = match AstVisitor::extract(&stmt) {
314 Some(fact) => Resolver::resolve(&fact, state),
315 None => vec![Mutation::Opaque(
316 crate::_internal::analysis::mutations::OpaqueMutation::UnsupportedStatement,
317 )],
318 };
319 if squawk_linter::analyze::possibly_slow_stmt(&stmt) {
320 mutations.push(Mutation::CheckTimeouts);
321 }
322 let mut statement_checkpoint = StatementCheckpoint::capture(state, &mutations);
323
324 for mutation in mutations {
325 let pre_cascade = match &mutation {
326 Mutation::DropTable(d) if d.cascade => {
327 Some(state.cascade_for_relations(&d.ids))
328 }
329 _ => None,
330 };
331
332 state.capture_pre_state_into(&mut pre_state);
333 let result = state.apply(&mutation, pre_cascade.as_ref());
334
335 let statement_failed = matches!(
336 result,
337 crate::_internal::analysis::state::MutationResult::Conflict { .. }
338 );
339 if statement_failed {
340 let transaction_aborted = state.transaction_is_aborted();
341 if let Err(error) = statement_checkpoint.restore(state) {
342 return Err(vec![format!(
343 "failed to restore PostgreSQL statement atomicity: {error}"
344 )]);
345 }
346 if transaction_aborted && state.in_transaction() {
347 state.mark_transaction_aborted();
348 }
349 statement_violations.clear();
350 statement_warned_keys.clear();
351 }
352
353 if result == crate::_internal::analysis::state::MutationResult::NotExecuted {
354 continue;
355 }
356
357 for rule in &self.rules {
358 if file_ignores.contains(rule.id())
359 || stmt_ignores.contains(rule.id())
360 || self.config.is_rule_disabled(rule.id())
361 {
362 continue;
363 }
364
365 let rule_context = RuleContext::new(
366 &mutation,
367 &result,
368 &pre_state,
369 state,
370 &self.config,
371 pre_cascade.as_ref(),
372 );
373 let violations = rule.evaluate(&rule_context);
374
375 if !violations.is_empty() {
381 for capability in rule.required_capabilities() {
382 if !capability.available_for(state, &mutation, &pre_state) {
383 let code = if state.baseline_is_available() {
384 capability.evidence_code()
385 } else {
386 crate::_internal::analysis::evidence::EvidenceCode::BaselineUnavailable
387 };
388 state.taint(
389 code,
390 crate::_internal::analysis::evidence::EvidenceScope::Statement,
391 );
392 }
393 }
394 }
395
396 for v in violations {
397 if let Some(key) = &v.dedup_key
398 && (warned_keys.contains(key)
399 || !statement_warned_keys.insert(key.clone()))
400 {
401 continue;
402 }
403 let mut v = v;
404 if v.source_range.is_none() {
405 let start = stmt
406 .syntax()
407 .descendants_with_tokens()
408 .filter_map(|element| element.into_token())
409 .find(|token| {
410 let text = token.text().trim();
411 !text.is_empty()
412 && !text.starts_with("--")
413 && !text.starts_with("/*")
414 })
415 .map(|token| token.text_range().start())
416 .unwrap_or_else(|| stmt.syntax().text_range().start());
417 let end = stmt.syntax().text_range().end();
418 v.source_range = Some(rowan::TextRange::new(start, end));
419 }
420 if v.sql.is_none() {
421 if let Some(range) = v.source_range {
422 let start = usize::from(range.start());
423 let end = usize::from(range.end());
424 if start < sql.len() && end <= sql.len() {
425 v.sql = Some(sql[start..end].trim().to_string());
426 } else {
427 v.sql = Some(stmt_text.trim().to_string());
428 }
429 } else {
430 v.sql = Some(stmt_text.trim().to_string());
431 }
432 }
433 if statement_confidence
436 == crate::_internal::analysis::state::Confidence::Tainted
437 && v.tier == crate::_internal::report::violations::ViolationTier::Tier1
438 {
439 v.tier = crate::_internal::report::violations::ViolationTier::Tier2;
440 }
441 statement_violations.push(v);
442 }
443 }
444
445 if statement_failed {
446 break;
447 }
448 }
449
450 warned_keys.extend(statement_warned_keys);
451 all_violations.extend(statement_violations);
452 }
453
454 state.set_evidence_location(None);
455 Ok(all_violations)
456 }
457
458 fn source_location(
459 filename: &str,
460 sql: &str,
461 source_range: Option<rowan::TextRange>,
462 ) -> Option<SourceLocation> {
463 let start = usize::from(source_range?.start());
464 if start > sql.len() || !sql.is_char_boundary(start) {
465 return None;
466 }
467
468 let before = &sql[..start];
469 let line = before.bytes().filter(|byte| *byte == b'\n').count() + 1;
470 let column = before
471 .rsplit_once('\n')
472 .map_or(before, |(_, final_line)| final_line)
473 .chars()
474 .count()
475 + 1;
476 Some(SourceLocation {
477 file: filename.to_string(),
478 line,
479 column,
480 })
481 }
482
483 fn normalize_execute(sql: &str) -> String {
489 let mut out = String::with_capacity(sql.len());
490 for line in sql.split_inclusive('\n') {
491 let trimmed = line.trim_start();
492 let bytes = trimmed.as_bytes();
493 if bytes.len() > 9 && bytes[..9].eq_ignore_ascii_case(b"EXECUTE '") {
494 let indent = &line[..line.len() - trimmed.len()];
495 out.push_str(indent);
496 out.push_str("DO '");
497 out.push_str(&trimmed[9..]);
498 } else if bytes.len() > 10 && bytes[..10].eq_ignore_ascii_case(b"EXECUTE $$") {
499 let indent = &line[..line.len() - trimmed.len()];
500 out.push_str(indent);
501 out.push_str("DO $$");
502 out.push_str(&trimmed[10..]);
503 } else {
504 out.push_str(line);
505 }
506 }
507 out
508 }
509
510 fn parse_directives(
511 text: &str,
512 file_ignores: &mut HashSet<String>,
513 stmt_ignores: &mut HashSet<String>,
514 ) {
515 let marker = "safe-migrate:";
516 let mut pos = 0;
517
518 while let Some(start) = text[pos..].find(marker) {
519 let after = text[pos + start + marker.len()..].trim_start();
520
521 if let Some(rest) = after.strip_prefix("ignore-file") {
522 let rest = rest.trim_start();
523 if let Some(inner) = rest
524 .strip_prefix('(')
525 .and_then(|s| s.find(')').map(|e| &s[..e]))
526 {
527 file_ignores.insert(inner.trim().to_string());
528 }
529 } else if let Some(rest) = after.strip_prefix("ignore") {
530 let rest = rest.trim_start();
531 if let Some(inner) = rest
532 .strip_prefix('(')
533 .and_then(|s| s.find(')').map(|e| &s[..e]))
534 {
535 stmt_ignores.insert(inner.trim().to_string());
536 }
537 }
538
539 pos = pos + start + marker.len();
540 }
541 }
542
543 fn strip_sql_leading_comments(s: &str) -> String {
544 let mut pos = 0;
545 let bytes = s.as_bytes();
546 while pos < bytes.len() {
547 while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
548 pos += 1;
549 }
550 if pos + 1 < bytes.len() && bytes[pos] == b'-' && bytes[pos + 1] == b'-' {
551 while pos < bytes.len() && bytes[pos] != b'\n' {
552 pos += 1;
553 }
554 continue;
555 }
556 if pos + 1 < bytes.len() && bytes[pos] == b'/' && bytes[pos + 1] == b'*' {
557 pos += 2;
558 while pos + 1 < bytes.len() && !(bytes[pos] == b'*' && bytes[pos + 1] == b'/') {
559 pos += 1;
560 }
561 if pos + 1 < bytes.len() {
562 pos += 2;
563 }
564 continue;
565 }
566 break;
567 }
568 s[pos..].to_string()
569 }
570}