1#![deny(missing_docs)]
10
11use std::collections::{BTreeMap, BTreeSet};
12
13mod ast;
14mod fix;
15mod output;
16mod rules;
17mod sarif;
18mod suppression;
19mod synthesized;
20
21use synthesized::{
25 do_block, enum_value, fk_index, forbid_nullable_fk, forbidden_types, identifier, naming,
26 require_columns, require_comment, require_if_exists, require_not_null, require_pk,
27 require_schema_qualified, timeout, txn,
28};
29
30#[cfg(feature = "cli")]
31pub mod cli;
32
33pub use output::{
34 gate, lint_input, render_errors, render_finding_body, render_finding_human, render_github,
35 render_human, render_human_styled, render_json, render_statement_header, render_summary,
36 ColorWhen, FailOn, FileReport, Format, Styling, SCHEMA_VERSION,
37};
38pub use sarif::render_sarif;
39
40pub const VERSION: &str = env!("CARGO_PKG_VERSION");
49
50#[non_exhaustive]
53#[derive(
54 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
55)]
56#[serde(rename_all = "lowercase")]
57pub enum Severity {
58 Warning,
60 Error,
62}
63
64#[non_exhaustive]
67#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
68pub struct Location {
69 pub byte: u32,
71 pub line: u32,
73 pub column: u32,
76}
77
78#[non_exhaustive]
80#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
81pub enum NameKind {
82 Table,
84 Column,
86 Index,
88 Constraint,
90 Sequence,
92 Trigger,
94 Schema,
96}
97
98impl NameKind {
99 #[must_use]
101 pub fn as_str(self) -> &'static str {
102 match self {
103 NameKind::Table => "table",
104 NameKind::Column => "column",
105 NameKind::Index => "index",
106 NameKind::Constraint => "constraint",
107 NameKind::Sequence => "sequence",
108 NameKind::Trigger => "trigger",
109 NameKind::Schema => "schema",
110 }
111 }
112}
113
114impl std::fmt::Display for Severity {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 match self {
117 Severity::Warning => f.write_str("warning"),
118 Severity::Error => f.write_str("error"),
119 }
120 }
121}
122
123#[non_exhaustive]
125#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
126pub struct Suppression {
127 pub reason: String,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub(crate) struct RuleHit {
134 pub message: String,
135 pub guidance: String,
136 pub fix: Option<crate::fix::FixDraft>,
137}
138
139#[non_exhaustive]
146#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
147pub struct FixEdit {
148 pub start: u32,
150 pub end: u32,
152 pub replacement: String,
154}
155
156#[non_exhaustive]
175#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
176pub struct Fix {
177 pub title: String,
179 pub edits: Vec<FixEdit>,
181}
182
183#[non_exhaustive]
185#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
186pub struct Finding {
187 pub rule_id: String,
189 pub severity: Severity,
191 pub message: String,
193 pub guidance: String,
195 pub statement_index: usize,
199 pub location: Location,
201 pub snippet: String,
203 #[serde(skip_serializing_if = "Option::is_none", default)]
206 pub suppression: Option<Suppression>,
207 #[serde(skip_serializing_if = "Option::is_none", default)]
209 pub fix: Option<Fix>,
210}
211
212impl Finding {
213 #[must_use]
215 pub fn is_suppressed(&self) -> bool {
216 self.suppression.is_some()
217 }
218}
219
220#[non_exhaustive]
222#[derive(Debug, Clone, Default)]
223pub struct LintOptions {
224 pub assume_in_transaction: bool,
228 pub disabled_rules: BTreeSet<String>,
231 pub enabled_rules: BTreeSet<String>,
238 pub severity_overrides: BTreeMap<String, Severity>,
241 pub naming_patterns: BTreeMap<NameKind, String>,
244 pub forbidden_column_types: BTreeMap<String, String>,
247 pub required_columns: BTreeSet<String>,
252}
253
254#[non_exhaustive]
256#[derive(Debug, thiserror::Error)]
257pub enum LintError {
258 #[error("parse error: {0}")]
260 Parse(String),
261}
262
263pub(crate) fn line_col(sql: &str, byte: usize) -> (u32, u32) {
265 let mut line = 1u32;
266 let mut column = 1u32;
267 for (i, ch) in sql.char_indices() {
268 if i >= byte {
269 break;
270 }
271 if ch == '\n' {
272 line += 1;
273 column = 1;
274 } else {
275 column += 1;
276 }
277 }
278 (line, column)
279}
280
281pub(crate) fn known_rule_ids() -> Vec<&'static str> {
289 let mut ids = rules::rule_ids();
290 ids.push(txn::ID);
291 ids.push(timeout::ID);
292 ids.push(identifier::ID);
293 ids.push(fk_index::ID);
294 ids.push(enum_value::ID);
295 ids.push(require_pk::ID);
296 ids.push(require_schema_qualified::ID);
297 ids.push(require_not_null::ID);
298 ids.push(naming::ID);
299 ids.push(forbidden_types::ID);
300 ids.push(require_if_exists::ID);
301 ids.push(do_block::ID);
302 ids.push(require_comment::ID);
303 ids.push(require_columns::ID);
304 ids.push(forbid_nullable_fk::ID);
305 ids
306}
307
308#[must_use]
312pub fn list_rule_ids() -> Vec<&'static str> {
313 known_rule_ids()
314}
315
316pub fn lint_sql(sql: &str, options: &LintOptions) -> Result<Vec<Finding>, LintError> {
334 let parsed = crate::ast::parse(sql).map_err(|e| LintError::Parse(e.to_string()))?;
335 let stmts = &parsed.protobuf.stmts;
336 let comments = suppression::scan_comments(sql)?;
337 let geoms = suppression::geometry(sql, stmts, &comments);
338 let rules = rules::all_rules();
339 let mut findings = Vec::new();
340 let mut hits = Vec::new();
341 for (i, raw) in stmts.iter().enumerate() {
342 let Some(stmt_box) = raw.stmt.as_ref() else {
343 continue;
344 };
345 let Some(node) = stmt_box.node.as_ref() else {
346 continue;
347 };
348 let g = &geoms[i];
349 let (line, column) = line_col(sql, g.start);
350 let location = Location {
351 byte: u32::try_from(g.start).unwrap_or(u32::MAX),
352 line,
353 column,
354 };
355 let snippet = sql.get(g.start..g.end).unwrap_or("").trim().to_string();
356 for rule in rules {
357 if options.disabled_rules.contains(rule.id()) {
358 continue;
359 }
360 rule.check(node, &mut hits);
361 for h in hits.drain(..) {
362 let fix = h.fix.as_ref().and_then(|d| {
363 let r = crate::fix::resolve(d, sql, g.start, g.body_end);
364 debug_assert!(
365 r.is_some() || d.may_legitimately_not_resolve(),
366 "rule {}: fix draft {:?} failed to resolve",
367 rule.id(),
368 d.title
369 );
370 r
371 });
372 findings.push(Finding {
373 rule_id: rule.id().to_string(),
374 severity: rule.severity(),
375 message: h.message,
376 guidance: h.guidance,
377 statement_index: i,
378 location,
379 snippet: snippet.clone(),
380 suppression: None,
381 fix,
382 });
383 }
384 }
385 }
386 let (mut findings, new_table_dropped) =
387 synthesized::run_all(sql, stmts, &geoms, options, findings);
388 synthesized::txn::suppress_concurrently_fix_in_transaction(
391 stmts,
392 &mut findings,
393 options.assume_in_transaction,
394 );
395 if !options.severity_overrides.is_empty() {
396 for f in &mut findings {
397 if let Some(&sev) = options.severity_overrides.get(&f.rule_id) {
398 f.severity = sev;
399 }
400 }
401 }
402 let known_ids = known_rule_ids();
403 suppression::resolve(
404 sql,
405 &geoms,
406 &comments,
407 findings,
408 &known_ids,
409 &new_table_dropped,
410 &options.disabled_rules,
411 )
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 #[test]
419 fn severity_is_ordered_warning_below_error() {
420 assert!(Severity::Warning < Severity::Error);
421 }
422
423 #[test]
424 fn empty_string_returns_no_findings() {
425 assert_eq!(lint_sql("", &LintOptions::default()).unwrap(), Vec::new());
426 }
427
428 #[test]
429 fn comment_only_returns_no_findings() {
430 assert_eq!(
431 lint_sql("-- just a comment\n", &LintOptions::default()).unwrap(),
432 Vec::new()
433 );
434 }
435
436 #[test]
437 fn valid_sql_returns_no_findings() {
438 assert_eq!(
439 lint_sql("SELECT 1", &LintOptions::default()).unwrap(),
440 Vec::new()
441 );
442 }
443
444 #[test]
445 fn invalid_sql_is_parse_error() {
446 assert!(matches!(
447 lint_sql("ALTER TABLE", &LintOptions::default()),
448 Err(LintError::Parse(_))
449 ));
450 }
451
452 #[test]
453 fn engine_finding_order_and_positional_enrichment() {
454 let sql = "CREATE INDEX i ON t (x);\n\nALTER TABLE t ALTER COLUMN a TYPE bigint, ALTER COLUMN a SET NOT NULL;\n";
455 let f = lint_sql(sql, &LintOptions::default()).unwrap();
456
457 let ids: Vec<&str> = f.iter().map(|x| x.rule_id.as_str()).collect();
462 assert_eq!(
463 ids,
464 [
465 "add-index-non-concurrent",
466 "require-timeout",
467 "set-not-null",
468 "alter-column-type",
469 "require-timeout",
470 ]
471 );
472
473 assert_eq!(f[0].statement_index, 0);
474 assert_eq!(f[1].statement_index, 0);
475 assert_eq!(f[2].statement_index, 1);
476 assert_eq!(f[3].statement_index, 1);
477 assert_eq!(f[4].statement_index, 1);
478
479 for finding in &f {
481 let b = finding.location.byte as usize;
482 assert!(
483 sql[b..].starts_with(&finding.snippet),
484 "location.byte must point at the trimmed statement start"
485 );
486 }
487
488 assert_eq!((f[0].location.line, f[0].location.column), (1, 1));
490 assert_eq!((f[2].location.line, f[2].location.column), (3, 1));
491 assert_eq!(f[3].location.line, 3);
492
493 assert!(f[2].snippet.starts_with("ALTER TABLE"));
494 }
495
496 #[test]
497 fn findings_json_round_trip() {
498 let findings = lint_sql("CREATE INDEX i ON t (x)", &LintOptions::default()).unwrap();
499 let json = serde_json::to_string(&findings).unwrap();
500 let back: Vec<Finding> = serde_json::from_str(&json).unwrap();
501 assert_eq!(findings, back);
502 }
503
504 #[test]
505 fn suppression_field_defaults_to_none_and_is_omitted_from_json() {
506 let f = &lint_sql("CREATE INDEX i ON t (x)", &LintOptions::default()).unwrap()[0];
507 assert!(!f.is_suppressed());
508 let json = serde_json::to_string(f).unwrap();
509 assert!(
510 !json.contains("suppression"),
511 "None suppression must be omitted"
512 );
513 }
514
515 #[test]
516 fn suppression_round_trips_when_present() {
517 let mut f = lint_sql("CREATE INDEX i ON t (x)", &LintOptions::default())
518 .unwrap()
519 .remove(0);
520 f.suppression = Some(Suppression {
521 reason: "off-peak deploy".into(),
522 });
523 assert!(f.is_suppressed());
524 let back: Finding = serde_json::from_str(&serde_json::to_string(&f).unwrap()).unwrap();
525 assert_eq!(back.suppression.unwrap().reason, "off-peak deploy");
526 }
527
528 #[test]
529 fn assume_in_transaction_flags_top_level_concurrently() {
530 let sql = "CREATE INDEX CONCURRENTLY i ON t (x)";
531 let on = lint_sql(
532 sql,
533 &LintOptions {
534 assume_in_transaction: true,
535 ..LintOptions::default()
536 },
537 )
538 .unwrap();
539 assert!(on
540 .iter()
541 .any(|f| f.rule_id == "concurrently-in-transaction"));
542 let off = lint_sql(sql, &LintOptions::default()).unwrap();
543 assert!(!off
544 .iter()
545 .any(|f| f.rule_id == "concurrently-in-transaction"));
546 }
547
548 fn opts(disabled: &[&str], overrides: &[(&str, Severity)]) -> LintOptions {
549 LintOptions {
550 disabled_rules: disabled.iter().map(|s| (*s).to_string()).collect(),
551 severity_overrides: overrides
552 .iter()
553 .map(|(k, v)| ((*k).to_string(), *v))
554 .collect(),
555 ..LintOptions::default()
556 }
557 }
558
559 #[test]
560 fn disabled_registered_rule_produces_no_finding() {
561 let fs = lint_sql("DROP TABLE x;", &opts(&["drop-table"], &[])).unwrap();
562 assert!(!fs.iter().any(|f| f.rule_id == "drop-table"));
563 }
564
565 #[test]
566 fn disabled_synthesized_rule_is_silent() {
567 let fs = lint_sql(
569 "ALTER TABLE t ADD COLUMN c int;",
570 &opts(&["require-timeout"], &[]),
571 )
572 .unwrap();
573 assert!(!fs.iter().any(|f| f.rule_id == "require-timeout"));
574 }
575
576 #[test]
577 fn severity_override_changes_a_findings_severity() {
578 let fs = lint_sql(
579 "CREATE INDEX i ON t (x);",
580 &opts(&[], &[("add-index-non-concurrent", Severity::Warning)]),
581 )
582 .unwrap();
583 let f = fs
584 .iter()
585 .find(|f| f.rule_id == "add-index-non-concurrent")
586 .unwrap();
587 assert_eq!(f.severity, Severity::Warning); }
589
590 #[test]
591 fn directive_for_a_disabled_rule_is_not_reported_unused() {
592 let sql = "-- pgsafe:ignore drop-table disabled in config anyway\nDROP TABLE x;";
593 let fs = lint_sql(sql, &opts(&["drop-table"], &[])).unwrap();
594 assert!(!fs.iter().any(|f| f.rule_id == "suppression-unused"));
595 assert!(!fs.iter().any(|f| f.rule_id == "drop-table"));
596 }
597
598 #[test]
599 fn hazard_inside_do_block_is_flagged_by_the_real_rule() {
600 let f = lint_sql(
601 "DO $$ BEGIN CREATE INDEX i ON t (c); END $$;",
602 &LintOptions::default(),
603 )
604 .unwrap();
605 let hit = f
606 .iter()
607 .find(|f| f.rule_id == "add-index-non-concurrent")
608 .expect("a non-CONCURRENTLY CREATE INDEX in a DO block must be flagged");
609 assert!(hit.message.starts_with("Inside a DO block:"));
610 assert!(hit.snippet.contains("CREATE INDEX"));
611 }
612
613 #[test]
614 fn safe_do_block_produces_no_findings() {
615 let f = lint_sql("DO $$ BEGIN PERFORM 1; END $$;", &LintOptions::default()).unwrap();
616 assert!(f.is_empty());
617 }
618
619 #[test]
620 fn multiple_hazards_in_one_do_block_each_flagged() {
621 let f = lint_sql(
622 "DO $$ BEGIN CREATE INDEX i ON t (c); DROP TABLE u; END $$;",
623 &LintOptions::default(),
624 )
625 .unwrap();
626 assert!(f.iter().any(|f| f.rule_id == "add-index-non-concurrent"));
627 assert!(f.iter().any(|f| f.rule_id == "drop-table"));
628 }
629
630 #[test]
631 fn embedded_finding_respects_disabled_rules() {
632 let opts = LintOptions {
633 disabled_rules: ["add-index-non-concurrent".to_string()]
634 .into_iter()
635 .collect(),
636 ..LintOptions::default()
637 };
638 let f = lint_sql("DO $$ BEGIN CREATE INDEX i ON t (c); END $$;", &opts).unwrap();
639 assert!(f.iter().all(|f| f.rule_id != "add-index-non-concurrent"));
640 }
641
642 #[test]
643 fn embedded_finding_is_suppressible_on_the_do_statement() {
644 let sql = "-- pgsafe:ignore add-index-non-concurrent reviewed\n\
645 DO $$ BEGIN CREATE INDEX i ON t (c); END $$;";
646 let f = lint_sql(sql, &LintOptions::default()).unwrap();
647 let hit = f
648 .iter()
649 .find(|f| f.rule_id == "add-index-non-concurrent")
650 .expect("finding must still be present, just suppressed");
651 assert!(hit.is_suppressed());
652 }
653
654 #[test]
655 fn mixed_do_block_yields_both_embedded_and_residue_findings() {
656 let opts = LintOptions {
657 enabled_rules: ["unchecked-do-block".to_string()].into_iter().collect(),
658 ..LintOptions::default()
659 };
660 let f = lint_sql(
661 "DO $$ BEGIN CREATE INDEX i ON t (c); EXECUTE 'DROP TABLE u'; END $$;",
662 &opts,
663 )
664 .unwrap();
665 assert!(f.iter().any(|f| f.rule_id == "add-index-non-concurrent"
666 && f.message.starts_with("Inside a DO block:")));
667 assert!(f.iter().any(|f| f.rule_id == "unchecked-do-block"));
668 }
669
670 #[test]
671 fn finding_serializes_fix_when_present() {
672 let f = Finding {
673 rule_id: "add-index-non-concurrent".into(),
674 severity: Severity::Error,
675 message: "m".into(),
676 guidance: "g".into(),
677 statement_index: 0,
678 location: Location {
679 byte: 0,
680 line: 1,
681 column: 1,
682 },
683 snippet: "CREATE INDEX i ON t (c)".into(),
684 suppression: None,
685 fix: Some(Fix {
686 title: "Add CONCURRENTLY".into(),
687 edits: vec![FixEdit {
688 start: 12,
689 end: 12,
690 replacement: " CONCURRENTLY".into(),
691 }],
692 }),
693 };
694 let json = serde_json::to_string(&f).unwrap();
695 assert!(
696 json.contains(r#""fix":{"title":"Add CONCURRENTLY""#),
697 "{json}"
698 );
699 assert!(
700 json.contains(r#""edits":[{"start":12,"end":12,"replacement":" CONCURRENTLY"}]"#),
701 "{json}"
702 );
703 assert_eq!(serde_json::from_str::<Finding>(&json).unwrap(), f);
705 }
706
707 #[test]
708 fn finding_omits_fix_when_absent() {
709 let f = Finding {
710 rule_id: "drop-column".into(),
711 severity: Severity::Warning,
712 message: "m".into(),
713 guidance: "g".into(),
714 statement_index: 0,
715 location: Location {
716 byte: 0,
717 line: 1,
718 column: 1,
719 },
720 snippet: "ALTER TABLE t DROP COLUMN c".into(),
721 suppression: None,
722 fix: None,
723 };
724 assert!(!serde_json::to_string(&f).unwrap().contains("fix"));
725 }
726
727 #[test]
728 fn rule_hit_default_carries_no_fix() {
729 let fs = lint_sql("DROP TABLE t;", &LintOptions::default()).unwrap();
731 let f = fs.iter().find(|f| f.rule_id == "drop-table").unwrap();
732 assert!(f.fix.is_none());
733 }
734
735 #[test]
736 fn concurrently_fix_withdrawn_inside_transaction() {
737 for (sql, rule) in [
739 (
740 "BEGIN; CREATE INDEX i ON t (c); COMMIT;",
741 "add-index-non-concurrent",
742 ),
743 ("BEGIN; DROP INDEX i; COMMIT;", "drop-index-non-concurrent"),
744 ("BEGIN; REINDEX INDEX i; COMMIT;", "reindex-non-concurrent"),
745 (
746 "BEGIN; ALTER TABLE p DETACH PARTITION p1; COMMIT;",
747 "detach-partition-non-concurrent",
748 ),
749 ] {
750 let fs = lint_sql(sql, &LintOptions::default()).unwrap();
751 let f = fs
752 .iter()
753 .find(|f| f.rule_id == rule)
754 .expect("finding present");
755 assert!(
756 f.fix.is_none(),
757 "fix must be withdrawn in a txn for {rule}: {sql}"
758 );
759 }
760 }
761
762 #[test]
763 fn concurrently_fix_withdrawn_only_for_the_in_transaction_statement() {
764 let sql = "CREATE INDEX a ON t (c); BEGIN; CREATE INDEX b ON t (c); COMMIT;";
767 let fs = lint_sql(sql, &LintOptions::default()).unwrap();
768 let hits: Vec<_> = fs
769 .iter()
770 .filter(|f| f.rule_id == "add-index-non-concurrent")
771 .collect();
772 assert_eq!(hits.len(), 2, "both CREATE INDEX statements are flagged");
773 let outside = hits.iter().find(|f| f.statement_index == 0).unwrap();
774 let inside = hits.iter().find(|f| f.statement_index == 2).unwrap();
775 assert!(outside.fix.is_some(), "outside-txn fix must be kept");
776 assert!(inside.fix.is_none(), "in-txn fix must be withdrawn");
777 }
778
779 #[test]
780 fn concurrently_fix_kept_outside_transaction() {
781 let fs = lint_sql("CREATE INDEX i ON t (c);", &LintOptions::default()).unwrap();
783 let f = fs
784 .iter()
785 .find(|f| f.rule_id == "add-index-non-concurrent")
786 .unwrap();
787 assert!(f.fix.is_some());
788 }
789
790 #[test]
791 fn assume_in_transaction_withdraws_top_level_concurrently_fix() {
792 let opts = LintOptions {
793 assume_in_transaction: true,
794 ..LintOptions::default()
795 };
796 let fs = lint_sql("CREATE INDEX i ON t (c);", &opts).unwrap();
797 let f = fs
798 .iter()
799 .find(|f| f.rule_id == "add-index-non-concurrent")
800 .unwrap();
801 assert!(f.fix.is_none());
802 }
803}