#![deny(missing_docs)]
use std::collections::{BTreeMap, BTreeSet};
use pg_query::NodeEnum;
mod do_block;
mod enum_value;
mod fk_index;
mod forbid_nullable_fk;
mod forbidden_types;
mod identifier;
mod naming;
mod newtable;
mod output;
mod plpgsql;
mod require_columns;
mod require_comment;
mod require_if_exists;
mod require_not_null;
mod require_pk;
mod rules;
mod suppression;
mod timeout;
mod txn;
#[cfg(feature = "cli")]
pub mod cli;
#[cfg(feature = "cli")]
mod config;
#[cfg(feature = "cli")]
mod gitdiff;
pub use output::{
gate, lint_input, render_errors, render_finding_body, render_finding_human, render_github,
render_human, render_json, render_statement_header, FailOn, FileReport, Format, SCHEMA_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,
}
#[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>,
}
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()
}
fn push_synthesized(
findings: &mut Vec<Finding>,
sql: &str,
geoms: &[suppression::StatementGeom],
rule_id: &str,
severity: Severity,
hits: impl IntoIterator<Item = (usize, String, String)>,
) {
for (i, message, guidance) in hits {
debug_assert!(
i < geoms.len(),
"synthesized hit index {i} out of range for {rule_id}"
);
let g = &geoms[i];
let (line, column) = line_col(sql, g.start);
findings.push(Finding {
rule_id: rule_id.to_string(),
severity,
message,
guidance,
statement_index: i,
location: Location {
byte: u32::try_from(g.start).unwrap_or(u32::MAX),
line,
column,
},
snippet: sql.get(g.start..g.end).unwrap_or("").trim().to_string(),
suppression: None,
});
}
}
pub fn lint_sql(sql: &str, options: &LintOptions) -> Result<Vec<Finding>, LintError> {
let parsed = pg_query::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(..) {
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,
});
}
}
}
let mut residue_do_indices: Vec<usize> = Vec::new();
for (i, raw) in stmts.iter().enumerate() {
let Some(node) = raw.stmt.as_ref().and_then(|b| b.node.as_ref()) else {
continue;
};
if !matches!(node, NodeEnum::DoStmt(_)) {
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 Some(do_sql) = sql.get(g.start..g.end) else {
residue_do_indices.push(i); continue;
};
let analysis = plpgsql::analyze_do_block(do_sql);
let mut block_residue = analysis.has_residue;
for embedded in &analysis.statements {
let Ok(embedded_parsed) = pg_query::parse(embedded) else {
block_residue = true; continue;
};
for inner in &embedded_parsed.protobuf.stmts {
let Some(inner_node) = inner.stmt.as_ref().and_then(|b| b.node.as_ref()) else {
block_residue = true; continue;
};
for rule in rules {
if options.disabled_rules.contains(rule.id()) {
continue;
}
rule.check(inner_node, &mut hits);
for h in hits.drain(..) {
findings.push(Finding {
rule_id: rule.id().to_string(),
severity: rule.severity(),
message: format!("Inside a DO block: {}", h.message),
guidance: h.guidance,
statement_index: i,
location,
snippet: embedded.trim().to_string(),
suppression: None,
});
}
}
}
}
if block_residue {
residue_do_indices.push(i);
}
}
if !options.disabled_rules.contains(timeout::ID) {
push_synthesized(
&mut findings,
sql,
&geoms,
timeout::ID,
Severity::Warning,
timeout::require_timeout_indices(stmts, options.assume_in_transaction)
.into_iter()
.map(|i| {
(
i,
timeout::MESSAGE.to_string(),
timeout::GUIDANCE.to_string(),
)
}),
);
}
let (mut findings, new_table_dropped) = newtable::drop_new_table_findings(stmts, findings);
newtable::escalate_pre_existing_attach(stmts, &mut findings);
if !options.disabled_rules.contains(txn::ID) {
push_synthesized(
&mut findings,
sql,
&geoms,
txn::ID,
Severity::Error,
txn::concurrently_in_transaction_indices(stmts, options.assume_in_transaction)
.into_iter()
.map(|i| (i, txn::MESSAGE.to_string(), txn::GUIDANCE.to_string())),
);
}
if !options.disabled_rules.contains(identifier::ID) {
push_synthesized(
&mut findings,
sql,
&geoms,
identifier::ID,
Severity::Warning,
identifier::long_identifiers(sql).into_iter().filter_map(|(off, name, len)| {
let i = geoms.iter().position(|g| off >= g.start && off < g.end)?;
Some((
i,
format!(
"Identifier `{name}` is {len} bytes; PostgreSQL truncates identifiers to 63 \
bytes, so two names sharing a 63-byte prefix silently collide."
),
"Shorten the identifier to 63 bytes or fewer so PostgreSQL does not silently \
truncate it."
.to_string(),
))
}),
);
}
if !options.disabled_rules.contains(fk_index::ID) {
push_synthesized(
&mut findings,
sql,
&geoms,
fk_index::ID,
Severity::Warning,
fk_index::fk_without_index(stmts).into_iter().map(|(i, table, col)| {
(
i,
format!(
"Foreign key on `{table}` column `{col}` has no covering index; referential \
checks and ON DELETE/UPDATE actions on the parent scan and lock the child on \
every change."
),
format!(
"Add a covering index on the referencing column, e.g. \
`CREATE INDEX CONCURRENTLY ON {table} ({col});`."
),
)
}),
);
}
if !options.disabled_rules.contains(enum_value::ID) {
push_synthesized(
&mut findings,
sql,
&geoms,
enum_value::ID,
Severity::Warning,
enum_value::unsafe_enum_value_indices(sql, stmts, options.assume_in_transaction)
.into_iter()
.map(|i| {
(
i,
enum_value::MESSAGE.to_string(),
enum_value::GUIDANCE.to_string(),
)
}),
);
}
if options.enabled_rules.contains(require_pk::ID)
&& !options.disabled_rules.contains(require_pk::ID)
{
push_synthesized(
&mut findings,
sql,
&geoms,
require_pk::ID,
Severity::Warning,
require_pk::tables_without_primary_key(stmts)
.into_iter()
.map(|i| {
(
i,
require_pk::MESSAGE.to_string(),
require_pk::GUIDANCE.to_string(),
)
}),
);
}
if options.enabled_rules.contains(require_not_null::ID)
&& !options.disabled_rules.contains(require_not_null::ID)
{
push_synthesized(
&mut findings,
sql,
&geoms,
require_not_null::ID,
Severity::Warning,
require_not_null::nullable_columns(stmts)
.into_iter()
.map(|(i, message)| (i, message, require_not_null::GUIDANCE.to_string())),
);
}
if !options.naming_patterns.is_empty() && !options.disabled_rules.contains(naming::ID) {
push_synthesized(
&mut findings,
sql,
&geoms,
naming::ID,
Severity::Warning,
naming::naming_violations(stmts, &options.naming_patterns)
.into_iter()
.map(|(i, message)| (i, message, naming::GUIDANCE.to_string())),
);
}
if !options.forbidden_column_types.is_empty()
&& !options.disabled_rules.contains(forbidden_types::ID)
{
push_synthesized(
&mut findings,
sql,
&geoms,
forbidden_types::ID,
Severity::Warning,
forbidden_types::forbidden_violations(stmts, &options.forbidden_column_types)
.into_iter()
.map(|(i, message)| (i, message, forbidden_types::GUIDANCE.to_string())),
);
}
if options.enabled_rules.contains(require_if_exists::ID)
&& !options.disabled_rules.contains(require_if_exists::ID)
{
push_synthesized(
&mut findings,
sql,
&geoms,
require_if_exists::ID,
Severity::Warning,
require_if_exists::missing_if_exists(stmts)
.into_iter()
.map(|(i, message)| (i, message, require_if_exists::GUIDANCE.to_string())),
);
}
if options.enabled_rules.contains(do_block::ID)
&& !options.disabled_rules.contains(do_block::ID)
{
push_synthesized(
&mut findings,
sql,
&geoms,
do_block::ID,
Severity::Warning,
residue_do_indices.iter().map(|&i| {
(
i,
do_block::MESSAGE.to_string(),
do_block::GUIDANCE.to_string(),
)
}),
);
}
if options.enabled_rules.contains(require_comment::ID)
&& !options.disabled_rules.contains(require_comment::ID)
{
push_synthesized(
&mut findings,
sql,
&geoms,
require_comment::ID,
Severity::Warning,
require_comment::missing_comments(stmts)
.into_iter()
.map(|(i, message)| (i, message, require_comment::GUIDANCE.to_string())),
);
}
if !options.required_columns.is_empty() && !options.disabled_rules.contains(require_columns::ID)
{
push_synthesized(
&mut findings,
sql,
&geoms,
require_columns::ID,
Severity::Warning,
require_columns::missing_required_columns(stmts, &options.required_columns)
.into_iter()
.map(|(i, message)| (i, message, require_columns::GUIDANCE.to_string())),
);
}
if options.enabled_rules.contains(forbid_nullable_fk::ID)
&& !options.disabled_rules.contains(forbid_nullable_fk::ID)
{
push_synthesized(
&mut findings,
sql,
&geoms,
forbid_nullable_fk::ID,
Severity::Warning,
forbid_nullable_fk::nullable_fk_columns(stmts)
.into_iter()
.map(|(i, message)| (i, message, forbid_nullable_fk::GUIDANCE.to_string())),
);
}
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"));
}
}