#![deny(missing_docs)]
use std::collections::{BTreeMap, BTreeSet};
mod ast;
mod fix;
mod output;
mod rules;
mod sarif;
mod suppression;
mod synthesized;
use synthesized::{
do_block, enum_value, fk_index, forbid_nullable_fk, forbidden_types, identifier, naming,
require_columns, require_comment, require_if_exists, require_not_null, require_pk, timeout,
txn,
};
#[cfg(feature = "cli")]
pub mod cli;
pub use output::{
gate, lint_input, render_errors, render_finding_body, render_finding_human, render_github,
render_human, render_human_styled, render_json, render_statement_header, render_summary,
ColorWhen, FailOn, FileReport, Format, Styling, SCHEMA_VERSION,
};
pub use sarif::render_sarif;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[non_exhaustive]
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Warning,
Error,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Location {
pub byte: u32,
pub line: u32,
pub column: u32,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum NameKind {
Table,
Column,
Index,
Constraint,
Sequence,
Trigger,
Schema,
}
impl NameKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
NameKind::Table => "table",
NameKind::Column => "column",
NameKind::Index => "index",
NameKind::Constraint => "constraint",
NameKind::Sequence => "sequence",
NameKind::Trigger => "trigger",
NameKind::Schema => "schema",
}
}
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Severity::Warning => f.write_str("warning"),
Severity::Error => f.write_str("error"),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Suppression {
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RuleHit {
pub message: String,
pub guidance: String,
pub fix: Option<crate::fix::FixDraft>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct FixEdit {
pub start: u32,
pub end: u32,
pub replacement: String,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Fix {
pub title: String,
pub edits: Vec<FixEdit>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Finding {
pub rule_id: String,
pub severity: Severity,
pub message: String,
pub guidance: String,
pub statement_index: usize,
pub location: Location,
pub snippet: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub suppression: Option<Suppression>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub fix: Option<Fix>,
}
impl Finding {
#[must_use]
pub fn is_suppressed(&self) -> bool {
self.suppression.is_some()
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Default)]
pub struct LintOptions {
pub assume_in_transaction: bool,
pub disabled_rules: BTreeSet<String>,
pub enabled_rules: BTreeSet<String>,
pub severity_overrides: BTreeMap<String, Severity>,
pub naming_patterns: BTreeMap<NameKind, String>,
pub forbidden_column_types: BTreeMap<String, String>,
pub required_columns: BTreeSet<String>,
}
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum LintError {
#[error("parse error: {0}")]
Parse(String),
}
pub(crate) fn line_col(sql: &str, byte: usize) -> (u32, u32) {
let mut line = 1u32;
let mut column = 1u32;
for (i, ch) in sql.char_indices() {
if i >= byte {
break;
}
if ch == '\n' {
line += 1;
column = 1;
} else {
column += 1;
}
}
(line, column)
}
pub(crate) fn known_rule_ids() -> Vec<&'static str> {
let mut ids = rules::rule_ids();
ids.push(txn::ID);
ids.push(timeout::ID);
ids.push(identifier::ID);
ids.push(fk_index::ID);
ids.push(enum_value::ID);
ids.push(require_pk::ID);
ids.push(require_not_null::ID);
ids.push(naming::ID);
ids.push(forbidden_types::ID);
ids.push(require_if_exists::ID);
ids.push(do_block::ID);
ids.push(require_comment::ID);
ids.push(require_columns::ID);
ids.push(forbid_nullable_fk::ID);
ids
}
#[must_use]
pub fn list_rule_ids() -> Vec<&'static str> {
known_rule_ids()
}
pub fn lint_sql(sql: &str, options: &LintOptions) -> Result<Vec<Finding>, LintError> {
let parsed = crate::ast::parse(sql).map_err(|e| LintError::Parse(e.to_string()))?;
let stmts = &parsed.protobuf.stmts;
let comments = suppression::scan_comments(sql)?;
let geoms = suppression::geometry(sql, stmts, &comments);
let rules = rules::all_rules();
let mut findings = Vec::new();
let mut hits = Vec::new();
for (i, raw) in stmts.iter().enumerate() {
let Some(stmt_box) = raw.stmt.as_ref() else {
continue;
};
let Some(node) = stmt_box.node.as_ref() else {
continue;
};
let g = &geoms[i];
let (line, column) = line_col(sql, g.start);
let location = Location {
byte: u32::try_from(g.start).unwrap_or(u32::MAX),
line,
column,
};
let snippet = sql.get(g.start..g.end).unwrap_or("").trim().to_string();
for rule in rules {
if options.disabled_rules.contains(rule.id()) {
continue;
}
rule.check(node, &mut hits);
for h in hits.drain(..) {
let fix = h.fix.as_ref().and_then(|d| {
let r = crate::fix::resolve(d, sql, g.start, g.end);
debug_assert!(
r.is_some() || d.may_legitimately_not_resolve(),
"rule {}: fix draft {:?} failed to resolve",
rule.id(),
d.title
);
r
});
findings.push(Finding {
rule_id: rule.id().to_string(),
severity: rule.severity(),
message: h.message,
guidance: h.guidance,
statement_index: i,
location,
snippet: snippet.clone(),
suppression: None,
fix,
});
}
}
}
let (mut findings, new_table_dropped) =
synthesized::run_all(sql, stmts, &geoms, options, findings);
if !options.severity_overrides.is_empty() {
for f in &mut findings {
if let Some(&sev) = options.severity_overrides.get(&f.rule_id) {
f.severity = sev;
}
}
}
let known_ids = known_rule_ids();
suppression::resolve(
sql,
&geoms,
&comments,
findings,
&known_ids,
&new_table_dropped,
&options.disabled_rules,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn severity_is_ordered_warning_below_error() {
assert!(Severity::Warning < Severity::Error);
}
#[test]
fn empty_string_returns_no_findings() {
assert_eq!(lint_sql("", &LintOptions::default()).unwrap(), Vec::new());
}
#[test]
fn comment_only_returns_no_findings() {
assert_eq!(
lint_sql("-- just a comment\n", &LintOptions::default()).unwrap(),
Vec::new()
);
}
#[test]
fn valid_sql_returns_no_findings() {
assert_eq!(
lint_sql("SELECT 1", &LintOptions::default()).unwrap(),
Vec::new()
);
}
#[test]
fn invalid_sql_is_parse_error() {
assert!(matches!(
lint_sql("ALTER TABLE", &LintOptions::default()),
Err(LintError::Parse(_))
));
}
#[test]
fn engine_finding_order_and_positional_enrichment() {
let sql = "CREATE INDEX i ON t (x);\n\nALTER TABLE t ALTER COLUMN a TYPE bigint, ALTER COLUMN a SET NOT NULL;\n";
let f = lint_sql(sql, &LintOptions::default()).unwrap();
let ids: Vec<&str> = f.iter().map(|x| x.rule_id.as_str()).collect();
assert_eq!(
ids,
[
"add-index-non-concurrent",
"require-timeout",
"set-not-null",
"alter-column-type",
"require-timeout",
]
);
assert_eq!(f[0].statement_index, 0);
assert_eq!(f[1].statement_index, 0);
assert_eq!(f[2].statement_index, 1);
assert_eq!(f[3].statement_index, 1);
assert_eq!(f[4].statement_index, 1);
for finding in &f {
let b = finding.location.byte as usize;
assert!(
sql[b..].starts_with(&finding.snippet),
"location.byte must point at the trimmed statement start"
);
}
assert_eq!((f[0].location.line, f[0].location.column), (1, 1));
assert_eq!((f[2].location.line, f[2].location.column), (3, 1));
assert_eq!(f[3].location.line, 3);
assert!(f[2].snippet.starts_with("ALTER TABLE"));
}
#[test]
fn findings_json_round_trip() {
let findings = lint_sql("CREATE INDEX i ON t (x)", &LintOptions::default()).unwrap();
let json = serde_json::to_string(&findings).unwrap();
let back: Vec<Finding> = serde_json::from_str(&json).unwrap();
assert_eq!(findings, back);
}
#[test]
fn suppression_field_defaults_to_none_and_is_omitted_from_json() {
let f = &lint_sql("CREATE INDEX i ON t (x)", &LintOptions::default()).unwrap()[0];
assert!(!f.is_suppressed());
let json = serde_json::to_string(f).unwrap();
assert!(
!json.contains("suppression"),
"None suppression must be omitted"
);
}
#[test]
fn suppression_round_trips_when_present() {
let mut f = lint_sql("CREATE INDEX i ON t (x)", &LintOptions::default())
.unwrap()
.remove(0);
f.suppression = Some(Suppression {
reason: "off-peak deploy".into(),
});
assert!(f.is_suppressed());
let back: Finding = serde_json::from_str(&serde_json::to_string(&f).unwrap()).unwrap();
assert_eq!(back.suppression.unwrap().reason, "off-peak deploy");
}
#[test]
fn assume_in_transaction_flags_top_level_concurrently() {
let sql = "CREATE INDEX CONCURRENTLY i ON t (x)";
let on = lint_sql(
sql,
&LintOptions {
assume_in_transaction: true,
..LintOptions::default()
},
)
.unwrap();
assert!(on
.iter()
.any(|f| f.rule_id == "concurrently-in-transaction"));
let off = lint_sql(sql, &LintOptions::default()).unwrap();
assert!(!off
.iter()
.any(|f| f.rule_id == "concurrently-in-transaction"));
}
fn opts(disabled: &[&str], overrides: &[(&str, Severity)]) -> LintOptions {
LintOptions {
disabled_rules: disabled.iter().map(|s| (*s).to_string()).collect(),
severity_overrides: overrides
.iter()
.map(|(k, v)| ((*k).to_string(), *v))
.collect(),
..LintOptions::default()
}
}
#[test]
fn disabled_registered_rule_produces_no_finding() {
let fs = lint_sql("DROP TABLE x;", &opts(&["drop-table"], &[])).unwrap();
assert!(!fs.iter().any(|f| f.rule_id == "drop-table"));
}
#[test]
fn disabled_synthesized_rule_is_silent() {
let fs = lint_sql(
"ALTER TABLE t ADD COLUMN c int;",
&opts(&["require-timeout"], &[]),
)
.unwrap();
assert!(!fs.iter().any(|f| f.rule_id == "require-timeout"));
}
#[test]
fn severity_override_changes_a_findings_severity() {
let fs = lint_sql(
"CREATE INDEX i ON t (x);",
&opts(&[], &[("add-index-non-concurrent", Severity::Warning)]),
)
.unwrap();
let f = fs
.iter()
.find(|f| f.rule_id == "add-index-non-concurrent")
.unwrap();
assert_eq!(f.severity, Severity::Warning); }
#[test]
fn directive_for_a_disabled_rule_is_not_reported_unused() {
let sql = "-- pgsafe:ignore drop-table disabled in config anyway\nDROP TABLE x;";
let fs = lint_sql(sql, &opts(&["drop-table"], &[])).unwrap();
assert!(!fs.iter().any(|f| f.rule_id == "suppression-unused"));
assert!(!fs.iter().any(|f| f.rule_id == "drop-table"));
}
#[test]
fn hazard_inside_do_block_is_flagged_by_the_real_rule() {
let f = lint_sql(
"DO $$ BEGIN CREATE INDEX i ON t (c); END $$;",
&LintOptions::default(),
)
.unwrap();
let hit = f
.iter()
.find(|f| f.rule_id == "add-index-non-concurrent")
.expect("a non-CONCURRENTLY CREATE INDEX in a DO block must be flagged");
assert!(hit.message.starts_with("Inside a DO block:"));
assert!(hit.snippet.contains("CREATE INDEX"));
}
#[test]
fn safe_do_block_produces_no_findings() {
let f = lint_sql("DO $$ BEGIN PERFORM 1; END $$;", &LintOptions::default()).unwrap();
assert!(f.is_empty());
}
#[test]
fn multiple_hazards_in_one_do_block_each_flagged() {
let f = lint_sql(
"DO $$ BEGIN CREATE INDEX i ON t (c); DROP TABLE u; END $$;",
&LintOptions::default(),
)
.unwrap();
assert!(f.iter().any(|f| f.rule_id == "add-index-non-concurrent"));
assert!(f.iter().any(|f| f.rule_id == "drop-table"));
}
#[test]
fn embedded_finding_respects_disabled_rules() {
let opts = LintOptions {
disabled_rules: ["add-index-non-concurrent".to_string()]
.into_iter()
.collect(),
..LintOptions::default()
};
let f = lint_sql("DO $$ BEGIN CREATE INDEX i ON t (c); END $$;", &opts).unwrap();
assert!(f.iter().all(|f| f.rule_id != "add-index-non-concurrent"));
}
#[test]
fn embedded_finding_is_suppressible_on_the_do_statement() {
let sql = "-- pgsafe:ignore add-index-non-concurrent reviewed\n\
DO $$ BEGIN CREATE INDEX i ON t (c); END $$;";
let f = lint_sql(sql, &LintOptions::default()).unwrap();
let hit = f
.iter()
.find(|f| f.rule_id == "add-index-non-concurrent")
.expect("finding must still be present, just suppressed");
assert!(hit.is_suppressed());
}
#[test]
fn mixed_do_block_yields_both_embedded_and_residue_findings() {
let opts = LintOptions {
enabled_rules: ["unchecked-do-block".to_string()].into_iter().collect(),
..LintOptions::default()
};
let f = lint_sql(
"DO $$ BEGIN CREATE INDEX i ON t (c); EXECUTE 'DROP TABLE u'; END $$;",
&opts,
)
.unwrap();
assert!(f.iter().any(|f| f.rule_id == "add-index-non-concurrent"
&& f.message.starts_with("Inside a DO block:")));
assert!(f.iter().any(|f| f.rule_id == "unchecked-do-block"));
}
#[test]
fn finding_serializes_fix_when_present() {
let f = Finding {
rule_id: "add-index-non-concurrent".into(),
severity: Severity::Error,
message: "m".into(),
guidance: "g".into(),
statement_index: 0,
location: Location {
byte: 0,
line: 1,
column: 1,
},
snippet: "CREATE INDEX i ON t (c)".into(),
suppression: None,
fix: Some(Fix {
title: "Add CONCURRENTLY".into(),
edits: vec![FixEdit {
start: 12,
end: 12,
replacement: " CONCURRENTLY".into(),
}],
}),
};
let json = serde_json::to_string(&f).unwrap();
assert!(
json.contains(r#""fix":{"title":"Add CONCURRENTLY""#),
"{json}"
);
assert!(
json.contains(r#""edits":[{"start":12,"end":12,"replacement":" CONCURRENTLY"}]"#),
"{json}"
);
assert_eq!(serde_json::from_str::<Finding>(&json).unwrap(), f);
}
#[test]
fn finding_omits_fix_when_absent() {
let f = Finding {
rule_id: "drop-column".into(),
severity: Severity::Warning,
message: "m".into(),
guidance: "g".into(),
statement_index: 0,
location: Location {
byte: 0,
line: 1,
column: 1,
},
snippet: "ALTER TABLE t DROP COLUMN c".into(),
suppression: None,
fix: None,
};
assert!(!serde_json::to_string(&f).unwrap().contains("fix"));
}
#[test]
fn rule_hit_default_carries_no_fix() {
let fs = lint_sql("DROP TABLE t;", &LintOptions::default()).unwrap();
let f = fs.iter().find(|f| f.rule_id == "drop-table").unwrap();
assert!(f.fix.is_none());
}
}