Skip to main content

safe_migrate/_internal/engine/
engine.rs

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    /// Returns primary rule IDs in evaluation order.
85    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        // Stable ordering keeps reports reproducible across files.
100        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    /// Analyze ordered files and retain reportable source locations for every
127    /// finding. The original `analyze_chain` API remains available to callers
128    /// that only need violations.
129    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    /// Analyze a migration chain and return immutable findings, confidence, and
197    /// conservative-analysis evidence together.
198    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    /// Analyze one migration and return immutable findings, confidence, and
212    /// conservative-analysis evidence together.
213    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            // Capture raw statement text for sql field on violations (strip leading comments)
302            let stmt_text = Self::strip_sql_leading_comments(&stmt.syntax().text().to_string());
303
304            // PostgreSQL executes a statement atomically. Keep both state
305            // and diagnostics local until all resolved actions succeed so an
306            // earlier action in a failed compound statement cannot leak
307            // state, findings, or deduplication keys. A parsed statement
308            // without a typed extractor is explicitly opaque: silently
309            // ignoring it would claim exact confidence for later SQL.
310            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                    // A capability is relevant only when this rule actually
376                    // produces a finding for the current mutation. Recording
377                    // it for every unrelated statement would taint an
378                    // otherwise exact chain (for example, DROP DATABASE does
379                    // not need relation statistics).
380                    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                        // A taint produced by this statement must not
434                        // downgrade that same statement's findings.
435                        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    /// Pre-process SQL to handle EXECUTE '...' which Squawk's parser does not
484    /// recognize (top-level EXECUTE expects a prepared-statement name, not a
485    /// string literal). Rewriting to DO lets the parser produce a proper
486    /// DoBlock node. Keep the replacement byte-for-byte the same length so
487    /// source ranges still point into the original migration text.
488    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}