From b12cfc0a0f7743d59f67b747ce2da456b4ad23df Mon Sep 17 00:00:00 2001 From: psteinroe Date: Sun, 14 Dec 2025 18:30:18 +0100 Subject: [PATCH 01/32] refactor: analyse --- Cargo.lock | 1 + PLAN.md | 194 ++++++++++++++++++ crates/pgls_analyse/src/context.rs | 96 --------- crates/pgls_analyse/src/filter.rs | 8 +- crates/pgls_analyse/src/lib.rs | 15 +- crates/pgls_analyse/src/metadata.rs | 157 ++++++++++++++ crates/pgls_analyse/src/registry.rs | 118 +---------- crates/pgls_analyser/Cargo.toml | 1 + crates/pgls_analyser/src/lib.rs | 44 ++-- .../src/lint/safety/add_serial_column.rs | 3 +- .../lint/safety/adding_field_with_default.rs | 3 +- .../safety/adding_foreign_key_constraint.rs | 3 +- .../src/lint/safety/adding_not_null_field.rs | 3 +- .../safety/adding_primary_key_constraint.rs | 3 +- .../src/lint/safety/adding_required_field.rs | 3 +- .../src/lint/safety/ban_char_field.rs | 3 +- ...oncurrent_index_creation_in_transaction.rs | 3 +- .../src/lint/safety/ban_drop_column.rs | 3 +- .../src/lint/safety/ban_drop_database.rs | 3 +- .../src/lint/safety/ban_drop_not_null.rs | 3 +- .../src/lint/safety/ban_drop_table.rs | 3 +- .../src/lint/safety/ban_truncate_cascade.rs | 3 +- .../src/lint/safety/changing_column_type.rs | 3 +- .../safety/constraint_missing_not_valid.rs | 3 +- .../src/lint/safety/creating_enum.rs | 3 +- .../lint/safety/disallow_unique_constraint.rs | 3 +- .../src/lint/safety/lock_timeout_warning.rs | 3 +- .../src/lint/safety/multiple_alter_table.rs | 3 +- .../src/lint/safety/prefer_big_int.rs | 3 +- .../src/lint/safety/prefer_bigint_over_int.rs | 3 +- .../safety/prefer_bigint_over_smallint.rs | 3 +- .../src/lint/safety/prefer_identity.rs | 3 +- .../src/lint/safety/prefer_jsonb.rs | 3 +- .../src/lint/safety/prefer_robust_stmts.rs | 3 +- .../src/lint/safety/prefer_text_field.rs | 3 +- .../src/lint/safety/prefer_timestamptz.rs | 3 +- .../src/lint/safety/renaming_column.rs | 3 +- .../src/lint/safety/renaming_table.rs | 3 +- .../require_concurrent_index_creation.rs | 8 +- .../require_concurrent_index_deletion.rs | 3 +- ...tatement_while_holding_access_exclusive.rs | 3 +- .../src/lint/safety/transaction_nesting.rs | 5 +- .../src/linter_context.rs} | 94 +++++++++ .../src/linter_options.rs} | 19 +- crates/pgls_analyser/src/linter_registry.rs | 118 +++++++++++ .../src/linter_rule.rs} | 188 ++--------------- crates/pgls_analyser/src/options.rs | 67 +++--- crates/pgls_analyser/src/registry.rs | 167 ++++++++++++++- crates/pgls_configuration/src/linter/rules.rs | 3 +- .../src/rules/configuration.rs | 3 +- crates/pgls_splinter/src/convert.rs | 7 +- crates/pgls_splinter/src/diagnostics.rs | 10 +- crates/pgls_workspace/src/configuration.rs | 6 +- crates/pgls_workspace/src/workspace/server.rs | 7 +- .../src/workspace/server/analyser.rs | 6 +- docs/codegen/src/rules_docs.rs | 6 +- docs/codegen/src/utils.rs | 8 +- xtask/codegen/src/generate_analyser.rs | 61 +++++- xtask/codegen/src/generate_configuration.rs | 6 +- xtask/rules_check/src/lib.rs | 12 +- 60 files changed, 997 insertions(+), 531 deletions(-) create mode 100644 PLAN.md delete mode 100644 crates/pgls_analyse/src/context.rs create mode 100644 crates/pgls_analyse/src/metadata.rs rename crates/{pgls_analyse/src/analysed_file_context.rs => pgls_analyser/src/linter_context.rs} (66%) rename crates/{pgls_analyse/src/options.rs => pgls_analyser/src/linter_options.rs} (83%) create mode 100644 crates/pgls_analyser/src/linter_registry.rs rename crates/{pgls_analyse/src/rule.rs => pgls_analyser/src/linter_rule.rs} (50%) diff --git a/Cargo.lock b/Cargo.lock index b5f2f6f57..3425f1a83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2667,6 +2667,7 @@ dependencies = [ "pgls_statement_splitter", "pgls_test_macros", "pgls_text_size", + "rustc-hash 2.1.0", "serde", "termcolor", ] diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 000000000..86e0536db --- /dev/null +++ b/PLAN.md @@ -0,0 +1,194 @@ +# Splinter Integration Plan + +## Goal +Integrate splinter into the codegen/rule setup used for the analyser, providing a consistent API (both internally and user-facing) for all types of analysers/linters. + +## Architecture Vision + +### Crate Responsibilities + +**`pgls_analyse`** - Generic framework for all analyzer types +- Generic traits: `RuleMeta`, `RuleGroup`, `GroupCategory`, `RegistryVisitor` +- Shared types: `RuleMetadata`, `RuleCategory` +- Configuration traits (execution-agnostic) +- Macros: `declare_rule!`, `declare_group!`, `declare_category!` + +**`pgls_linter`** (renamed from `pgls_analyser`) - AST-based source code linting +- `LinterRule` trait (extends `RuleMeta`) +- `LinterRuleContext` (wraps AST nodes) +- `LinterDiagnostic` (span-based diagnostics) +- `LinterRuleRegistry` (type-erased executors) + +**`pgls_splinter`** - Database-level linting +- `SplinterRule` trait (extends `RuleMeta`) +- `SplinterRuleRegistry` (metadata-based) +- `SplinterDiagnostic` (db-object-based diagnostics) +- Generated rule types from SQL files + +**`pgls_configuration`** +- `analyser/linter/` - Generated from `pgls_linter` +- `analyser/splinter/` - Generated from `pgls_splinter` +- Per-rule configuration for both + +## Implementation Phases + +### Phase 1: Refactor pgls_analyse ⏳ IN PROGRESS +Extract AST-specific code into pgls_linter, keep only generic framework in pgls_analyse. + +**Tasks:** +- [x] Analyze current `pgls_analyse` exports +- [x] Identify AST-specific vs generic code +- [x] Create new modules in `pgls_analyser`: + - [x] `linter_rule.rs` - LinterRule trait, LinterDiagnostic + - [x] `linter_context.rs` - LinterRuleContext, AnalysedFileContext + - [x] `linter_options.rs` - LinterOptions, LinterRules + - [x] `linter_registry.rs` - LinterRuleRegistry, LinterRegistryVisitor +- [x] Create `pgls_analyse/src/metadata.rs` - Generic traits only +- [x] Update `pgls_analyse/src/registry.rs` - Keep MetadataRegistry only +- [x] Update `pgls_analyse/src/lib.rs` - Export generic framework +- [x] Update `pgls_analyser/src/lib.rs` - Use new modules +- [x] Fix imports in filter.rs (RuleMeta instead of Rule) +- [x] Update generated files (options.rs, registry.rs) +- [x] Fix imports in all rule files +- [x] Add rustc-hash dependency +- [x] Verify compilation completes - **RESOLVED** +- [x] Separate visitor concerns from executor creation +- [x] Update codegen to generate factory function +- [x] Fix all import paths across workspace +- [x] Verify full workspace compiles +- [ ] Run tests + +**Resolution:** +Separated two concerns: +1. **Visitor pattern** (generic): Collects rule keys that match the filter + - Implementation in `LinterRuleRegistryBuilder::record_rule` + - Only requires `R: RuleMeta` (satisfies trait) +2. **Factory mapping** (AST-specific): Maps rule keys to executor factories + - Function `get_linter_rule_factory` in `registry.rs` + - Will be generated by codegen with full type information + - Each factory can require `R: LinterRule` + +**Changes Made:** +- `LinterRuleRegistryBuilder` stores `Vec` instead of factories +- `record_rule` just collects keys (no LinterRule bounds needed) +- `build()` calls `get_linter_rule_factory` to create executors +- Added stub `get_linter_rule_factory` in `registry.rs` (will be generated) + +**Next Steps:** +- Update codegen to generate `get_linter_rule_factory` with match on all rules + +**Design Decisions:** +- ✅ Keep `RuleDiagnostic` generic or make it linter-specific? → **Move to pgls_linter as LinterDiagnostic** (Option A) + - Rationale: Fundamentally different location models (spans vs db objects) + - LinterDiagnostic: span-based + - SplinterDiagnostic: db-object-based + +**Code Classification:** + +AST-specific (move to pgls_analyser): +- `Rule` trait +- `RuleContext` +- `RuleDiagnostic` → `LinterDiagnostic` +- `AnalysedFileContext` +- `RegistryRuleParams` +- `RuleRegistry`, `RuleRegistryBuilder` (AST execution) +- `AnalyserOptions`, `AnalyserRules` (rule options storage) + +Generic (keep in pgls_analyse): +- `RuleMeta` trait +- `RuleMetadata` struct +- `RuleGroup` trait +- `GroupCategory` trait +- `RegistryVisitor` trait +- `RuleCategory` enum +- `RuleSource` enum +- `RuleFilter`, `AnalysisFilter`, `RuleKey`, `GroupKey` +- `MetadataRegistry` +- Macros: `declare_rule!`, `declare_lint_rule!`, `declare_lint_group!`, `declare_category!` + +--- + +### Phase 2: Enhance pgls_splinter 📋 PLANNED +Add rule type generation and registry similar to linter. + +**Tasks:** +- [ ] Create `pgls_splinter/src/rule.rs` with `SplinterRule` trait +- [ ] Create `pgls_splinter/src/rules/` directory structure +- [ ] Generate rule types from SQL files +- [ ] Generate registry with `visit_registry()` function +- [ ] Update diagnostics to use generated categories + +**Structure:** +``` +pgls_splinter/src/ + rules/ + performance/ + unindexed_foreign_keys.rs + auth_rls_initplan.rs + security/ + auth_users_exposed.rs + rule.rs # SplinterRule trait + registry.rs # Generated visit_registry() +``` + +--- + +### Phase 3: Update codegen for both linters 📋 PLANNED +Generalize codegen to handle both linter types. + +**Tasks:** +- [ ] Rename `generate_analyser.rs` → `generate_linter.rs` +- [ ] Enhance `generate_splinter.rs` to generate rules + registry +- [ ] Update `generate_configuration.rs` for both linters +- [ ] Update justfile commands +- [ ] Test full generation cycle + +**Codegen outputs:** +- Linter: registry.rs, options.rs, configuration +- Splinter: rules/, registry.rs, configuration + +--- + +### Phase 4: Rename pgls_analyser → pgls_linter 📋 PLANNED +Final rename to clarify purpose. + +**Tasks:** +- [ ] Rename crate in Cargo.toml +- [ ] Update all imports +- [ ] Update documentation +- [ ] Update CLAUDE.md / AGENTS.md +- [ ] Verify tests pass + +--- + +## Progress Tracking + +### Current Status +- [x] Requirements gathering & design +- [x] Architecture proposal (Option 1 - Dual-Track) +- [ ] Phase 1: Refactor pgls_analyse - **IN PROGRESS** +- [ ] Phase 2: Enhance pgls_splinter +- [ ] Phase 3: Update codegen +- [ ] Phase 4: Rename to pgls_linter + +### Open Questions +None currently + +### Decisions Made +1. Use `LinterRule` (not `ASTRule` or `SourceCodeRule`) for clarity +2. Use `SplinterRule` for database-level rules +3. Keep codegen in xtask (not build.rs) for consistency +4. Mirror file structure between linter and splinter + +--- + +## Testing Strategy +- [ ] Existing linter tests continue to pass +- [ ] Splinter rules generate correctly from SQL +- [ ] Configuration schema validates +- [ ] Integration test: enable/disable rules via config +- [ ] Integration test: severity overrides work + +--- + +Last updated: 2025-12-14 diff --git a/crates/pgls_analyse/src/context.rs b/crates/pgls_analyse/src/context.rs deleted file mode 100644 index bf3f48762..000000000 --- a/crates/pgls_analyse/src/context.rs +++ /dev/null @@ -1,96 +0,0 @@ -use pgls_schema_cache::SchemaCache; - -use crate::{ - AnalysedFileContext, - categories::RuleCategory, - rule::{GroupCategory, Rule, RuleGroup, RuleMetadata}, -}; - -pub struct RuleContext<'a, R: Rule> { - stmt: &'a pgls_query::NodeEnum, - options: &'a R::Options, - schema_cache: Option<&'a SchemaCache>, - file_context: &'a AnalysedFileContext<'a>, -} - -impl<'a, R> RuleContext<'a, R> -where - R: Rule + Sized + 'static, -{ - #[allow(clippy::too_many_arguments)] - pub fn new( - stmt: &'a pgls_query::NodeEnum, - options: &'a R::Options, - schema_cache: Option<&'a SchemaCache>, - file_context: &'a AnalysedFileContext, - ) -> Self { - Self { - stmt, - options, - schema_cache, - file_context, - } - } - - /// Returns the group that belongs to the current rule - pub fn group(&self) -> &'static str { - ::NAME - } - - /// Returns the category that belongs to the current rule - pub fn category(&self) -> RuleCategory { - <::Category as GroupCategory>::CATEGORY - } - - /// Returns the AST root - pub fn stmt(&self) -> &pgls_query::NodeEnum { - self.stmt - } - - pub fn file_context(&self) -> &AnalysedFileContext { - self.file_context - } - - pub fn schema_cache(&self) -> Option<&SchemaCache> { - self.schema_cache - } - - /// Returns the metadata of the rule - /// - /// The metadata contains information about the rule, such as the name, version, language, and whether it is recommended. - /// - /// ## Examples - /// ```rust,ignore - /// declare_lint_rule! { - /// /// Some doc - /// pub(crate) Foo { - /// version: "0.0.0", - /// name: "foo", - /// recommended: true, - /// } - /// } - /// - /// impl Rule for Foo { - /// const CATEGORY: RuleCategory = RuleCategory::Lint; - /// type State = (); - /// type Signals = (); - /// type Options = (); - /// - /// fn run(ctx: &RuleContext) -> Self::Signals { - /// assert_eq!(ctx.metadata().name, "foo"); - /// } - /// } - /// ``` - pub fn metadata(&self) -> &RuleMetadata { - &R::METADATA - } - - /// It retrieves the options that belong to a rule, if they exist. - /// - /// In order to retrieve a typed data structure, you have to create a deserializable - /// data structure and define it inside the generic type `type Options` of the [Rule] - /// - pub fn options(&self) -> &R::Options { - self.options - } -} diff --git a/crates/pgls_analyse/src/filter.rs b/crates/pgls_analyse/src/filter.rs index bb9cdc27c..72e33cf59 100644 --- a/crates/pgls_analyse/src/filter.rs +++ b/crates/pgls_analyse/src/filter.rs @@ -2,7 +2,7 @@ use std::fmt::{Debug, Display, Formatter}; use crate::{ categories::RuleCategories, - rule::{GroupCategory, Rule, RuleGroup}, + metadata::{GroupCategory, RuleGroup, RuleMeta}, }; /// Allow filtering a single rule or group of rules by their names @@ -52,7 +52,7 @@ impl<'analysis> AnalysisFilter<'analysis> { } /// Return `true` if the rule `R` matches this filter - pub fn match_rule(&self) -> bool { + pub fn match_rule(&self) -> bool { self.match_category::<::Category>() && self.enabled_rules.is_none_or(|enabled_rules| { enabled_rules.iter().any(|filter| filter.match_rule::()) @@ -83,7 +83,7 @@ impl<'a> RuleFilter<'a> { /// Return `true` if the rule `R` matches this filter pub fn match_rule(self) -> bool where - R: Rule, + R: RuleMeta, { match self { RuleFilter::Group(group) => group == ::NAME, @@ -160,7 +160,7 @@ impl RuleKey { Self { group, rule } } - pub fn rule() -> Self { + pub fn rule() -> Self { Self::new(::NAME, R::METADATA.name) } diff --git a/crates/pgls_analyse/src/lib.rs b/crates/pgls_analyse/src/lib.rs index 29b18f362..b327b843a 100644 --- a/crates/pgls_analyse/src/lib.rs +++ b/crates/pgls_analyse/src/lib.rs @@ -1,25 +1,16 @@ -mod analysed_file_context; mod categories; -pub mod context; mod filter; pub mod macros; -pub mod options; +mod metadata; mod registry; -mod rule; // Re-exported for use in the `declare_group` macro pub use pgls_diagnostics::category_concat; -pub use crate::analysed_file_context::AnalysedFileContext; pub use crate::categories::{ ActionCategory, RefactorKind, RuleCategories, RuleCategoriesBuilder, RuleCategory, SUPPRESSION_ACTION_CATEGORY, SourceActionKind, }; pub use crate::filter::{AnalysisFilter, GroupKey, RuleFilter, RuleKey}; -pub use crate::options::{AnalyserOptions, AnalyserRules}; -pub use crate::registry::{ - MetadataRegistry, RegistryRuleParams, RegistryVisitor, RuleRegistry, RuleRegistryBuilder, -}; -pub use crate::rule::{ - GroupCategory, Rule, RuleDiagnostic, RuleGroup, RuleMeta, RuleMetadata, RuleSource, -}; +pub use crate::metadata::{GroupCategory, RuleGroup, RuleMeta, RuleMetadata, RuleSource}; +pub use crate::registry::{MetadataRegistry, RegistryVisitor}; diff --git a/crates/pgls_analyse/src/metadata.rs b/crates/pgls_analyse/src/metadata.rs new file mode 100644 index 000000000..a614e400e --- /dev/null +++ b/crates/pgls_analyse/src/metadata.rs @@ -0,0 +1,157 @@ +use pgls_diagnostics::Severity; +use std::cmp::Ordering; + +use crate::{categories::RuleCategory, registry::RegistryVisitor}; + +#[derive(Clone, Debug)] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize), + serde(rename_all = "camelCase") +)] +/// Static metadata containing information about a rule +pub struct RuleMetadata { + /// It marks if a rule is deprecated, and if so a reason has to be provided. + pub deprecated: Option<&'static str>, + /// The version when the rule was implemented + pub version: &'static str, + /// The name of this rule, displayed in the diagnostics it emits + pub name: &'static str, + /// The content of the documentation comments for this rule + pub docs: &'static str, + /// Whether a rule is recommended or not + pub recommended: bool, + /// The source URL of the rule + pub sources: &'static [RuleSource], + /// The default severity of the rule + pub severity: Severity, +} + +impl RuleMetadata { + pub const fn new( + version: &'static str, + name: &'static str, + docs: &'static str, + severity: Severity, + ) -> Self { + Self { + deprecated: None, + version, + name, + docs, + sources: &[], + recommended: false, + severity, + } + } + + pub const fn recommended(mut self, recommended: bool) -> Self { + self.recommended = recommended; + self + } + + pub const fn deprecated(mut self, deprecated: &'static str) -> Self { + self.deprecated = Some(deprecated); + self + } + + pub const fn sources(mut self, sources: &'static [RuleSource]) -> Self { + self.sources = sources; + self + } +} + +pub trait RuleMeta { + type Group: RuleGroup; + const METADATA: RuleMetadata; +} + +/// A rule group is a collection of rules under a given name, serving as a +/// "namespace" for lint rules and allowing the entire set of rules to be +/// disabled at once +pub trait RuleGroup { + type Category: GroupCategory; + /// The name of this group, displayed in the diagnostics emitted by its rules + const NAME: &'static str; + /// Register all the rules belonging to this group into `registry` + fn record_rules(registry: &mut V); +} + +/// A group category is a collection of rule groups under a given category ID, +/// serving as a broad classification on the kind of diagnostic or code action +/// these rule emit, and allowing whole categories of rules to be disabled at +/// once depending on the kind of analysis being performed +pub trait GroupCategory { + /// The category ID used for all groups and rule belonging to this category + const CATEGORY: RuleCategory; + /// Register all the groups belonging to this category into `registry` + fn record_groups(registry: &mut V); +} + +#[derive(Debug, Clone, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub enum RuleSource { + /// Rules from [Squawk](https://squawkhq.com) + Squawk(&'static str), + /// Rules from [Eugene](https://github.com/kaaveland/eugene) + Eugene(&'static str), +} + +impl PartialEq for RuleSource { + fn eq(&self, other: &Self) -> bool { + std::mem::discriminant(self) == std::mem::discriminant(other) + } +} + +impl std::fmt::Display for RuleSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Squawk(_) => write!(f, "Squawk"), + Self::Eugene(_) => write!(f, "Eugene"), + } + } +} + +impl PartialOrd for RuleSource { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for RuleSource { + fn cmp(&self, other: &Self) -> Ordering { + let self_rule = self.as_rule_name(); + let other_rule = other.as_rule_name(); + self_rule.cmp(other_rule) + } +} + +impl RuleSource { + pub fn as_rule_name(&self) -> &'static str { + match self { + Self::Squawk(rule_name) => rule_name, + Self::Eugene(rule_name) => rule_name, + } + } + + pub fn to_namespaced_rule_name(&self) -> String { + match self { + Self::Squawk(rule_name) => format!("squawk/{rule_name}"), + Self::Eugene(rule_name) => format!("eugene/{rule_name}"), + } + } + + pub fn to_rule_url(&self) -> String { + match self { + Self::Squawk(rule_name) => format!("https://squawkhq.com/docs/{rule_name}"), + Self::Eugene(rule_name) => { + format!("https://kaveland.no/eugene/hints/{rule_name}/index.html") + } + } + } + + pub fn as_url_and_rule_name(&self) -> (String, &'static str) { + (self.to_rule_url(), self.as_rule_name()) + } +} diff --git a/crates/pgls_analyse/src/registry.rs b/crates/pgls_analyse/src/registry.rs index 3278c926d..413fa2e6c 100644 --- a/crates/pgls_analyse/src/registry.rs +++ b/crates/pgls_analyse/src/registry.rs @@ -1,11 +1,8 @@ use std::{borrow, collections::BTreeSet}; use crate::{ - AnalyserOptions, - analysed_file_context::AnalysedFileContext, - context::RuleContext, - filter::{AnalysisFilter, GroupKey, RuleKey}, - rule::{GroupCategory, Rule, RuleDiagnostic, RuleGroup}, + filter::{GroupKey, RuleKey}, + metadata::{GroupCategory, RuleGroup, RuleMeta}, }; pub trait RegistryVisitor { @@ -22,7 +19,7 @@ pub trait RegistryVisitor { /// Record the rule `R` to this visitor fn record_rule(&mut self) where - R: Rule + 'static; + R: RuleMeta + 'static; } /// Key struct for a rule in the metadata map, sorted alphabetically @@ -85,115 +82,8 @@ impl MetadataRegistry { impl RegistryVisitor for MetadataRegistry { fn record_rule(&mut self) where - R: Rule + 'static, + R: RuleMeta + 'static, { self.insert_rule(::NAME, R::METADATA.name); } } - -pub struct RuleRegistryBuilder<'a> { - filter: &'a AnalysisFilter<'a>, - // Rule Registry - registry: RuleRegistry, -} - -impl RegistryVisitor for RuleRegistryBuilder<'_> { - fn record_category(&mut self) { - if self.filter.match_category::() { - C::record_groups(self); - } - } - - fn record_group(&mut self) { - if self.filter.match_group::() { - G::record_rules(self); - } - } - - /// Add the rule `R` to the list of rules stored in this registry instance - fn record_rule(&mut self) - where - R: Rule + 'static, - { - if !self.filter.match_rule::() { - return; - } - - let rule = RegistryRule::new::(); - - self.registry.rules.push(rule); - } -} - -/// The rule registry holds type-erased instances of all active analysis rules -pub struct RuleRegistry { - pub rules: Vec, -} - -impl IntoIterator for RuleRegistry { - type Item = RegistryRule; - type IntoIter = std::vec::IntoIter; - - fn into_iter(self) -> Self::IntoIter { - self.rules.into_iter() - } -} - -/// Internal representation of a single rule in the registry -#[derive(Copy, Clone)] -pub struct RegistryRule { - pub run: RuleExecutor, -} - -impl RuleRegistry { - pub fn builder<'a>(filter: &'a AnalysisFilter<'a>) -> RuleRegistryBuilder<'a> { - RuleRegistryBuilder { - filter, - registry: RuleRegistry { - rules: Default::default(), - }, - } - } -} - -pub struct RegistryRuleParams<'a> { - pub root: &'a pgls_query::NodeEnum, - pub options: &'a AnalyserOptions, - pub analysed_file_context: &'a AnalysedFileContext<'a>, - pub schema_cache: Option<&'a pgls_schema_cache::SchemaCache>, -} - -/// Executor for rule as a generic function pointer -type RuleExecutor = fn(&RegistryRuleParams) -> Vec; - -impl RegistryRule { - fn new() -> Self - where - R: Rule + 'static, - { - /// Generic implementation of RuleExecutor for any rule type R - fn run(params: &RegistryRuleParams) -> Vec - where - R: Rule + 'static, - { - let options = params.options.rule_options::().unwrap_or_default(); - - let ctx = RuleContext::new( - params.root, - &options, - params.schema_cache, - params.analysed_file_context, - ); - - R::run(&ctx) - } - - Self { run: run:: } - } -} - -impl RuleRegistryBuilder<'_> { - pub fn build(self) -> RuleRegistry { - self.registry - } -} diff --git a/crates/pgls_analyser/Cargo.toml b/crates/pgls_analyser/Cargo.toml index ce9f0bf22..26c8ba3eb 100644 --- a/crates/pgls_analyser/Cargo.toml +++ b/crates/pgls_analyser/Cargo.toml @@ -19,6 +19,7 @@ pgls_query = { workspace = true } pgls_query_ext = { workspace = true } pgls_schema_cache = { workspace = true } pgls_text_size = { workspace = true } +rustc-hash = { workspace = true } serde = { workspace = true } [dev-dependencies] diff --git a/crates/pgls_analyser/src/lib.rs b/crates/pgls_analyser/src/lib.rs index fa1d407ba..038a53239 100644 --- a/crates/pgls_analyser/src/lib.rs +++ b/crates/pgls_analyser/src/lib.rs @@ -1,21 +1,41 @@ use std::{ops::Deref, sync::LazyLock}; -use pgls_analyse::{ - AnalysedFileContext, AnalyserOptions, AnalysisFilter, MetadataRegistry, RegistryRuleParams, - RuleDiagnostic, RuleRegistry, -}; +use pgls_analyse::{AnalysisFilter, MetadataRegistry}; pub use registry::visit_registry; mod lint; +mod linter_context; +mod linter_options; +mod linter_registry; +mod linter_rule; pub mod options; mod registry; +// Re-export linter-specific types +pub use linter_context::{AnalysedFileContext, LinterRuleContext}; +pub use linter_options::{LinterOptions, LinterRules, RuleOptions}; +pub use linter_registry::{ + LinterRegistryRuleParams, LinterRuleRegistry, LinterRuleRegistryBuilder, +}; +pub use linter_rule::{LinterDiagnostic, LinterRule}; + +// For convenience in macros and rule files - keep these shorter names +pub use LinterDiagnostic as RuleDiagnostic; +pub use LinterRule as Rule; +pub use LinterRuleContext as RuleContext; + pub static METADATA: LazyLock = LazyLock::new(|| { let mut metadata = MetadataRegistry::default(); - visit_registry(&mut metadata); + // Use a separate visitor for metadata that implements pgls_analyse::RegistryVisitor + visit_metadata_registry(&mut metadata); metadata }); +// Separate function for visiting metadata registry (uses pgls_analyse::RegistryVisitor) +fn visit_metadata_registry(registry: &mut V) { + registry.record_category::(); +} + /// Main entry point to the analyser. pub struct Analyser<'a> { /// Holds the metadata for all the rules statically known to the analyser @@ -24,10 +44,10 @@ pub struct Analyser<'a> { metadata: &'a MetadataRegistry, /// Holds all rule options - options: &'a AnalyserOptions, + options: &'a LinterOptions, /// Holds all rules - registry: RuleRegistry, + registry: LinterRuleRegistry, } #[derive(Debug)] @@ -42,13 +62,13 @@ pub struct AnalyserParams<'a> { } pub struct AnalyserConfig<'a> { - pub options: &'a AnalyserOptions, + pub options: &'a LinterOptions, pub filter: AnalysisFilter<'a>, } impl<'a> Analyser<'a> { pub fn new(conf: AnalyserConfig<'a>) -> Self { - let mut builder = RuleRegistry::builder(&conf.filter); + let mut builder = LinterRuleRegistry::builder(&conf.filter); visit_registry(&mut builder); let registry = builder.build(); @@ -59,7 +79,7 @@ impl<'a> Analyser<'a> { } } - pub fn run(&self, params: AnalyserParams) -> Vec { + pub fn run(&self, params: AnalyserParams) -> Vec { let mut diagnostics = vec![]; let roots: Vec = @@ -68,7 +88,7 @@ impl<'a> Analyser<'a> { for (i, stmt) in params.stmts.into_iter().enumerate() { let stmt_diagnostics: Vec<_> = { - let rule_params = RegistryRuleParams { + let rule_params = LinterRegistryRuleParams { root: &roots[i], options: self.options, analysed_file_context: &file_context, @@ -131,7 +151,7 @@ mod tests { let ast = pgls_query::parse(SQL).expect("failed to parse SQL"); let range = TextRange::new(0.into(), u32::try_from(SQL.len()).unwrap().into()); - let options = AnalyserOptions::default(); + let options = LinterOptions::default(); let analyser = Analyser::new(crate::AnalyserConfig { options: &options, diff --git a/crates/pgls_analyser/src/lint/safety/add_serial_column.rs b/crates/pgls_analyser/src/lint/safety/add_serial_column.rs index 0c53c2eb0..c249235fa 100644 --- a/crates/pgls_analyser/src/lint/safety/add_serial_column.rs +++ b/crates/pgls_analyser/src/lint/safety/add_serial_column.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/adding_field_with_default.rs b/crates/pgls_analyser/src/lint/safety/adding_field_with_default.rs index 604f60dab..e9b55cc8b 100644 --- a/crates/pgls_analyser/src/lint/safety/adding_field_with_default.rs +++ b/crates/pgls_analyser/src/lint/safety/adding_field_with_default.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/adding_foreign_key_constraint.rs b/crates/pgls_analyser/src/lint/safety/adding_foreign_key_constraint.rs index 58059b910..5c707339c 100644 --- a/crates/pgls_analyser/src/lint/safety/adding_foreign_key_constraint.rs +++ b/crates/pgls_analyser/src/lint/safety/adding_foreign_key_constraint.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/adding_not_null_field.rs b/crates/pgls_analyser/src/lint/safety/adding_not_null_field.rs index 8b14d5227..bdbbc43b0 100644 --- a/crates/pgls_analyser/src/lint/safety/adding_not_null_field.rs +++ b/crates/pgls_analyser/src/lint/safety/adding_not_null_field.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/adding_primary_key_constraint.rs b/crates/pgls_analyser/src/lint/safety/adding_primary_key_constraint.rs index 129812035..c38225271 100644 --- a/crates/pgls_analyser/src/lint/safety/adding_primary_key_constraint.rs +++ b/crates/pgls_analyser/src/lint/safety/adding_primary_key_constraint.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/adding_required_field.rs b/crates/pgls_analyser/src/lint/safety/adding_required_field.rs index c39597d53..22a978d4d 100644 --- a/crates/pgls_analyser/src/lint/safety/adding_required_field.rs +++ b/crates/pgls_analyser/src/lint/safety/adding_required_field.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/ban_char_field.rs b/crates/pgls_analyser/src/lint/safety/ban_char_field.rs index 39b1cdbe6..f9c948129 100644 --- a/crates/pgls_analyser/src/lint/safety/ban_char_field.rs +++ b/crates/pgls_analyser/src/lint/safety/ban_char_field.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/ban_concurrent_index_creation_in_transaction.rs b/crates/pgls_analyser/src/lint/safety/ban_concurrent_index_creation_in_transaction.rs index 2de0563d1..dcc4af04e 100644 --- a/crates/pgls_analyser/src/lint/safety/ban_concurrent_index_creation_in_transaction.rs +++ b/crates/pgls_analyser/src/lint/safety/ban_concurrent_index_creation_in_transaction.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/ban_drop_column.rs b/crates/pgls_analyser/src/lint/safety/ban_drop_column.rs index 868782a98..f499d7afb 100644 --- a/crates/pgls_analyser/src/lint/safety/ban_drop_column.rs +++ b/crates/pgls_analyser/src/lint/safety/ban_drop_column.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/ban_drop_database.rs b/crates/pgls_analyser/src/lint/safety/ban_drop_database.rs index 837cb8e77..8448eb26a 100644 --- a/crates/pgls_analyser/src/lint/safety/ban_drop_database.rs +++ b/crates/pgls_analyser/src/lint/safety/ban_drop_database.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/ban_drop_not_null.rs b/crates/pgls_analyser/src/lint/safety/ban_drop_not_null.rs index ba4eb09c2..a9ed90ea8 100644 --- a/crates/pgls_analyser/src/lint/safety/ban_drop_not_null.rs +++ b/crates/pgls_analyser/src/lint/safety/ban_drop_not_null.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/ban_drop_table.rs b/crates/pgls_analyser/src/lint/safety/ban_drop_table.rs index f98bc6d26..77265d24e 100644 --- a/crates/pgls_analyser/src/lint/safety/ban_drop_table.rs +++ b/crates/pgls_analyser/src/lint/safety/ban_drop_table.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/ban_truncate_cascade.rs b/crates/pgls_analyser/src/lint/safety/ban_truncate_cascade.rs index 892086a5f..e2bd9009b 100644 --- a/crates/pgls_analyser/src/lint/safety/ban_truncate_cascade.rs +++ b/crates/pgls_analyser/src/lint/safety/ban_truncate_cascade.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; use pgls_query::protobuf::DropBehavior; diff --git a/crates/pgls_analyser/src/lint/safety/changing_column_type.rs b/crates/pgls_analyser/src/lint/safety/changing_column_type.rs index 9e7cc9e75..f093b4242 100644 --- a/crates/pgls_analyser/src/lint/safety/changing_column_type.rs +++ b/crates/pgls_analyser/src/lint/safety/changing_column_type.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/constraint_missing_not_valid.rs b/crates/pgls_analyser/src/lint/safety/constraint_missing_not_valid.rs index 5599317ec..1e27fc06e 100644 --- a/crates/pgls_analyser/src/lint/safety/constraint_missing_not_valid.rs +++ b/crates/pgls_analyser/src/lint/safety/constraint_missing_not_valid.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/creating_enum.rs b/crates/pgls_analyser/src/lint/safety/creating_enum.rs index fa7eb61d7..e6b6ccf8a 100644 --- a/crates/pgls_analyser/src/lint/safety/creating_enum.rs +++ b/crates/pgls_analyser/src/lint/safety/creating_enum.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/disallow_unique_constraint.rs b/crates/pgls_analyser/src/lint/safety/disallow_unique_constraint.rs index 5fdf710c8..c7a081133 100644 --- a/crates/pgls_analyser/src/lint/safety/disallow_unique_constraint.rs +++ b/crates/pgls_analyser/src/lint/safety/disallow_unique_constraint.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/lock_timeout_warning.rs b/crates/pgls_analyser/src/lint/safety/lock_timeout_warning.rs index f3c5af62e..7660b9db4 100644 --- a/crates/pgls_analyser/src/lint/safety/lock_timeout_warning.rs +++ b/crates/pgls_analyser/src/lint/safety/lock_timeout_warning.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/multiple_alter_table.rs b/crates/pgls_analyser/src/lint/safety/multiple_alter_table.rs index 907a29b00..b7b7178b1 100644 --- a/crates/pgls_analyser/src/lint/safety/multiple_alter_table.rs +++ b/crates/pgls_analyser/src/lint/safety/multiple_alter_table.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/prefer_big_int.rs b/crates/pgls_analyser/src/lint/safety/prefer_big_int.rs index c89a08b5a..5aae8df47 100644 --- a/crates/pgls_analyser/src/lint/safety/prefer_big_int.rs +++ b/crates/pgls_analyser/src/lint/safety/prefer_big_int.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/prefer_bigint_over_int.rs b/crates/pgls_analyser/src/lint/safety/prefer_bigint_over_int.rs index 71db41684..58d0deff3 100644 --- a/crates/pgls_analyser/src/lint/safety/prefer_bigint_over_int.rs +++ b/crates/pgls_analyser/src/lint/safety/prefer_bigint_over_int.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/prefer_bigint_over_smallint.rs b/crates/pgls_analyser/src/lint/safety/prefer_bigint_over_smallint.rs index 06e92bb11..066923b02 100644 --- a/crates/pgls_analyser/src/lint/safety/prefer_bigint_over_smallint.rs +++ b/crates/pgls_analyser/src/lint/safety/prefer_bigint_over_smallint.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/prefer_identity.rs b/crates/pgls_analyser/src/lint/safety/prefer_identity.rs index ddc378b5c..bc82b1967 100644 --- a/crates/pgls_analyser/src/lint/safety/prefer_identity.rs +++ b/crates/pgls_analyser/src/lint/safety/prefer_identity.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/prefer_jsonb.rs b/crates/pgls_analyser/src/lint/safety/prefer_jsonb.rs index 637d32f01..16f51f9ff 100644 --- a/crates/pgls_analyser/src/lint/safety/prefer_jsonb.rs +++ b/crates/pgls_analyser/src/lint/safety/prefer_jsonb.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/prefer_robust_stmts.rs b/crates/pgls_analyser/src/lint/safety/prefer_robust_stmts.rs index 021c08473..0451db031 100644 --- a/crates/pgls_analyser/src/lint/safety/prefer_robust_stmts.rs +++ b/crates/pgls_analyser/src/lint/safety/prefer_robust_stmts.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/prefer_text_field.rs b/crates/pgls_analyser/src/lint/safety/prefer_text_field.rs index ce03c9958..847ad43d5 100644 --- a/crates/pgls_analyser/src/lint/safety/prefer_text_field.rs +++ b/crates/pgls_analyser/src/lint/safety/prefer_text_field.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/prefer_timestamptz.rs b/crates/pgls_analyser/src/lint/safety/prefer_timestamptz.rs index 7dc876783..8fd048e01 100644 --- a/crates/pgls_analyser/src/lint/safety/prefer_timestamptz.rs +++ b/crates/pgls_analyser/src/lint/safety/prefer_timestamptz.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/renaming_column.rs b/crates/pgls_analyser/src/lint/safety/renaming_column.rs index a7d151480..a54fbb51a 100644 --- a/crates/pgls_analyser/src/lint/safety/renaming_column.rs +++ b/crates/pgls_analyser/src/lint/safety/renaming_column.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/renaming_table.rs b/crates/pgls_analyser/src/lint/safety/renaming_table.rs index 6c3465da8..2cc1046fc 100644 --- a/crates/pgls_analyser/src/lint/safety/renaming_table.rs +++ b/crates/pgls_analyser/src/lint/safety/renaming_table.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/require_concurrent_index_creation.rs b/crates/pgls_analyser/src/lint/safety/require_concurrent_index_creation.rs index e2289ea8a..257536d3a 100644 --- a/crates/pgls_analyser/src/lint/safety/require_concurrent_index_creation.rs +++ b/crates/pgls_analyser/src/lint/safety/require_concurrent_index_creation.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -74,10 +75,7 @@ impl Rule for RequireConcurrentIndexCreation { } } -fn is_table_created_in_file( - file_context: &pgls_analyse::AnalysedFileContext, - table_name: &str, -) -> bool { +fn is_table_created_in_file(file_context: &crate::AnalysedFileContext, table_name: &str) -> bool { // Check all statements in the file to see if this table was created for stmt in file_context.stmts { if let pgls_query::NodeEnum::CreateStmt(create_stmt) = stmt { diff --git a/crates/pgls_analyser/src/lint/safety/require_concurrent_index_deletion.rs b/crates/pgls_analyser/src/lint/safety/require_concurrent_index_deletion.rs index cac317170..7117908c4 100644 --- a/crates/pgls_analyser/src/lint/safety/require_concurrent_index_deletion.rs +++ b/crates/pgls_analyser/src/lint/safety/require_concurrent_index_deletion.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/running_statement_while_holding_access_exclusive.rs b/crates/pgls_analyser/src/lint/safety/running_statement_while_holding_access_exclusive.rs index 7d18e7b16..f481f76a9 100644 --- a/crates/pgls_analyser/src/lint/safety/running_statement_while_holding_access_exclusive.rs +++ b/crates/pgls_analyser/src/lint/safety/running_statement_while_holding_access_exclusive.rs @@ -1,4 +1,5 @@ -use pgls_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use crate::{Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyser/src/lint/safety/transaction_nesting.rs b/crates/pgls_analyser/src/lint/safety/transaction_nesting.rs index 4f76c99db..d7205c945 100644 --- a/crates/pgls_analyser/src/lint/safety/transaction_nesting.rs +++ b/crates/pgls_analyser/src/lint/safety/transaction_nesting.rs @@ -1,6 +1,5 @@ -use pgls_analyse::{ - AnalysedFileContext, Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule, -}; +use crate::{AnalysedFileContext, Rule, RuleContext, RuleDiagnostic}; +use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; diff --git a/crates/pgls_analyse/src/analysed_file_context.rs b/crates/pgls_analyser/src/linter_context.rs similarity index 66% rename from crates/pgls_analyse/src/analysed_file_context.rs rename to crates/pgls_analyser/src/linter_context.rs index 5907d70e6..d445efb41 100644 --- a/crates/pgls_analyse/src/analysed_file_context.rs +++ b/crates/pgls_analyser/src/linter_context.rs @@ -1,3 +1,97 @@ +use pgls_analyse::{GroupCategory, RuleCategory, RuleGroup, RuleMetadata}; +use pgls_schema_cache::SchemaCache; + +use crate::linter_rule::LinterRule; + +pub struct LinterRuleContext<'a, R: LinterRule> { + stmt: &'a pgls_query::NodeEnum, + options: &'a R::Options, + schema_cache: Option<&'a SchemaCache>, + file_context: &'a AnalysedFileContext<'a>, +} + +impl<'a, R> LinterRuleContext<'a, R> +where + R: LinterRule + Sized + 'static, +{ + #[allow(clippy::too_many_arguments)] + pub fn new( + stmt: &'a pgls_query::NodeEnum, + options: &'a R::Options, + schema_cache: Option<&'a SchemaCache>, + file_context: &'a AnalysedFileContext, + ) -> Self { + Self { + stmt, + options, + schema_cache, + file_context, + } + } + + /// Returns the group that belongs to the current rule + pub fn group(&self) -> &'static str { + ::NAME + } + + /// Returns the category that belongs to the current rule + pub fn category(&self) -> RuleCategory { + <::Category as GroupCategory>::CATEGORY + } + + /// Returns the AST root + pub fn stmt(&self) -> &pgls_query::NodeEnum { + self.stmt + } + + pub fn file_context(&self) -> &AnalysedFileContext { + self.file_context + } + + pub fn schema_cache(&self) -> Option<&SchemaCache> { + self.schema_cache + } + + /// Returns the metadata of the rule + /// + /// The metadata contains information about the rule, such as the name, version, language, and whether it is recommended. + /// + /// ## Examples + /// ```rust,ignore + /// declare_lint_rule! { + /// /// Some doc + /// pub(crate) Foo { + /// version: "0.0.0", + /// name: "foo", + /// recommended: true, + /// } + /// } + /// + /// impl LinterRule for Foo { + /// const CATEGORY: RuleCategory = RuleCategory::Lint; + /// type State = (); + /// type Signals = (); + /// type Options = (); + /// + /// fn run(ctx: &LinterRuleContext) -> Self::Signals { + /// assert_eq!(ctx.metadata().name, "foo"); + /// } + /// } + /// ``` + pub fn metadata(&self) -> &RuleMetadata { + &R::METADATA + } + + /// It retrieves the options that belong to a rule, if they exist. + /// + /// In order to retrieve a typed data structure, you have to create a deserializable + /// data structure and define it inside the generic type `type Options` of the [LinterRule] + /// + pub fn options(&self) -> &R::Options { + self.options + } +} + pub struct AnalysedFileContext<'a> { pub stmts: &'a Vec, pos: usize, diff --git a/crates/pgls_analyse/src/options.rs b/crates/pgls_analyser/src/linter_options.rs similarity index 83% rename from crates/pgls_analyse/src/options.rs rename to crates/pgls_analyser/src/linter_options.rs index e46b7104f..65460236c 100644 --- a/crates/pgls_analyse/src/options.rs +++ b/crates/pgls_analyser/src/linter_options.rs @@ -1,9 +1,10 @@ +use pgls_analyse::RuleKey; use rustc_hash::FxHashMap; - -use crate::{Rule, RuleKey}; use std::any::{Any, TypeId}; use std::fmt::Debug; +use crate::linter_rule::LinterRule; + /// A convenient new type data structure to store the options that belong to a rule #[derive(Debug)] pub struct RuleOptions(TypeId, Box); @@ -28,9 +29,9 @@ impl RuleOptions { /// A convenient new type data structure to insert and get rules #[derive(Debug, Default)] -pub struct AnalyserRules(FxHashMap); +pub struct LinterRules(FxHashMap); -impl AnalyserRules { +impl LinterRules { /// It tracks the options of a specific rule pub fn push_rule(&mut self, rule_key: RuleKey, options: RuleOptions) { self.0.insert(rule_key, options); @@ -42,17 +43,17 @@ impl AnalyserRules { } } -/// A set of information useful to the analyser infrastructure +/// A set of information useful to the linter infrastructure #[derive(Debug, Default)] -pub struct AnalyserOptions { +pub struct LinterOptions { /// A data structured derived from the [`postgres-language-server.jsonc`] file - pub rules: AnalyserRules, + pub rules: LinterRules, } -impl AnalyserOptions { +impl LinterOptions { pub fn rule_options(&self) -> Option where - R: Rule + 'static, + R: LinterRule + 'static, { self.rules .get_rule_options::(&RuleKey::rule::()) diff --git a/crates/pgls_analyser/src/linter_registry.rs b/crates/pgls_analyser/src/linter_registry.rs new file mode 100644 index 000000000..5840d069f --- /dev/null +++ b/crates/pgls_analyser/src/linter_registry.rs @@ -0,0 +1,118 @@ +use pgls_analyse::{AnalysisFilter, GroupCategory, RuleGroup, RuleKey}; + +use crate::linter_context::{AnalysedFileContext, LinterRuleContext}; +use crate::linter_options::LinterOptions; +use crate::linter_rule::{LinterDiagnostic, LinterRule}; + +pub struct LinterRuleRegistryBuilder<'a> { + filter: &'a AnalysisFilter<'a>, + // Store rule keys discovered during traversal + rule_keys: Vec, +} + +// Implement RegistryVisitor - only needs RuleMeta! +impl pgls_analyse::RegistryVisitor for LinterRuleRegistryBuilder<'_> { + fn record_category(&mut self) { + if self.filter.match_category::() { + C::record_groups(self); + } + } + + fn record_group(&mut self) { + if self.filter.match_group::() { + G::record_rules(self); + } + } + + fn record_rule(&mut self) + where + R: pgls_analyse::RuleMeta + 'static, + { + // Visitor just collects which rules match the filter + // No executor creation happens here - that's AST-specific + if self.filter.match_rule::() { + self.rule_keys.push(RuleKey::rule::()); + } + } +} + +/// The rule registry holds type-erased instances of all active analysis rules +pub struct LinterRuleRegistry { + pub rules: Vec, +} + +impl IntoIterator for LinterRuleRegistry { + type Item = RegistryLinterRule; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.rules.into_iter() + } +} + +/// Internal representation of a single rule in the registry +#[derive(Copy, Clone)] +pub struct RegistryLinterRule { + pub run: LinterRuleExecutor, +} + +impl LinterRuleRegistry { + pub fn builder<'a>(filter: &'a AnalysisFilter<'a>) -> LinterRuleRegistryBuilder<'a> { + LinterRuleRegistryBuilder { + filter, + rule_keys: Vec::new(), + } + } +} + +pub struct LinterRegistryRuleParams<'a> { + pub root: &'a pgls_query::NodeEnum, + pub options: &'a LinterOptions, + pub analysed_file_context: &'a AnalysedFileContext<'a>, + pub schema_cache: Option<&'a pgls_schema_cache::SchemaCache>, +} + +/// Executor for rule as a generic function pointer +type LinterRuleExecutor = fn(&LinterRegistryRuleParams) -> Vec; + +impl RegistryLinterRule { + pub fn new() -> Self + where + R: LinterRule + 'static, + { + /// Generic implementation of LinterRuleExecutor for any rule type R + fn run(params: &LinterRegistryRuleParams) -> Vec + where + R: LinterRule + 'static, + { + let options = params.options.rule_options::().unwrap_or_default(); + + let ctx = LinterRuleContext::new( + params.root, + &options, + params.schema_cache, + params.analysed_file_context, + ); + + R::run(&ctx) + } + + Self { run: run:: } + } +} + +impl LinterRuleRegistryBuilder<'_> { + pub fn build(self) -> LinterRuleRegistry { + // Look up factory for each collected rule key and create executors + let rules = self + .rule_keys + .into_iter() + .filter_map(|key| { + // This function will be generated by codegen + crate::registry::get_linter_rule_factory(&key).map(|factory| factory()) + }) + .collect(); + + LinterRuleRegistry { rules } + } +} diff --git a/crates/pgls_analyse/src/rule.rs b/crates/pgls_analyser/src/linter_rule.rs similarity index 50% rename from crates/pgls_analyse/src/rule.rs rename to crates/pgls_analyser/src/linter_rule.rs index 42a08cda3..d9fc8d006 100644 --- a/crates/pgls_analyse/src/rule.rs +++ b/crates/pgls_analyser/src/linter_rule.rs @@ -1,114 +1,28 @@ +use pgls_analyse::RuleMeta; use pgls_console::fmt::Display; use pgls_console::{MarkupBuf, markup}; use pgls_diagnostics::advice::CodeSuggestionAdvice; use pgls_diagnostics::{ Advices, Category, Diagnostic, DiagnosticTags, Location, LogCategory, MessageAndDescription, - Severity, Visit, + Visit, }; use pgls_text_size::TextRange; -use std::cmp::Ordering; use std::fmt::Debug; -use crate::{categories::RuleCategory, context::RuleContext, registry::RegistryVisitor}; +use crate::linter_context::LinterRuleContext; -#[derive(Clone, Debug)] -#[cfg_attr( - feature = "serde", - derive(serde::Serialize), - serde(rename_all = "camelCase") -)] -/// Static metadata containing information about a rule -pub struct RuleMetadata { - /// It marks if a rule is deprecated, and if so a reason has to be provided. - pub deprecated: Option<&'static str>, - /// The version when the rule was implemented - pub version: &'static str, - /// The name of this rule, displayed in the diagnostics it emits - pub name: &'static str, - /// The content of the documentation comments for this rule - pub docs: &'static str, - /// Whether a rule is recommended or not - pub recommended: bool, - /// The source URL of the rule - pub sources: &'static [RuleSource], - /// The default severity of the rule - pub severity: Severity, -} - -impl RuleMetadata { - pub const fn new( - version: &'static str, - name: &'static str, - docs: &'static str, - severity: Severity, - ) -> Self { - Self { - deprecated: None, - version, - name, - docs, - sources: &[], - recommended: false, - severity, - } - } - - pub const fn recommended(mut self, recommended: bool) -> Self { - self.recommended = recommended; - self - } - - pub const fn deprecated(mut self, deprecated: &'static str) -> Self { - self.deprecated = Some(deprecated); - self - } - - pub const fn sources(mut self, sources: &'static [RuleSource]) -> Self { - self.sources = sources; - self - } -} - -pub trait RuleMeta { - type Group: RuleGroup; - const METADATA: RuleMetadata; -} - -/// A rule group is a collection of rules under a given name, serving as a -/// "namespace" for lint rules and allowing the entire set of rules to be -/// disabled at once -pub trait RuleGroup { - type Category: GroupCategory; - /// The name of this group, displayed in the diagnostics emitted by its rules - const NAME: &'static str; - /// Register all the rules belonging to this group into `registry` - fn record_rules(registry: &mut V); -} - -/// A group category is a collection of rule groups under a given category ID, -/// serving as a broad classification on the kind of diagnostic or code action -/// these rule emit, and allowing whole categories of rules to be disabled at -/// once depending on the kind of analysis being performed -pub trait GroupCategory { - /// The category ID used for all groups and rule belonging to this category - const CATEGORY: RuleCategory; - /// Register all the groups belonging to this category into `registry` - fn record_groups(registry: &mut V); -} - -/// Trait implemented by all analysis rules: declares interest to a certain AstNode type, -/// and a callback function to be executed on all nodes matching the query to possibly -/// raise an analysis event -pub trait Rule: RuleMeta + Sized { +/// Trait implemented by all AST-based linter rules +pub trait LinterRule: RuleMeta + Sized { type Options: Default + Clone + Debug; + /// Execute the rule on the given AST context /// `schema_cache` will only be available if the user has a working database connection. - fn run(rule_context: &RuleContext) -> Vec; + fn run(rule_context: &LinterRuleContext) -> Vec; } -/// Diagnostic object returned by a single analysis rule +/// Diagnostic object returned by a single linter rule #[derive(Debug, Diagnostic, PartialEq)] -pub struct RuleDiagnostic { +pub struct LinterDiagnostic { #[category] pub(crate) category: &'static Category, #[location(span)] @@ -180,8 +94,8 @@ pub struct Detail { pub range: Option, } -impl RuleDiagnostic { - /// Creates a new [`RuleDiagnostic`] with a severity and title that will be +impl LinterDiagnostic { + /// Creates a new [`LinterDiagnostic`] with a severity and title that will be /// used in a builder-like way to modify labels. pub fn new(category: &'static Category, span: Option, title: impl Display) -> Self { let message = markup!({ title }).to_owned(); @@ -224,9 +138,9 @@ impl RuleDiagnostic { self } - /// Attaches a label to this [`RuleDiagnostic`]. + /// Attaches a label to this [`LinterDiagnostic`]. /// - /// The given span has to be in the file that was provided while creating this [`RuleDiagnostic`]. + /// The given span has to be in the file that was provided while creating this [`LinterDiagnostic`]. pub fn label(mut self, span: Option, msg: impl Display) -> Self { self.rule_advice.details.push(Detail { log_category: LogCategory::Info, @@ -236,12 +150,12 @@ impl RuleDiagnostic { self } - /// Attaches a detailed message to this [`RuleDiagnostic`]. + /// Attaches a detailed message to this [`LinterDiagnostic`]. pub fn detail(self, span: Option, msg: impl Display) -> Self { self.label(span, msg) } - /// Adds a footer to this [`RuleDiagnostic`], which will be displayed under the actual error. + /// Adds a footer to this [`LinterDiagnostic`], which will be displayed under the actual error. fn footer(mut self, log_category: LogCategory, msg: impl Display) -> Self { self.rule_advice .notes @@ -249,7 +163,7 @@ impl RuleDiagnostic { self } - /// Adds a footer to this [`RuleDiagnostic`], with the `Info` log category. + /// Adds a footer to this [`LinterDiagnostic`], with the `Info` log category. pub fn note(self, msg: impl Display) -> Self { self.footer(LogCategory::Info, msg) } @@ -270,7 +184,7 @@ impl RuleDiagnostic { self } - /// Adds a footer to this [`RuleDiagnostic`], with the `Warn` severity. + /// Adds a footer to this [`LinterDiagnostic`], with the `Warn` severity. pub fn warning(self, msg: impl Display) -> Self { self.footer(LogCategory::Warn, msg) } @@ -284,71 +198,3 @@ impl RuleDiagnostic { self.category.name() } } - -#[derive(Debug, Clone, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize))] -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -pub enum RuleSource { - /// Rules from [Squawk](https://squawkhq.com) - Squawk(&'static str), - /// Rules from [Eugene](https://github.com/kaaveland/eugene) - Eugene(&'static str), -} - -impl PartialEq for RuleSource { - fn eq(&self, other: &Self) -> bool { - std::mem::discriminant(self) == std::mem::discriminant(other) - } -} - -impl std::fmt::Display for RuleSource { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Squawk(_) => write!(f, "Squawk"), - Self::Eugene(_) => write!(f, "Eugene"), - } - } -} - -impl PartialOrd for RuleSource { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for RuleSource { - fn cmp(&self, other: &Self) -> Ordering { - let self_rule = self.as_rule_name(); - let other_rule = other.as_rule_name(); - self_rule.cmp(other_rule) - } -} - -impl RuleSource { - pub fn as_rule_name(&self) -> &'static str { - match self { - Self::Squawk(rule_name) => rule_name, - Self::Eugene(rule_name) => rule_name, - } - } - - pub fn to_namespaced_rule_name(&self) -> String { - match self { - Self::Squawk(rule_name) => format!("squawk/{rule_name}"), - Self::Eugene(rule_name) => format!("eugene/{rule_name}"), - } - } - - pub fn to_rule_url(&self) -> String { - match self { - Self::Squawk(rule_name) => format!("https://squawkhq.com/docs/{rule_name}"), - Self::Eugene(rule_name) => { - format!("https://kaveland.no/eugene/hints/{rule_name}/index.html") - } - } - } - - pub fn as_url_and_rule_name(&self) -> (String, &'static str) { - (self.to_rule_url(), self.as_rule_name()) - } -} diff --git a/crates/pgls_analyser/src/options.rs b/crates/pgls_analyser/src/options.rs index 0cd484bf2..b04539c45 100644 --- a/crates/pgls_analyser/src/options.rs +++ b/crates/pgls_analyser/src/options.rs @@ -2,53 +2,54 @@ use crate::lint; pub type AddSerialColumn = - ::Options; -pub type AddingFieldWithDefault = < lint :: safety :: adding_field_with_default :: AddingFieldWithDefault as pgls_analyse :: Rule > :: Options ; -pub type AddingForeignKeyConstraint = < lint :: safety :: adding_foreign_key_constraint :: AddingForeignKeyConstraint as pgls_analyse :: Rule > :: Options ; + ::Options; +pub type AddingFieldWithDefault = + ::Options; +pub type AddingForeignKeyConstraint = < lint :: safety :: adding_foreign_key_constraint :: AddingForeignKeyConstraint as crate::LinterRule > :: Options ; pub type AddingNotNullField = - ::Options; -pub type AddingPrimaryKeyConstraint = < lint :: safety :: adding_primary_key_constraint :: AddingPrimaryKeyConstraint as pgls_analyse :: Rule > :: Options ; + ::Options; +pub type AddingPrimaryKeyConstraint = < lint :: safety :: adding_primary_key_constraint :: AddingPrimaryKeyConstraint as crate::LinterRule > :: Options ; pub type AddingRequiredField = - ::Options; -pub type BanCharField = ::Options; -pub type BanConcurrentIndexCreationInTransaction = < lint :: safety :: ban_concurrent_index_creation_in_transaction :: BanConcurrentIndexCreationInTransaction as pgls_analyse :: Rule > :: Options ; + ::Options; +pub type BanCharField = ::Options; +pub type BanConcurrentIndexCreationInTransaction = < lint :: safety :: ban_concurrent_index_creation_in_transaction :: BanConcurrentIndexCreationInTransaction as crate::LinterRule > :: Options ; pub type BanDropColumn = - ::Options; + ::Options; pub type BanDropDatabase = - ::Options; + ::Options; pub type BanDropNotNull = - ::Options; -pub type BanDropTable = ::Options; + ::Options; +pub type BanDropTable = ::Options; pub type BanTruncateCascade = - ::Options; + ::Options; pub type ChangingColumnType = - ::Options; -pub type ConstraintMissingNotValid = < lint :: safety :: constraint_missing_not_valid :: ConstraintMissingNotValid as pgls_analyse :: Rule > :: Options ; -pub type CreatingEnum = ::Options; -pub type DisallowUniqueConstraint = < lint :: safety :: disallow_unique_constraint :: DisallowUniqueConstraint as pgls_analyse :: Rule > :: Options ; + ::Options; +pub type ConstraintMissingNotValid = < lint :: safety :: constraint_missing_not_valid :: ConstraintMissingNotValid as crate::LinterRule > :: Options ; +pub type CreatingEnum = ::Options; +pub type DisallowUniqueConstraint = < lint :: safety :: disallow_unique_constraint :: DisallowUniqueConstraint as crate::LinterRule > :: Options ; pub type LockTimeoutWarning = - ::Options; + ::Options; pub type MultipleAlterTable = - ::Options; -pub type PreferBigInt = ::Options; + ::Options; +pub type PreferBigInt = ::Options; pub type PreferBigintOverInt = - ::Options; -pub type PreferBigintOverSmallint = < lint :: safety :: prefer_bigint_over_smallint :: PreferBigintOverSmallint as pgls_analyse :: Rule > :: Options ; + ::Options; +pub type PreferBigintOverSmallint = < lint :: safety :: prefer_bigint_over_smallint :: PreferBigintOverSmallint as crate::LinterRule > :: Options ; pub type PreferIdentity = - ::Options; -pub type PreferJsonb = ::Options; + ::Options; +pub type PreferJsonb = ::Options; pub type PreferRobustStmts = - ::Options; + ::Options; pub type PreferTextField = - ::Options; + ::Options; pub type PreferTimestamptz = - ::Options; + ::Options; pub type RenamingColumn = - ::Options; + ::Options; pub type RenamingTable = - ::Options; -pub type RequireConcurrentIndexCreation = < lint :: safety :: require_concurrent_index_creation :: RequireConcurrentIndexCreation as pgls_analyse :: Rule > :: Options ; -pub type RequireConcurrentIndexDeletion = < lint :: safety :: require_concurrent_index_deletion :: RequireConcurrentIndexDeletion as pgls_analyse :: Rule > :: Options ; -pub type RunningStatementWhileHoldingAccessExclusive = < lint :: safety :: running_statement_while_holding_access_exclusive :: RunningStatementWhileHoldingAccessExclusive as pgls_analyse :: Rule > :: Options ; + ::Options; +pub type RequireConcurrentIndexCreation = < lint :: safety :: require_concurrent_index_creation :: RequireConcurrentIndexCreation as crate::LinterRule > :: Options ; +pub type RequireConcurrentIndexDeletion = < lint :: safety :: require_concurrent_index_deletion :: RequireConcurrentIndexDeletion as crate::LinterRule > :: Options ; +pub type RunningStatementWhileHoldingAccessExclusive = < lint :: safety :: running_statement_while_holding_access_exclusive :: RunningStatementWhileHoldingAccessExclusive as crate::LinterRule > :: Options ; pub type TransactionNesting = - ::Options; + ::Options; diff --git a/crates/pgls_analyser/src/registry.rs b/crates/pgls_analyser/src/registry.rs index 4978e40fd..29862d096 100644 --- a/crates/pgls_analyser/src/registry.rs +++ b/crates/pgls_analyser/src/registry.rs @@ -1,6 +1,171 @@ //! Generated file, do not edit by hand, see `xtask/codegen` -use pgls_analyse::RegistryVisitor; +use crate::linter_registry::RegistryLinterRule; +use pgls_analyse::{RegistryVisitor, RuleKey}; pub fn visit_registry(registry: &mut V) { registry.record_category::(); } +#[doc = r" Maps rule keys to factory functions that create rule executors"] +#[doc = r" This function is generated by codegen and includes all linter rules"] +pub fn get_linter_rule_factory(key: &RuleKey) -> Option RegistryLinterRule>> { + match key.rule_name() { + "addSerialColumn" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::add_serial_column::AddSerialColumn, + >() + })), + "addingFieldWithDefault" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::adding_field_with_default::AddingFieldWithDefault, + >() + })), + "addingForeignKeyConstraint" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::adding_foreign_key_constraint::AddingForeignKeyConstraint, + >() + })), + "addingNotNullField" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::adding_not_null_field::AddingNotNullField, + >() + })), + "addingPrimaryKeyConstraint" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::adding_primary_key_constraint::AddingPrimaryKeyConstraint, + >() + })), + "addingRequiredField" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::adding_required_field::AddingRequiredField, + >() + })), + "banCharField" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::ban_char_field::BanCharField, + >() + })), + "banConcurrentIndexCreationInTransaction" => Some(Box::new(|| { + crate :: linter_registry :: RegistryLinterRule :: new :: < crate::lint::safety::ban_concurrent_index_creation_in_transaction :: BanConcurrentIndexCreationInTransaction > () + })), + "banDropColumn" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::ban_drop_column::BanDropColumn, + >() + })), + "banDropDatabase" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::ban_drop_database::BanDropDatabase, + >() + })), + "banDropNotNull" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::ban_drop_not_null::BanDropNotNull, + >() + })), + "banDropTable" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::ban_drop_table::BanDropTable, + >() + })), + "banTruncateCascade" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::ban_truncate_cascade::BanTruncateCascade, + >() + })), + "changingColumnType" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::changing_column_type::ChangingColumnType, + >() + })), + "constraintMissingNotValid" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::constraint_missing_not_valid::ConstraintMissingNotValid, + >() + })), + "creatingEnum" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::creating_enum::CreatingEnum, + >() + })), + "disallowUniqueConstraint" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::disallow_unique_constraint::DisallowUniqueConstraint, + >() + })), + "lockTimeoutWarning" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::lock_timeout_warning::LockTimeoutWarning, + >() + })), + "multipleAlterTable" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::multiple_alter_table::MultipleAlterTable, + >() + })), + "preferBigInt" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::prefer_big_int::PreferBigInt, + >() + })), + "preferBigintOverInt" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::prefer_bigint_over_int::PreferBigintOverInt, + >() + })), + "preferBigintOverSmallint" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::prefer_bigint_over_smallint::PreferBigintOverSmallint, + >() + })), + "preferIdentity" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::prefer_identity::PreferIdentity, + >() + })), + "preferJsonb" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::prefer_jsonb::PreferJsonb, + >() + })), + "preferRobustStmts" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::prefer_robust_stmts::PreferRobustStmts, + >() + })), + "preferTextField" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::prefer_text_field::PreferTextField, + >() + })), + "preferTimestamptz" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::prefer_timestamptz::PreferTimestamptz, + >() + })), + "renamingColumn" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::renaming_column::RenamingColumn, + >() + })), + "renamingTable" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::renaming_table::RenamingTable, + >() + })), + "requireConcurrentIndexCreation" => Some(Box::new(|| { + crate :: linter_registry :: RegistryLinterRule :: new :: < crate::lint::safety::require_concurrent_index_creation :: RequireConcurrentIndexCreation > () + })), + "requireConcurrentIndexDeletion" => Some(Box::new(|| { + crate :: linter_registry :: RegistryLinterRule :: new :: < crate::lint::safety::require_concurrent_index_deletion :: RequireConcurrentIndexDeletion > () + })), + "runningStatementWhileHoldingAccessExclusive" => Some(Box::new(|| { + crate :: linter_registry :: RegistryLinterRule :: new :: < crate::lint::safety::running_statement_while_holding_access_exclusive :: RunningStatementWhileHoldingAccessExclusive > () + })), + "transactionNesting" => Some(Box::new(|| { + crate::linter_registry::RegistryLinterRule::new::< + crate::lint::safety::transaction_nesting::TransactionNesting, + >() + })), + _ => None, + } +} diff --git a/crates/pgls_configuration/src/linter/rules.rs b/crates/pgls_configuration/src/linter/rules.rs index 5e27b92d1..32fc1a2fc 100644 --- a/crates/pgls_configuration/src/linter/rules.rs +++ b/crates/pgls_configuration/src/linter/rules.rs @@ -3,7 +3,8 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rules::{RuleConfiguration, RulePlainConfiguration}; use biome_deserialize_macros::Merge; -use pgls_analyse::{RuleFilter, options::RuleOptions}; +use pgls_analyse::RuleFilter; +use pgls_analyser::RuleOptions; use pgls_diagnostics::{Category, Severity}; use rustc_hash::FxHashSet; #[cfg(feature = "schema")] diff --git a/crates/pgls_configuration/src/rules/configuration.rs b/crates/pgls_configuration/src/rules/configuration.rs index a3e43f63c..ad93abeeb 100644 --- a/crates/pgls_configuration/src/rules/configuration.rs +++ b/crates/pgls_configuration/src/rules/configuration.rs @@ -1,6 +1,7 @@ use biome_deserialize::Merge; use biome_deserialize_macros::Deserializable; -use pgls_analyse::options::RuleOptions; +use pgls_analyse::RuleFilter; +use pgls_analyser::RuleOptions; use pgls_diagnostics::Severity; #[cfg(feature = "schema")] use schemars::JsonSchema; diff --git a/crates/pgls_splinter/src/convert.rs b/crates/pgls_splinter/src/convert.rs index 926de1d40..c5d0c7904 100644 --- a/crates/pgls_splinter/src/convert.rs +++ b/crates/pgls_splinter/src/convert.rs @@ -1,4 +1,4 @@ -use pgls_diagnostics::{Category, Severity, category}; +use pgls_diagnostics::{Category, DatabaseObjectOwned, Severity, category}; use serde_json::Value; use crate::{SplinterAdvices, SplinterDiagnostic, SplinterQueryResult}; @@ -22,6 +22,11 @@ impl From for SplinterDiagnostic { category: rule_name_to_category(&result.name, &group), message: result.detail.into(), severity, + db_object: object_name.as_ref().map(|name| DatabaseObjectOwned { + schema: schema.clone(), + name: name.clone(), + object_type: object_type.clone(), + }), advices: SplinterAdvices { description: result.description, schema, diff --git a/crates/pgls_splinter/src/diagnostics.rs b/crates/pgls_splinter/src/diagnostics.rs index 7ff6ca942..ac9e32d77 100644 --- a/crates/pgls_splinter/src/diagnostics.rs +++ b/crates/pgls_splinter/src/diagnostics.rs @@ -1,5 +1,6 @@ use pgls_diagnostics::{ - Advices, Category, Diagnostic, LogCategory, MessageAndDescription, Severity, Visit, + Advices, Category, DatabaseObjectOwned, Diagnostic, LogCategory, MessageAndDescription, + Severity, Visit, }; use serde_json::Value; use std::io; @@ -10,10 +11,9 @@ pub struct SplinterDiagnostic { #[category] pub category: &'static Category, - // TODO: add new location type for database objects - // This will map schema + object_name to source code location - // #[location(span)] - // pub span: Option, + #[location(database_object)] + pub db_object: Option, + #[message] #[description] pub message: MessageAndDescription, diff --git a/crates/pgls_workspace/src/configuration.rs b/crates/pgls_workspace/src/configuration.rs index 0804a1c84..ce5ce6e36 100644 --- a/crates/pgls_workspace/src/configuration.rs +++ b/crates/pgls_workspace/src/configuration.rs @@ -6,7 +6,7 @@ use std::{ }; use biome_deserialize::Merge; -use pgls_analyse::AnalyserRules; +use pgls_analyser::LinterRules; use pgls_configuration::{ ConfigurationDiagnostic, ConfigurationPathHint, ConfigurationPayload, PartialConfiguration, VERSION, diagnostics::CantLoadExtendFile, push_to_analyser_rules, @@ -216,8 +216,8 @@ pub fn create_config( } /// Returns the rules applied to a specific [Path], given the [Settings] -pub fn to_analyser_rules(settings: &Settings) -> AnalyserRules { - let mut analyser_rules = AnalyserRules::default(); +pub fn to_analyser_rules(settings: &Settings) -> LinterRules { + let mut analyser_rules = LinterRules::default(); if let Some(rules) = settings.linter.rules.as_ref() { push_to_analyser_rules(rules, pgls_analyser::METADATA.deref(), &mut analyser_rules); } diff --git a/crates/pgls_workspace/src/workspace/server.rs b/crates/pgls_workspace/src/workspace/server.rs index 2ff11ec5f..e5feed085 100644 --- a/crates/pgls_workspace/src/workspace/server.rs +++ b/crates/pgls_workspace/src/workspace/server.rs @@ -15,8 +15,9 @@ use document::{ }; use futures::{StreamExt, stream}; use pg_query::convert_to_positional_params; -use pgls_analyse::{AnalyserOptions, AnalysisFilter}; -use pgls_analyser::{Analyser, AnalyserConfig, AnalyserParams}; +use pgls_analyse::AnalysisFilter; +use pgls_analyser::{Analyser, AnalyserConfig, AnalyserParams, LinterOptions}; + use pgls_diagnostics::{ Diagnostic, DiagnosticExt, Error, Severity, serde::Diagnostic as SDiagnostic, }; @@ -601,7 +602,7 @@ impl Workspace for WorkspaceServer { .with_linter_rules(¶ms.only, ¶ms.skip) .finish(); - let options = AnalyserOptions { + let options = LinterOptions { rules: to_analyser_rules(settings), }; diff --git a/crates/pgls_workspace/src/workspace/server/analyser.rs b/crates/pgls_workspace/src/workspace/server/analyser.rs index b7382f2bb..d8de3bbb3 100644 --- a/crates/pgls_workspace/src/workspace/server/analyser.rs +++ b/crates/pgls_workspace/src/workspace/server/analyser.rs @@ -1,4 +1,4 @@ -use pgls_analyse::{GroupCategory, RegistryVisitor, Rule, RuleCategory, RuleFilter, RuleGroup}; +use pgls_analyse::{GroupCategory, RegistryVisitor, RuleCategory, RuleFilter, RuleGroup, RuleMeta}; use pgls_configuration::RuleSelector; use rustc_hash::FxHashSet; @@ -91,7 +91,7 @@ impl<'a, 'b> LintVisitor<'a, 'b> { fn push_rule(&mut self) where - R: Rule + 'static, + R: RuleMeta + 'static, { // Do not report unused suppression comment diagnostics if a single rule is run. for selector in self.only { @@ -132,7 +132,7 @@ impl RegistryVisitor for LintVisitor<'_, '_> { fn record_rule(&mut self) where - R: Rule + 'static, + R: RuleMeta + 'static, { self.push_rule::() } diff --git a/docs/codegen/src/rules_docs.rs b/docs/codegen/src/rules_docs.rs index eae0340e5..f68950315 100644 --- a/docs/codegen/src/rules_docs.rs +++ b/docs/codegen/src/rules_docs.rs @@ -1,7 +1,7 @@ use anyhow::{Result, bail}; use biome_string_case::Case; -use pgls_analyse::{AnalyserOptions, AnalysisFilter, RuleFilter, RuleMetadata}; -use pgls_analyser::{AnalysableStatement, Analyser, AnalyserConfig}; +use pgls_analyse::{AnalysisFilter, RuleFilter, RuleMetadata}; +use pgls_analyser::{AnalysableStatement, Analyser, AnalyserConfig, LinterOptions}; use pgls_console::StdDisplay; use pgls_diagnostics::{Diagnostic, DiagnosticExt, PrintDiagnostic}; use pgls_query_ext::diagnostics::SyntaxDiagnostic; @@ -431,7 +431,7 @@ fn print_diagnostics( ..AnalysisFilter::default() }; let settings = Settings::default(); - let options = AnalyserOptions::default(); + let options = LinterOptions::default(); let analyser = Analyser::new(AnalyserConfig { options: &options, filter, diff --git a/docs/codegen/src/utils.rs b/docs/codegen/src/utils.rs index 692ef9308..8d53cf909 100644 --- a/docs/codegen/src/utils.rs +++ b/docs/codegen/src/utils.rs @@ -1,4 +1,6 @@ -use pgls_analyse::{GroupCategory, RegistryVisitor, Rule, RuleCategory, RuleGroup, RuleMetadata}; +use pgls_analyse::{ + GroupCategory, RegistryVisitor, RuleCategory, RuleGroup, RuleMeta, RuleMetadata, +}; use regex::Regex; use std::collections::BTreeMap; @@ -32,7 +34,7 @@ pub(crate) struct LintRulesVisitor { impl LintRulesVisitor { fn push_rule(&mut self) where - R: Rule + 'static, + R: RuleMeta + 'static, { let group = self .groups @@ -52,7 +54,7 @@ impl RegistryVisitor for LintRulesVisitor { fn record_rule(&mut self) where - R: Rule + 'static, + R: RuleMeta + 'static, { self.push_rule::() } diff --git a/xtask/codegen/src/generate_analyser.rs b/xtask/codegen/src/generate_analyser.rs index 105ca7a82..fc1257d5c 100644 --- a/xtask/codegen/src/generate_analyser.rs +++ b/xtask/codegen/src/generate_analyser.rs @@ -15,11 +15,12 @@ pub fn generate_analyser() -> Result<()> { fn generate_linter() -> Result<()> { let base_path = project_root().join("crates/pgls_analyser/src"); let mut analysers = BTreeMap::new(); - generate_category("lint", &mut analysers, &base_path)?; + let mut all_rules = BTreeMap::new(); + generate_category("lint", &mut analysers, &mut all_rules, &base_path)?; generate_options(&base_path)?; - update_linter_registry_builder(analysers) + update_linter_registry_builder(analysers, all_rules) } fn generate_options(base_path: &Path) -> Result<()> { @@ -39,7 +40,7 @@ fn generate_options(base_path: &Path) -> Result<()> { let rule_module_name = format_ident!("{}", rule_filename); let rule_name = format_ident!("{}", rule_name); rules_options.insert(rule_filename.to_string(), quote! { - pub type #rule_name = <#category_name::#group_name::#rule_module_name::#rule_name as pgls_analyse::Rule>::Options; + pub type #rule_name = <#category_name::#group_name::#rule_module_name::#rule_name as crate::LinterRule>::Options; }); } } @@ -63,6 +64,7 @@ fn generate_options(base_path: &Path) -> Result<()> { fn generate_category( name: &'static str, entries: &mut BTreeMap<&'static str, TokenStream>, + all_rules: &mut BTreeMap, base_path: &Path, ) -> Result<()> { let path = base_path.join(name); @@ -81,7 +83,7 @@ fn generate_category( .to_str() .context("could not convert file name to string")?; - generate_group(name, file_name, base_path)?; + generate_group(name, file_name, all_rules, base_path)?; let module_name = format_ident!("{}", file_name); let group_name = format_ident!("{}", Case::Pascal.convert(file_name)); @@ -135,7 +137,12 @@ fn generate_category( Ok(()) } -fn generate_group(category: &'static str, group: &str, base_path: &Path) -> Result<()> { +fn generate_group( + category: &'static str, + group: &str, + all_rules: &mut BTreeMap, + base_path: &Path, +) -> Result<()> { let path = base_path.join(category).join(group); let mut rules = BTreeMap::new(); @@ -151,7 +158,21 @@ fn generate_group(category: &'static str, group: &str, base_path: &Path) -> Resu let key = rule_type.clone(); let module_name = format_ident!("{}", file_name); - let rule_type = format_ident!("{}", rule_type); + let rule_type_ident = format_ident!("{}", rule_type); + + // Collect for factory generation + // Key is the rule name (camelCase for config), value is (full_path_tokens, metadata_name) + let category_ident = format_ident!("{}", category); + let group_module_ident = format_ident!("{}", group); // Module name (lowercase) + all_rules.insert( + Case::Camel.convert(&key), + ( + quote! { + crate::#category_ident::#group_module_ident::#module_name::#rule_type_ident + }, + file_name.to_string(), + ), + ); rules.insert( key, @@ -160,7 +181,7 @@ fn generate_group(category: &'static str, group: &str, base_path: &Path) -> Resu pub mod #module_name; }, quote! { - self::#module_name::#rule_type + self::#module_name::#rule_type_ident }, ), ); @@ -199,17 +220,39 @@ fn generate_group(category: &'static str, group: &str, base_path: &Path) -> Resu Ok(()) } -fn update_linter_registry_builder(rules: BTreeMap<&'static str, TokenStream>) -> Result<()> { +fn update_linter_registry_builder( + rules: BTreeMap<&'static str, TokenStream>, + all_rules: BTreeMap, +) -> Result<()> { let path = project_root().join("crates/pgls_analyser/src/registry.rs"); let categories = rules.into_values(); + // Generate factory match arms for all rules + let factory_arms = all_rules.iter().map(|(rule_name, (rule_path, _))| { + quote! { + #rule_name => Some(Box::new(|| crate::linter_registry::RegistryLinterRule::new::<#rule_path>())) + } + }); + let tokens = xtask::reformat(quote! { - use pgls_analyse::RegistryVisitor; + use pgls_analyse::{RegistryVisitor, RuleKey}; + use crate::linter_registry::RegistryLinterRule; pub fn visit_registry(registry: &mut V) { #( #categories )* } + + /// Maps rule keys to factory functions that create rule executors + /// This function is generated by codegen and includes all linter rules + pub fn get_linter_rule_factory( + key: &RuleKey, + ) -> Option RegistryLinterRule>> { + match key.rule_name() { + #( #factory_arms, )* + _ => None, + } + } })?; fs2::write(path, tokens)?; diff --git a/xtask/codegen/src/generate_configuration.rs b/xtask/codegen/src/generate_configuration.rs index f4a922129..2a9913bf1 100644 --- a/xtask/codegen/src/generate_configuration.rs +++ b/xtask/codegen/src/generate_configuration.rs @@ -1,6 +1,8 @@ use crate::{to_capitalized, update}; use biome_string_case::Case; -use pgls_analyse::{GroupCategory, RegistryVisitor, Rule, RuleCategory, RuleGroup, RuleMetadata}; +use pgls_analyse::{ + GroupCategory, RegistryVisitor, RuleCategory, RuleGroup, RuleMeta, RuleMetadata, +}; use pgls_diagnostics::Severity; use proc_macro2::{Ident, Literal, Span, TokenStream}; use pulldown_cmark::{Event, Parser, Tag, TagEnd}; @@ -92,7 +94,7 @@ impl RegistryVisitor for CategoryRulesVisitor { fn record_rule(&mut self) where - R: Rule + 'static, + R: RuleMeta + 'static, { self.groups .entry(::NAME) diff --git a/xtask/rules_check/src/lib.rs b/xtask/rules_check/src/lib.rs index 3276e1a00..508d8c831 100644 --- a/xtask/rules_check/src/lib.rs +++ b/xtask/rules_check/src/lib.rs @@ -4,10 +4,10 @@ use std::{fmt::Write, slice}; use anyhow::bail; use pgls_analyse::{ - AnalyserOptions, AnalysisFilter, GroupCategory, RegistryVisitor, Rule, RuleCategory, - RuleFilter, RuleGroup, RuleMetadata, + AnalysisFilter, GroupCategory, RegistryVisitor, RuleCategory, RuleFilter, RuleGroup, RuleMeta, + RuleMetadata, }; -use pgls_analyser::{AnalysableStatement, Analyser, AnalyserConfig}; +use pgls_analyser::{AnalysableStatement, Analyser, AnalyserConfig, LinterOptions}; use pgls_console::{markup, Console}; use pgls_diagnostics::{Diagnostic, DiagnosticExt, PrintDiagnostic}; use pgls_query_ext::diagnostics::SyntaxDiagnostic; @@ -23,7 +23,7 @@ pub fn check_rules() -> anyhow::Result<()> { impl LintRulesVisitor { fn push_rule(&mut self) where - R: Rule + 'static, + R: RuleMeta + 'static, { self.groups .entry(::NAME) @@ -41,7 +41,7 @@ pub fn check_rules() -> anyhow::Result<()> { fn record_rule(&mut self) where - R: Rule + 'static, + R: RuleMeta + 'static, { self.push_rule::() } @@ -120,7 +120,7 @@ fn assert_lint( ..AnalysisFilter::default() }; let settings = Settings::default(); - let options = AnalyserOptions::default(); + let options = LinterOptions::default(); let analyser = Analyser::new(AnalyserConfig { options: &options, filter, From dc098c0a2c2307190b351567c0688541dfac14a4 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Sun, 14 Dec 2025 18:40:52 +0100 Subject: [PATCH 02/32] fix: dont use dyn --- crates/pgls_analyser/src/linter_registry.rs | 4 +- crates/pgls_analyser/src/options.rs | 18 +-- crates/pgls_analyser/src/registry.rs | 165 +------------------- xtask/codegen/src/generate_analyser.rs | 14 +- 4 files changed, 21 insertions(+), 180 deletions(-) diff --git a/crates/pgls_analyser/src/linter_registry.rs b/crates/pgls_analyser/src/linter_registry.rs index 5840d069f..3d64db4b8 100644 --- a/crates/pgls_analyser/src/linter_registry.rs +++ b/crates/pgls_analyser/src/linter_registry.rs @@ -103,13 +103,13 @@ impl RegistryLinterRule { impl LinterRuleRegistryBuilder<'_> { pub fn build(self) -> LinterRuleRegistry { - // Look up factory for each collected rule key and create executors + // Look up executor for each collected rule key let rules = self .rule_keys .into_iter() .filter_map(|key| { // This function will be generated by codegen - crate::registry::get_linter_rule_factory(&key).map(|factory| factory()) + crate::registry::get_linter_rule_executor(&key) }) .collect(); diff --git a/crates/pgls_analyser/src/options.rs b/crates/pgls_analyser/src/options.rs index b04539c45..c7625a5a3 100644 --- a/crates/pgls_analyser/src/options.rs +++ b/crates/pgls_analyser/src/options.rs @@ -5,14 +5,14 @@ pub type AddSerialColumn = ::Options; pub type AddingFieldWithDefault = ::Options; -pub type AddingForeignKeyConstraint = < lint :: safety :: adding_foreign_key_constraint :: AddingForeignKeyConstraint as crate::LinterRule > :: Options ; +pub type AddingForeignKeyConstraint = < lint :: safety :: adding_foreign_key_constraint :: AddingForeignKeyConstraint as crate :: LinterRule > :: Options ; pub type AddingNotNullField = ::Options; -pub type AddingPrimaryKeyConstraint = < lint :: safety :: adding_primary_key_constraint :: AddingPrimaryKeyConstraint as crate::LinterRule > :: Options ; +pub type AddingPrimaryKeyConstraint = < lint :: safety :: adding_primary_key_constraint :: AddingPrimaryKeyConstraint as crate :: LinterRule > :: Options ; pub type AddingRequiredField = ::Options; pub type BanCharField = ::Options; -pub type BanConcurrentIndexCreationInTransaction = < lint :: safety :: ban_concurrent_index_creation_in_transaction :: BanConcurrentIndexCreationInTransaction as crate::LinterRule > :: Options ; +pub type BanConcurrentIndexCreationInTransaction = < lint :: safety :: ban_concurrent_index_creation_in_transaction :: BanConcurrentIndexCreationInTransaction as crate :: LinterRule > :: Options ; pub type BanDropColumn = ::Options; pub type BanDropDatabase = @@ -24,9 +24,9 @@ pub type BanTruncateCascade = ::Options; pub type ChangingColumnType = ::Options; -pub type ConstraintMissingNotValid = < lint :: safety :: constraint_missing_not_valid :: ConstraintMissingNotValid as crate::LinterRule > :: Options ; +pub type ConstraintMissingNotValid = < lint :: safety :: constraint_missing_not_valid :: ConstraintMissingNotValid as crate :: LinterRule > :: Options ; pub type CreatingEnum = ::Options; -pub type DisallowUniqueConstraint = < lint :: safety :: disallow_unique_constraint :: DisallowUniqueConstraint as crate::LinterRule > :: Options ; +pub type DisallowUniqueConstraint = < lint :: safety :: disallow_unique_constraint :: DisallowUniqueConstraint as crate :: LinterRule > :: Options ; pub type LockTimeoutWarning = ::Options; pub type MultipleAlterTable = @@ -34,7 +34,7 @@ pub type MultipleAlterTable = pub type PreferBigInt = ::Options; pub type PreferBigintOverInt = ::Options; -pub type PreferBigintOverSmallint = < lint :: safety :: prefer_bigint_over_smallint :: PreferBigintOverSmallint as crate::LinterRule > :: Options ; +pub type PreferBigintOverSmallint = < lint :: safety :: prefer_bigint_over_smallint :: PreferBigintOverSmallint as crate :: LinterRule > :: Options ; pub type PreferIdentity = ::Options; pub type PreferJsonb = ::Options; @@ -48,8 +48,8 @@ pub type RenamingColumn = ::Options; pub type RenamingTable = ::Options; -pub type RequireConcurrentIndexCreation = < lint :: safety :: require_concurrent_index_creation :: RequireConcurrentIndexCreation as crate::LinterRule > :: Options ; -pub type RequireConcurrentIndexDeletion = < lint :: safety :: require_concurrent_index_deletion :: RequireConcurrentIndexDeletion as crate::LinterRule > :: Options ; -pub type RunningStatementWhileHoldingAccessExclusive = < lint :: safety :: running_statement_while_holding_access_exclusive :: RunningStatementWhileHoldingAccessExclusive as crate::LinterRule > :: Options ; +pub type RequireConcurrentIndexCreation = < lint :: safety :: require_concurrent_index_creation :: RequireConcurrentIndexCreation as crate :: LinterRule > :: Options ; +pub type RequireConcurrentIndexDeletion = < lint :: safety :: require_concurrent_index_deletion :: RequireConcurrentIndexDeletion as crate :: LinterRule > :: Options ; +pub type RunningStatementWhileHoldingAccessExclusive = < lint :: safety :: running_statement_while_holding_access_exclusive :: RunningStatementWhileHoldingAccessExclusive as crate :: LinterRule > :: Options ; pub type TransactionNesting = ::Options; diff --git a/crates/pgls_analyser/src/registry.rs b/crates/pgls_analyser/src/registry.rs index 29862d096..35dc5e914 100644 --- a/crates/pgls_analyser/src/registry.rs +++ b/crates/pgls_analyser/src/registry.rs @@ -5,167 +5,8 @@ use pgls_analyse::{RegistryVisitor, RuleKey}; pub fn visit_registry(registry: &mut V) { registry.record_category::(); } -#[doc = r" Maps rule keys to factory functions that create rule executors"] +#[doc = r" Maps rule keys to rule executors"] #[doc = r" This function is generated by codegen and includes all linter rules"] -pub fn get_linter_rule_factory(key: &RuleKey) -> Option RegistryLinterRule>> { - match key.rule_name() { - "addSerialColumn" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::add_serial_column::AddSerialColumn, - >() - })), - "addingFieldWithDefault" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::adding_field_with_default::AddingFieldWithDefault, - >() - })), - "addingForeignKeyConstraint" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::adding_foreign_key_constraint::AddingForeignKeyConstraint, - >() - })), - "addingNotNullField" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::adding_not_null_field::AddingNotNullField, - >() - })), - "addingPrimaryKeyConstraint" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::adding_primary_key_constraint::AddingPrimaryKeyConstraint, - >() - })), - "addingRequiredField" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::adding_required_field::AddingRequiredField, - >() - })), - "banCharField" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::ban_char_field::BanCharField, - >() - })), - "banConcurrentIndexCreationInTransaction" => Some(Box::new(|| { - crate :: linter_registry :: RegistryLinterRule :: new :: < crate::lint::safety::ban_concurrent_index_creation_in_transaction :: BanConcurrentIndexCreationInTransaction > () - })), - "banDropColumn" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::ban_drop_column::BanDropColumn, - >() - })), - "banDropDatabase" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::ban_drop_database::BanDropDatabase, - >() - })), - "banDropNotNull" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::ban_drop_not_null::BanDropNotNull, - >() - })), - "banDropTable" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::ban_drop_table::BanDropTable, - >() - })), - "banTruncateCascade" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::ban_truncate_cascade::BanTruncateCascade, - >() - })), - "changingColumnType" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::changing_column_type::ChangingColumnType, - >() - })), - "constraintMissingNotValid" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::constraint_missing_not_valid::ConstraintMissingNotValid, - >() - })), - "creatingEnum" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::creating_enum::CreatingEnum, - >() - })), - "disallowUniqueConstraint" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::disallow_unique_constraint::DisallowUniqueConstraint, - >() - })), - "lockTimeoutWarning" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::lock_timeout_warning::LockTimeoutWarning, - >() - })), - "multipleAlterTable" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::multiple_alter_table::MultipleAlterTable, - >() - })), - "preferBigInt" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::prefer_big_int::PreferBigInt, - >() - })), - "preferBigintOverInt" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::prefer_bigint_over_int::PreferBigintOverInt, - >() - })), - "preferBigintOverSmallint" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::prefer_bigint_over_smallint::PreferBigintOverSmallint, - >() - })), - "preferIdentity" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::prefer_identity::PreferIdentity, - >() - })), - "preferJsonb" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::prefer_jsonb::PreferJsonb, - >() - })), - "preferRobustStmts" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::prefer_robust_stmts::PreferRobustStmts, - >() - })), - "preferTextField" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::prefer_text_field::PreferTextField, - >() - })), - "preferTimestamptz" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::prefer_timestamptz::PreferTimestamptz, - >() - })), - "renamingColumn" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::renaming_column::RenamingColumn, - >() - })), - "renamingTable" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::renaming_table::RenamingTable, - >() - })), - "requireConcurrentIndexCreation" => Some(Box::new(|| { - crate :: linter_registry :: RegistryLinterRule :: new :: < crate::lint::safety::require_concurrent_index_creation :: RequireConcurrentIndexCreation > () - })), - "requireConcurrentIndexDeletion" => Some(Box::new(|| { - crate :: linter_registry :: RegistryLinterRule :: new :: < crate::lint::safety::require_concurrent_index_deletion :: RequireConcurrentIndexDeletion > () - })), - "runningStatementWhileHoldingAccessExclusive" => Some(Box::new(|| { - crate :: linter_registry :: RegistryLinterRule :: new :: < crate::lint::safety::running_statement_while_holding_access_exclusive :: RunningStatementWhileHoldingAccessExclusive > () - })), - "transactionNesting" => Some(Box::new(|| { - crate::linter_registry::RegistryLinterRule::new::< - crate::lint::safety::transaction_nesting::TransactionNesting, - >() - })), - _ => None, - } +pub fn get_linter_rule_executor(key: &RuleKey) -> Option { + match key . rule_name () { "addSerialColumn" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: add_serial_column :: AddSerialColumn > ()) , "addingFieldWithDefault" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: adding_field_with_default :: AddingFieldWithDefault > ()) , "addingForeignKeyConstraint" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: adding_foreign_key_constraint :: AddingForeignKeyConstraint > ()) , "addingNotNullField" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: adding_not_null_field :: AddingNotNullField > ()) , "addingPrimaryKeyConstraint" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: adding_primary_key_constraint :: AddingPrimaryKeyConstraint > ()) , "addingRequiredField" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: adding_required_field :: AddingRequiredField > ()) , "banCharField" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: ban_char_field :: BanCharField > ()) , "banConcurrentIndexCreationInTransaction" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: ban_concurrent_index_creation_in_transaction :: BanConcurrentIndexCreationInTransaction > ()) , "banDropColumn" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: ban_drop_column :: BanDropColumn > ()) , "banDropDatabase" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: ban_drop_database :: BanDropDatabase > ()) , "banDropNotNull" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: ban_drop_not_null :: BanDropNotNull > ()) , "banDropTable" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: ban_drop_table :: BanDropTable > ()) , "banTruncateCascade" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: ban_truncate_cascade :: BanTruncateCascade > ()) , "changingColumnType" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: changing_column_type :: ChangingColumnType > ()) , "constraintMissingNotValid" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: constraint_missing_not_valid :: ConstraintMissingNotValid > ()) , "creatingEnum" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: creating_enum :: CreatingEnum > ()) , "disallowUniqueConstraint" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: disallow_unique_constraint :: DisallowUniqueConstraint > ()) , "lockTimeoutWarning" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: lock_timeout_warning :: LockTimeoutWarning > ()) , "multipleAlterTable" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: multiple_alter_table :: MultipleAlterTable > ()) , "preferBigInt" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: prefer_big_int :: PreferBigInt > ()) , "preferBigintOverInt" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: prefer_bigint_over_int :: PreferBigintOverInt > ()) , "preferBigintOverSmallint" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: prefer_bigint_over_smallint :: PreferBigintOverSmallint > ()) , "preferIdentity" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: prefer_identity :: PreferIdentity > ()) , "preferJsonb" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: prefer_jsonb :: PreferJsonb > ()) , "preferRobustStmts" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: prefer_robust_stmts :: PreferRobustStmts > ()) , "preferTextField" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: prefer_text_field :: PreferTextField > ()) , "preferTimestamptz" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: prefer_timestamptz :: PreferTimestamptz > ()) , "renamingColumn" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: renaming_column :: RenamingColumn > ()) , "renamingTable" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: renaming_table :: RenamingTable > ()) , "requireConcurrentIndexCreation" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: require_concurrent_index_creation :: RequireConcurrentIndexCreation > ()) , "requireConcurrentIndexDeletion" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: require_concurrent_index_deletion :: RequireConcurrentIndexDeletion > ()) , "runningStatementWhileHoldingAccessExclusive" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: running_statement_while_holding_access_exclusive :: RunningStatementWhileHoldingAccessExclusive > ()) , "transactionNesting" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: transaction_nesting :: TransactionNesting > ()) , _ => None , } } diff --git a/xtask/codegen/src/generate_analyser.rs b/xtask/codegen/src/generate_analyser.rs index fc1257d5c..8698795fb 100644 --- a/xtask/codegen/src/generate_analyser.rs +++ b/xtask/codegen/src/generate_analyser.rs @@ -228,10 +228,10 @@ fn update_linter_registry_builder( let categories = rules.into_values(); - // Generate factory match arms for all rules - let factory_arms = all_rules.iter().map(|(rule_name, (rule_path, _))| { + // Generate match arms that directly create executors (no closure/Box overhead) + let executor_arms = all_rules.iter().map(|(rule_name, (rule_path, _))| { quote! { - #rule_name => Some(Box::new(|| crate::linter_registry::RegistryLinterRule::new::<#rule_path>())) + #rule_name => Some(crate::linter_registry::RegistryLinterRule::new::<#rule_path>()) } }); @@ -243,13 +243,13 @@ fn update_linter_registry_builder( #( #categories )* } - /// Maps rule keys to factory functions that create rule executors + /// Maps rule keys to rule executors (zero-cost abstraction) /// This function is generated by codegen and includes all linter rules - pub fn get_linter_rule_factory( + pub fn get_linter_rule_executor( key: &RuleKey, - ) -> Option RegistryLinterRule>> { + ) -> Option { match key.rule_name() { - #( #factory_arms, )* + #( #executor_arms, )* _ => None, } } From 6fae04119cd8d705cc4e945ef3cc1fb8d0e4323a Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 15 Dec 2025 12:06:49 +0100 Subject: [PATCH 03/32] fix: correct AnalyserRules reference in codegen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed pgls_analyse::AnalyserRules to pgls_analyser::LinterRules and fixed import statements to use the correct crate for RuleOptions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- crates/pgls_analyser/src/registry.rs | 2 +- crates/pgls_configuration/src/linter/rules.rs | 2 +- crates/pgls_configuration/src/rules/configuration.rs | 1 - xtask/codegen/src/generate_configuration.rs | 8 +++++--- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/pgls_analyser/src/registry.rs b/crates/pgls_analyser/src/registry.rs index 35dc5e914..7e3872905 100644 --- a/crates/pgls_analyser/src/registry.rs +++ b/crates/pgls_analyser/src/registry.rs @@ -5,7 +5,7 @@ use pgls_analyse::{RegistryVisitor, RuleKey}; pub fn visit_registry(registry: &mut V) { registry.record_category::(); } -#[doc = r" Maps rule keys to rule executors"] +#[doc = r" Maps rule keys to rule executors (zero-cost abstraction)"] #[doc = r" This function is generated by codegen and includes all linter rules"] pub fn get_linter_rule_executor(key: &RuleKey) -> Option { match key . rule_name () { "addSerialColumn" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: add_serial_column :: AddSerialColumn > ()) , "addingFieldWithDefault" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: adding_field_with_default :: AddingFieldWithDefault > ()) , "addingForeignKeyConstraint" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: adding_foreign_key_constraint :: AddingForeignKeyConstraint > ()) , "addingNotNullField" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: adding_not_null_field :: AddingNotNullField > ()) , "addingPrimaryKeyConstraint" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: adding_primary_key_constraint :: AddingPrimaryKeyConstraint > ()) , "addingRequiredField" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: adding_required_field :: AddingRequiredField > ()) , "banCharField" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: ban_char_field :: BanCharField > ()) , "banConcurrentIndexCreationInTransaction" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: ban_concurrent_index_creation_in_transaction :: BanConcurrentIndexCreationInTransaction > ()) , "banDropColumn" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: ban_drop_column :: BanDropColumn > ()) , "banDropDatabase" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: ban_drop_database :: BanDropDatabase > ()) , "banDropNotNull" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: ban_drop_not_null :: BanDropNotNull > ()) , "banDropTable" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: ban_drop_table :: BanDropTable > ()) , "banTruncateCascade" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: ban_truncate_cascade :: BanTruncateCascade > ()) , "changingColumnType" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: changing_column_type :: ChangingColumnType > ()) , "constraintMissingNotValid" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: constraint_missing_not_valid :: ConstraintMissingNotValid > ()) , "creatingEnum" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: creating_enum :: CreatingEnum > ()) , "disallowUniqueConstraint" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: disallow_unique_constraint :: DisallowUniqueConstraint > ()) , "lockTimeoutWarning" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: lock_timeout_warning :: LockTimeoutWarning > ()) , "multipleAlterTable" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: multiple_alter_table :: MultipleAlterTable > ()) , "preferBigInt" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: prefer_big_int :: PreferBigInt > ()) , "preferBigintOverInt" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: prefer_bigint_over_int :: PreferBigintOverInt > ()) , "preferBigintOverSmallint" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: prefer_bigint_over_smallint :: PreferBigintOverSmallint > ()) , "preferIdentity" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: prefer_identity :: PreferIdentity > ()) , "preferJsonb" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: prefer_jsonb :: PreferJsonb > ()) , "preferRobustStmts" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: prefer_robust_stmts :: PreferRobustStmts > ()) , "preferTextField" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: prefer_text_field :: PreferTextField > ()) , "preferTimestamptz" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: prefer_timestamptz :: PreferTimestamptz > ()) , "renamingColumn" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: renaming_column :: RenamingColumn > ()) , "renamingTable" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: renaming_table :: RenamingTable > ()) , "requireConcurrentIndexCreation" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: require_concurrent_index_creation :: RequireConcurrentIndexCreation > ()) , "requireConcurrentIndexDeletion" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: require_concurrent_index_deletion :: RequireConcurrentIndexDeletion > ()) , "runningStatementWhileHoldingAccessExclusive" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: running_statement_while_holding_access_exclusive :: RunningStatementWhileHoldingAccessExclusive > ()) , "transactionNesting" => Some (crate :: linter_registry :: RegistryLinterRule :: new :: < crate :: lint :: safety :: transaction_nesting :: TransactionNesting > ()) , _ => None , } diff --git a/crates/pgls_configuration/src/linter/rules.rs b/crates/pgls_configuration/src/linter/rules.rs index 32fc1a2fc..4bc3d4c98 100644 --- a/crates/pgls_configuration/src/linter/rules.rs +++ b/crates/pgls_configuration/src/linter/rules.rs @@ -908,7 +908,7 @@ impl Safety { pub fn push_to_analyser_rules( rules: &Rules, metadata: &pgls_analyse::MetadataRegistry, - analyser_rules: &mut pgls_analyse::AnalyserRules, + analyser_rules: &mut pgls_analyser::LinterRules, ) { if let Some(rules) = rules.safety.as_ref() { for rule_name in Safety::GROUP_RULES { diff --git a/crates/pgls_configuration/src/rules/configuration.rs b/crates/pgls_configuration/src/rules/configuration.rs index ad93abeeb..6b86fef69 100644 --- a/crates/pgls_configuration/src/rules/configuration.rs +++ b/crates/pgls_configuration/src/rules/configuration.rs @@ -1,6 +1,5 @@ use biome_deserialize::Merge; use biome_deserialize_macros::Deserializable; -use pgls_analyse::RuleFilter; use pgls_analyser::RuleOptions; use pgls_diagnostics::Severity; #[cfg(feature = "schema")] diff --git a/xtask/codegen/src/generate_configuration.rs b/xtask/codegen/src/generate_configuration.rs index 2a9913bf1..43224d48e 100644 --- a/xtask/codegen/src/generate_configuration.rs +++ b/xtask/codegen/src/generate_configuration.rs @@ -287,7 +287,8 @@ fn generate_lint_rules_file( use crate::rules::{RuleConfiguration, RulePlainConfiguration}; use biome_deserialize_macros::Merge; - use pgls_analyse::{RuleFilter, options::RuleOptions}; + use pgls_analyse::RuleFilter; + use pgls_analyser::RuleOptions; use pgls_diagnostics::{Category, Severity}; use rustc_hash::FxHashSet; #[cfg(feature = "schema")] @@ -426,7 +427,7 @@ fn generate_lint_rules_file( pub fn push_to_analyser_rules( rules: &Rules, metadata: &pgls_analyse::MetadataRegistry, - analyser_rules: &mut pgls_analyse::AnalyserRules, + analyser_rules: &mut pgls_analyser::LinterRules, ) { #( if let Some(rules) = rules.#group_idents.as_ref() { @@ -789,7 +790,8 @@ fn generate_action_actions_file( use crate::rules::{RuleAssistConfiguration, RuleAssistPlainConfiguration}; use biome_deserialize_macros::{Deserializable, Merge}; - use pgls_analyse::{RuleFilter, options::RuleOptions}; + use pgls_analyse::RuleFilter; + use pgls_analyser::RuleOptions; use pgls_diagnostics::{Category, Severity}; use rustc_hash::FxHashSet; #[cfg(feature = "schema")] From 4e9757f31a97d149dd0d3db0597ef69fc26ab417 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 15 Dec 2025 12:32:53 +0100 Subject: [PATCH 04/32] fix: update test imports to use LinterOptions and LinterDiagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated test files to use the new type names after the refactor: - Changed AnalyserOptions to LinterOptions - Changed RuleDiagnostic to LinterDiagnostic - Fixed import paths to import from pgls_analyser instead of pgls_analyse 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- crates/pgls_analyser/src/lib.rs | 3 ++- crates/pgls_analyser/tests/rules_tests.rs | 10 +++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/pgls_analyser/src/lib.rs b/crates/pgls_analyser/src/lib.rs index 038a53239..577a2a886 100644 --- a/crates/pgls_analyser/src/lib.rs +++ b/crates/pgls_analyser/src/lib.rs @@ -116,7 +116,8 @@ impl<'a> Analyser<'a> { mod tests { use core::slice; - use pgls_analyse::{AnalyserOptions, AnalysisFilter, RuleFilter}; + use pgls_analyse::{AnalysisFilter, RuleFilter}; + use crate::LinterOptions; use pgls_console::{ Markup, fmt::{Formatter, Termcolor}, diff --git a/crates/pgls_analyser/tests/rules_tests.rs b/crates/pgls_analyser/tests/rules_tests.rs index 2b023d472..4749b3401 100644 --- a/crates/pgls_analyser/tests/rules_tests.rs +++ b/crates/pgls_analyser/tests/rules_tests.rs @@ -1,8 +1,8 @@ use core::slice; use std::{collections::HashMap, fmt::Write, fs::read_to_string, path::Path}; -use pgls_analyse::{AnalyserOptions, AnalysisFilter, RuleDiagnostic, RuleFilter}; -use pgls_analyser::{AnalysableStatement, Analyser, AnalyserConfig, AnalyserParams}; +use pgls_analyse::{AnalysisFilter, RuleFilter}; +use pgls_analyser::{AnalysableStatement, Analyser, AnalyserConfig, AnalyserParams, LinterDiagnostic, LinterOptions}; use pgls_console::StdDisplay; use pgls_diagnostics::PrintDiagnostic; @@ -25,7 +25,7 @@ fn rule_test(full_path: &'static str, _: &str, _: &str) { let query = read_to_string(full_path).unwrap_or_else(|_| panic!("Failed to read file: {full_path} ")); - let options = AnalyserOptions::default(); + let options = LinterOptions::default(); let analyser = Analyser::new(AnalyserConfig { options: &options, filter, @@ -79,7 +79,7 @@ fn parse_test_path(path: &Path) -> (String, String, String) { (group.into(), rule.into(), fname.into()) } -fn write_snapshot(snapshot: &mut String, query: &str, diagnostics: &[RuleDiagnostic]) { +fn write_snapshot(snapshot: &mut String, query: &str, diagnostics: &[LinterDiagnostic]) { writeln!(snapshot, "# Input").unwrap(); writeln!(snapshot, "```").unwrap(); writeln!(snapshot, "{query}").unwrap(); @@ -140,7 +140,7 @@ impl Expectation { ); } - fn assert(&self, diagnostics: &[RuleDiagnostic]) { + fn assert(&self, diagnostics: &[LinterDiagnostic]) { match self { Self::NoDiagnostics => { if !diagnostics.is_empty() { From 4f16e91572205ea50f75f14efcae6a9bda203bc1 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 15 Dec 2025 12:50:37 +0100 Subject: [PATCH 05/32] style: apply rustfmt to test files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed import ordering and line length formatting issues. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- crates/pgls_analyser/src/lib.rs | 2 +- crates/pgls_analyser/tests/rules_tests.rs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/pgls_analyser/src/lib.rs b/crates/pgls_analyser/src/lib.rs index 577a2a886..5cfd01897 100644 --- a/crates/pgls_analyser/src/lib.rs +++ b/crates/pgls_analyser/src/lib.rs @@ -116,8 +116,8 @@ impl<'a> Analyser<'a> { mod tests { use core::slice; - use pgls_analyse::{AnalysisFilter, RuleFilter}; use crate::LinterOptions; + use pgls_analyse::{AnalysisFilter, RuleFilter}; use pgls_console::{ Markup, fmt::{Formatter, Termcolor}, diff --git a/crates/pgls_analyser/tests/rules_tests.rs b/crates/pgls_analyser/tests/rules_tests.rs index 4749b3401..39b74e62c 100644 --- a/crates/pgls_analyser/tests/rules_tests.rs +++ b/crates/pgls_analyser/tests/rules_tests.rs @@ -2,7 +2,9 @@ use core::slice; use std::{collections::HashMap, fmt::Write, fs::read_to_string, path::Path}; use pgls_analyse::{AnalysisFilter, RuleFilter}; -use pgls_analyser::{AnalysableStatement, Analyser, AnalyserConfig, AnalyserParams, LinterDiagnostic, LinterOptions}; +use pgls_analyser::{ + AnalysableStatement, Analyser, AnalyserConfig, AnalyserParams, LinterDiagnostic, LinterOptions, +}; use pgls_console::StdDisplay; use pgls_diagnostics::PrintDiagnostic; From 4798b242fa9aa51261dade16ba2490a8bd56562e Mon Sep 17 00:00:00 2001 From: psteinroe Date: Sun, 14 Dec 2025 19:40:21 +0100 Subject: [PATCH 06/32] chore: integrate splinter into codegen --- Cargo.lock | 2 + PLAN.md | 406 +++++++++- .../src/analyser/splinter/mod.rs | 58 ++ .../src/analyser/splinter/rules.rs | 754 ++++++++++++++++++ .../src/generated/splinter.rs | 29 + crates/pgls_configuration/src/linter/rules.rs | 4 +- .../src/categories.rs | 44 +- crates/pgls_splinter/Cargo.toml | 1 + crates/pgls_splinter/src/convert.rs | 6 +- crates/pgls_splinter/src/lib.rs | 4 + crates/pgls_splinter/src/registry.rs | 79 ++ crates/pgls_splinter/src/rule.rs | 14 + crates/pgls_splinter/src/rules/mod.rs | 6 + .../rules/performance/auth_rls_initplan.rs | 11 + .../src/rules/performance/duplicate_index.rs | 11 + .../src/rules/performance/mod.rs | 11 + .../multiple_permissive_policies.rs | 11 + .../src/rules/performance/no_primary_key.rs | 11 + .../src/rules/performance/table_bloat.rs | 11 + .../performance/unindexed_foreign_keys.rs | 11 + .../src/rules/performance/unused_index.rs | 11 + .../src/rules/security/auth_users_exposed.rs | 11 + .../src/rules/security/extension_in_public.rs | 11 + .../security/extension_versions_outdated.rs | 11 + .../src/rules/security/fkey_to_auth_unique.rs | 11 + .../rules/security/foreign_table_in_api.rs | 11 + .../security/function_search_path_mutable.rs | 11 + .../security/insecure_queue_exposed_in_api.rs | 11 + .../security/materialized_view_in_api.rs | 11 + .../pgls_splinter/src/rules/security/mod.rs | 18 + .../security/policy_exists_rls_disabled.rs | 11 + .../rules/security/rls_disabled_in_public.rs | 11 + .../rules/security/rls_enabled_no_policy.rs | 11 + .../security/rls_references_user_metadata.rs | 11 + .../rules/security/security_definer_view.rs | 11 + .../rules/security/unsupported_reg_types.rs | 11 + .../vendor/performance/auth_rls_initplan.sql | 107 +++ .../vendor/performance/duplicate_index.sql | 61 ++ .../multiple_permissive_policies.sql | 79 ++ .../vendor/performance/no_primary_key.sql | 52 ++ .../vendor/performance/table_bloat.sql | 105 +++ .../performance/unindexed_foreign_keys.sql | 78 ++ .../vendor/performance/unused_index.sql | 49 ++ .../vendor/security/auth_users_exposed.sql | 95 +++ .../vendor/security/extension_in_public.sql | 40 + .../security/extension_versions_outdated.sql | 45 ++ .../vendor/security/fkey_to_auth_unique.sql | 49 ++ .../vendor/security/foreign_table_in_api.sql | 49 ++ .../security/function_search_path_mutable.sql | 50 ++ .../insecure_queue_exposed_in_api.sql | 46 ++ .../security/materialized_view_in_api.sql | 49 ++ .../security/policy_exists_rls_disabled.sql | 52 ++ .../security/rls_disabled_in_public.sql | 47 ++ .../vendor/security/rls_enabled_no_policy.sql | 52 ++ .../security/rls_references_user_metadata.sql | 61 ++ .../vendor/security/security_definer_view.sql | 59 ++ .../vendor/security/unsupported_reg_types.sql | 49 ++ xtask/codegen/Cargo.toml | 1 + xtask/codegen/src/generate_splinter.rs | 542 +++++++++---- 59 files changed, 3267 insertions(+), 217 deletions(-) create mode 100644 crates/pgls_configuration/src/analyser/splinter/mod.rs create mode 100644 crates/pgls_configuration/src/analyser/splinter/rules.rs create mode 100644 crates/pgls_configuration/src/generated/splinter.rs create mode 100644 crates/pgls_splinter/src/registry.rs create mode 100644 crates/pgls_splinter/src/rule.rs create mode 100644 crates/pgls_splinter/src/rules/mod.rs create mode 100644 crates/pgls_splinter/src/rules/performance/auth_rls_initplan.rs create mode 100644 crates/pgls_splinter/src/rules/performance/duplicate_index.rs create mode 100644 crates/pgls_splinter/src/rules/performance/mod.rs create mode 100644 crates/pgls_splinter/src/rules/performance/multiple_permissive_policies.rs create mode 100644 crates/pgls_splinter/src/rules/performance/no_primary_key.rs create mode 100644 crates/pgls_splinter/src/rules/performance/table_bloat.rs create mode 100644 crates/pgls_splinter/src/rules/performance/unindexed_foreign_keys.rs create mode 100644 crates/pgls_splinter/src/rules/performance/unused_index.rs create mode 100644 crates/pgls_splinter/src/rules/security/auth_users_exposed.rs create mode 100644 crates/pgls_splinter/src/rules/security/extension_in_public.rs create mode 100644 crates/pgls_splinter/src/rules/security/extension_versions_outdated.rs create mode 100644 crates/pgls_splinter/src/rules/security/fkey_to_auth_unique.rs create mode 100644 crates/pgls_splinter/src/rules/security/foreign_table_in_api.rs create mode 100644 crates/pgls_splinter/src/rules/security/function_search_path_mutable.rs create mode 100644 crates/pgls_splinter/src/rules/security/insecure_queue_exposed_in_api.rs create mode 100644 crates/pgls_splinter/src/rules/security/materialized_view_in_api.rs create mode 100644 crates/pgls_splinter/src/rules/security/mod.rs create mode 100644 crates/pgls_splinter/src/rules/security/policy_exists_rls_disabled.rs create mode 100644 crates/pgls_splinter/src/rules/security/rls_disabled_in_public.rs create mode 100644 crates/pgls_splinter/src/rules/security/rls_enabled_no_policy.rs create mode 100644 crates/pgls_splinter/src/rules/security/rls_references_user_metadata.rs create mode 100644 crates/pgls_splinter/src/rules/security/security_definer_view.rs create mode 100644 crates/pgls_splinter/src/rules/security/unsupported_reg_types.rs create mode 100644 crates/pgls_splinter/vendor/performance/auth_rls_initplan.sql create mode 100644 crates/pgls_splinter/vendor/performance/duplicate_index.sql create mode 100644 crates/pgls_splinter/vendor/performance/multiple_permissive_policies.sql create mode 100644 crates/pgls_splinter/vendor/performance/no_primary_key.sql create mode 100644 crates/pgls_splinter/vendor/performance/table_bloat.sql create mode 100644 crates/pgls_splinter/vendor/performance/unindexed_foreign_keys.sql create mode 100644 crates/pgls_splinter/vendor/performance/unused_index.sql create mode 100644 crates/pgls_splinter/vendor/security/auth_users_exposed.sql create mode 100644 crates/pgls_splinter/vendor/security/extension_in_public.sql create mode 100644 crates/pgls_splinter/vendor/security/extension_versions_outdated.sql create mode 100644 crates/pgls_splinter/vendor/security/fkey_to_auth_unique.sql create mode 100644 crates/pgls_splinter/vendor/security/foreign_table_in_api.sql create mode 100644 crates/pgls_splinter/vendor/security/function_search_path_mutable.sql create mode 100644 crates/pgls_splinter/vendor/security/insecure_queue_exposed_in_api.sql create mode 100644 crates/pgls_splinter/vendor/security/materialized_view_in_api.sql create mode 100644 crates/pgls_splinter/vendor/security/policy_exists_rls_disabled.sql create mode 100644 crates/pgls_splinter/vendor/security/rls_disabled_in_public.sql create mode 100644 crates/pgls_splinter/vendor/security/rls_enabled_no_policy.sql create mode 100644 crates/pgls_splinter/vendor/security/rls_references_user_metadata.sql create mode 100644 crates/pgls_splinter/vendor/security/security_definer_view.sql create mode 100644 crates/pgls_splinter/vendor/security/unsupported_reg_types.sql diff --git a/Cargo.lock b/Cargo.lock index 3425f1a83..677328fa3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2996,6 +2996,7 @@ name = "pgls_splinter" version = "0.0.0" dependencies = [ "insta", + "pgls_analyse", "pgls_console", "pgls_diagnostics", "pgls_test_utils", @@ -5615,6 +5616,7 @@ dependencies = [ "pgls_analyser", "pgls_diagnostics", "pgls_env", + "pgls_splinter", "pgls_workspace", "proc-macro2", "pulldown-cmark", diff --git a/PLAN.md b/PLAN.md index 86e0536db..72c8b279f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -56,6 +56,7 @@ Extract AST-specific code into pgls_linter, keep only generic framework in pgls_ - [x] Update codegen to generate factory function - [x] Fix all import paths across workspace - [x] Verify full workspace compiles +- [x] Optimize executor creation (zero-cost abstraction) - [ ] Run tests **Resolution:** @@ -63,19 +64,22 @@ Separated two concerns: 1. **Visitor pattern** (generic): Collects rule keys that match the filter - Implementation in `LinterRuleRegistryBuilder::record_rule` - Only requires `R: RuleMeta` (satisfies trait) -2. **Factory mapping** (AST-specific): Maps rule keys to executor factories - - Function `get_linter_rule_factory` in `registry.rs` - - Will be generated by codegen with full type information - - Each factory can require `R: LinterRule` - -**Changes Made:** -- `LinterRuleRegistryBuilder` stores `Vec` instead of factories -- `record_rule` just collects keys (no LinterRule bounds needed) -- `build()` calls `get_linter_rule_factory` to create executors -- Added stub `get_linter_rule_factory` in `registry.rs` (will be generated) - -**Next Steps:** -- Update codegen to generate `get_linter_rule_factory` with match on all rules +2. **Executor mapping** (AST-specific): Maps rule keys directly to executors + - Function `get_linter_rule_executor` in `registry.rs` + - Generated by codegen with full type information + - Zero-cost abstraction: no Box, no dyn, no closures + +**Final Implementation:** +- `LinterRuleRegistryBuilder` stores `Vec` from visitor traversal +- `record_rule` only collects keys (generic, no LinterRule bounds) +- `build()` calls `get_linter_rule_executor` for each key +- Generated match statement returns executors directly (no heap allocation) + +**Performance:** +- ✅ No `Box` - returns values directly +- ✅ No closure overhead - simple match statement +- ✅ No dynamic dispatch - static dispatch only +- ✅ Clean codegen - 33 rules map to executors efficiently **Design Decisions:** - ✅ Keep `RuleDiagnostic` generic or make it linter-specific? → **Move to pgls_linter as LinterDiagnostic** (Option A) @@ -108,44 +112,106 @@ Generic (keep in pgls_analyse): --- -### Phase 2: Enhance pgls_splinter 📋 PLANNED +### Phase 2: Enhance pgls_splinter ✅ COMPLETED Add rule type generation and registry similar to linter. **Tasks:** -- [ ] Create `pgls_splinter/src/rule.rs` with `SplinterRule` trait -- [ ] Create `pgls_splinter/src/rules/` directory structure -- [ ] Generate rule types from SQL files -- [ ] Generate registry with `visit_registry()` function -- [ ] Update diagnostics to use generated categories +- [x] Create `pgls_splinter/src/rule.rs` with `SplinterRule` trait +- [x] Create `pgls_splinter/src/rules/` directory structure +- [x] Generate rule types from SQL files +- [x] Generate registry with `visit_registry()` function +- [x] Split monolithic SQL files into individual rule files with metadata +- [x] Create codegen to extract metadata from SQL comments +- [x] Generate get_sql_file_path() function for SQL file mapping +- [ ] Update diagnostics to use generated categories (deferred) +- [ ] Update runtime to build dynamic queries (deferred to Phase 3) **Structure:** ``` pgls_splinter/src/ rules/ performance/ - unindexed_foreign_keys.rs - auth_rls_initplan.rs + unindexed_foreign_keys.rs # Generated + auth_rls_initplan.rs # Generated + ... # 7 total + mod.rs # Generated group security/ - auth_users_exposed.rs - rule.rs # SplinterRule trait - registry.rs # Generated visit_registry() + auth_users_exposed.rs # Generated + ... # 14 total + mod.rs # Generated group + mod.rs # Generated category + rule.rs # Generated SplinterRule trait + registry.rs # Generated visit_registry() + get_sql_file_path() + +pgls_splinter/vendor/ + performance/ + *.sql # 7 individual SQL files with metadata + security/ + *.sql # 14 individual SQL files with metadata ``` +**Implementation Summary:** +- Implemented Option C (Hybrid Approach) from initial analysis +- SQL files remain source of truth with metadata comments +- Codegen extracts metadata and generates Rust structures +- `SplinterRule` trait extends `RuleMeta` with `sql_file_path()` method +- Registry provides centralized rule discovery via visitor pattern +- Category structure: `Splinter` (Lint) → `Performance`/`Security` (groups) → individual rules +- Successfully compiles without errors + --- -### Phase 3: Update codegen for both linters 📋 PLANNED -Generalize codegen to handle both linter types. +### Phase 3: Integrate configuration and documentation 📋 PLANNED +Complete integration of splinter into the configuration and documentation systems. **Tasks:** -- [ ] Rename `generate_analyser.rs` → `generate_linter.rs` -- [ ] Enhance `generate_splinter.rs` to generate rules + registry -- [ ] Update `generate_configuration.rs` for both linters -- [ ] Update justfile commands -- [ ] Test full generation cycle +- [ ] **Configuration Generation**: + - [ ] Create `pgls_configuration/src/analyser/splinter/` directory + - [ ] Generate splinter configuration types (groups, rules) + - [ ] Update `generate_configuration.rs` to visit splinter registry + - [ ] Generate `crates/pgls_configuration/src/generated/splinter.rs` + - [ ] Update `analyser/mod.rs` to export splinter config + - [ ] Add splinter to RuleSelector enum + +- [ ] **Documentation Enhancement** (FUTURE): + - [ ] Add SQL query examples to splinter rule docs (similar to linter) + - [ ] Extract SQL from vendor/*.sql files into doc comments + - [ ] Add usage examples and remediation steps + - [ ] Generate rule documentation via docs_codegen + +- [ ] **Runtime Integration** (DEFERRED): + - [ ] Update `run_splinter()` to use visitor pattern with AnalysisFilter + - [ ] Build dynamic SQL queries from enabled rules only + - [ ] Remove hardcoded SQL query execution + - [ ] Remove hardcoded category mapping in convert.rs + +- [ ] **Testing**: + - [ ] Run `just gen-lint` successfully + - [ ] Verify linter configuration still works + - [ ] Verify splinter configuration generates + - [ ] Test enabling/disabling splinter rules via config + - [ ] Verify full workspace compiles + +**Codegen Outputs After Phase 3:** +``` +Linter: + - crates/pgls_analyser/src/registry.rs (generated) + - crates/pgls_analyser/src/options.rs (generated) + - crates/pgls_configuration/src/analyser/linter/ (generated) + - crates/pgls_configuration/src/generated/linter.rs (generated) + +Splinter: + - crates/pgls_splinter/src/rules/ (generated - ✅ DONE) + - crates/pgls_splinter/src/rule.rs (generated - ✅ DONE) + - crates/pgls_splinter/src/registry.rs (generated - ✅ DONE) + - crates/pgls_configuration/src/analyser/splinter/ (TODO) + - crates/pgls_configuration/src/generated/splinter.rs (TODO) +``` -**Codegen outputs:** -- Linter: registry.rs, options.rs, configuration -- Splinter: rules/, registry.rs, configuration +**Notes:** +- Runtime integration (dynamic SQL query building) is deferred as it requires more complex changes to `run_splinter()` +- Documentation enhancement with SQL examples is marked as FUTURE work +- Focus Phase 3 on configuration integration to enable rule enable/disable via config files --- @@ -161,15 +227,279 @@ Final rename to clarify purpose. --- +### Phase 5: Runtime & Documentation Enhancements 📋 FUTURE +Advanced features for splinter integration (optional future work). + +--- + +#### **Part A: Configuration Structure Design** + +**Goal:** Create a splinter configuration structure that mirrors linter but shares common code. + +**Shared Components** (from `analyser/mod.rs`): +- `RuleConfiguration` - Configuration wrapper with severity levels +- `RulePlainConfiguration` - Severity enum (Warn, Error, Info, Off) +- Merge/Deserialize traits + +**New Splinter-Specific Types** (to generate in Phase 3): + +```rust +// analyser/splinter/mod.rs +pub struct SplinterConfiguration { + /// Enable/disable splinter linting + pub enabled: bool, + + /// Splinter rules configuration + pub rules: Rules, + + /// Ignore/include patterns (shared with linter) + pub ignore: StringSet, + pub include: StringSet, +} + +// analyser/splinter/rules.rs (GENERATED) +pub enum RuleGroup { + Performance, + Security, +} + +pub struct Rules { + /// Enable recommended rules + pub recommended: Option, + + /// Enable all rules + pub all: Option, + + /// Performance group + pub performance: Option, + + /// Security group + pub security: Option, +} + +// Performance group (GENERATED) +pub struct Performance { + pub recommended: Option, + pub all: Option, + + // Individual rules - note: using RuleConfiguration<()> since no options + pub unindexed_foreign_keys: Option>, + pub auth_rls_initplan: Option>, + pub duplicate_index: Option>, + pub multiple_permissive_policies: Option>, + pub no_primary_key: Option>, + pub table_bloat: Option>, + pub unused_index: Option>, +} + +impl Performance { + const GROUP_NAME: &'static str = "performance"; + const GROUP_RULES: &'static [&'static str] = &[ + "unindexedForeignKeys", + "authRlsInitplan", + // ... all 7 rules + ]; + + // Methods mirroring Safety group + pub fn has_rule(rule_name: &str) -> Option<&'static str> { /* ... */ } + pub fn severity(rule_name: &str) -> Severity { /* ... */ } + pub fn all_rules_as_filters() -> impl Iterator> { /* ... */ } + pub fn recommended_rules_as_filters() -> impl Iterator> { /* ... */ } + pub fn collect_preset_rules(&self, ...) { /* ... */ } + pub fn get_enabled_rules(&self) -> FxHashSet> { /* ... */ } + pub fn get_disabled_rules(&self) -> FxHashSet> { /* ... */ } +} + +// Security group (GENERATED) - same structure, 14 rules +pub struct Security { /* ... */ } +``` + +**Config File Example:** +```json +{ + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "safety": { + "addSerialColumn": "error" + } + } + }, + "splinter": { + "enabled": true, + "rules": { + "recommended": true, + "performance": { + "unindexedForeignKeys": "warn", + "noPrimaryKey": "off" + }, + "security": { + "all": true, + "authUsersExposed": "error" + } + } + } +} +``` + +**Code Sharing Strategy:** +- ✅ Reuse `RuleConfiguration` with `T = ()` for splinter (no rule options) +- ✅ Reuse severity conversion logic +- ✅ Same `as_enabled_rules()` / `as_disabled_rules()` pattern +- ✅ Same methods for each group (has_rule, severity, etc.) +- ⚠️ RuleGroup enum is separate (linter has Safety, splinter has Performance/Security) +- ⚠️ RuleSelector needs updating to handle both "lint/" and "splinter/" prefixes + +--- + +#### **Part B: Dynamic SQL Query Building** + +**Tasks:** +- [ ] Modify `run_splinter()` signature: + ```rust + pub async fn run_splinter( + params: SplinterParams<'_>, + filter: &AnalysisFilter, // NEW + ) -> Result, sqlx::Error> + ``` + +- [ ] Use visitor pattern to collect enabled rules: + ```rust + struct SplinterRuleCollector<'a> { + filter: &'a AnalysisFilter<'a>, + enabled_rules: Vec, // Rule names in camelCase + } + + impl RegistryVisitor for SplinterRuleCollector<'_> { + fn record_rule(&mut self) { + if self.filter.match_rule::() { + self.enabled_rules.push(R::METADATA.name.to_string()); + } + } + } + ``` + +- [ ] Build dynamic SQL query: + ```rust + let mut collector = SplinterRuleCollector { filter, enabled_rules: Vec::new() }; + crate::registry::visit_registry(&mut collector); + + // Map rule names to SQL file paths + let mut sql_queries = Vec::new(); + for rule_name in &collector.enabled_rules { + if let Some(sql_path) = crate::registry::get_sql_file_path(rule_name) { + let sql = std::fs::read_to_string(sql_path)?; + sql_queries.push(sql); + } + } + + // Combine with UNION ALL (only if enabled rules exist) + if sql_queries.is_empty() { + return Ok(Vec::new()); + } + + let combined_sql = sql_queries.join("\nUNION ALL\n"); + let results = sqlx::query_as::<_, SplinterQueryResult>(&combined_sql) + .fetch_all(params.conn) + .await?; + ``` + +- [ ] Remove hardcoded functions: + - Delete `load_generic_splinter_results()` + - Delete `load_supabase_splinter_results()` + - Remove `check_supabase_roles()` logic (rules are filtered by config) + +- [ ] Update convert.rs: + - Use generated category from `RuleMeta::METADATA.category` instead of `rule_name_to_category()` + - Or better: get category from diagnostic category system + +--- + +#### **Part C: Enhanced Documentation** + +**Tasks:** +- [ ] Extract SQL queries into rule doc comments: + ```rust + // In generate_splinter.rs codegen + let sql_content = std::fs::read_to_string(&sql_path)?; + let sql_query = extract_sql_query(&sql_content)?; // Remove metadata comments + + let content = quote! { + /// #title + /// + /// #description + /// + /// ## SQL Query + /// + /// ```sql + /// #sql_query + /// ``` + /// + /// ## Remediation + /// + /// #remediation + pub #struct_name { ... } + }; + ``` + +- [ ] Add example SQL snippets showing what triggers the rule +- [ ] Update docs_codegen to process splinter rules +- [ ] Generate markdown documentation for website + +--- + +#### **Benefits Summary:** + +**Performance:** +- 🚀 Only execute SQL for enabled rules (vs. running all 21 rules) +- 🚀 Skip expensive queries when rules are disabled +- 🚀 Example: User disables 18/21 rules → only 3 SQL queries execute + +**Consistency:** +- ✅ Same enable/disable mechanism as linter +- ✅ Same configuration file structure +- ✅ Same visitor pattern for rule discovery + +**Maintainability:** +- ✅ No hardcoded SQL queries in Rust +- ✅ SQL files remain source of truth +- ✅ Adding new rules = add SQL file + run codegen +- ✅ No manual category mapping needed + +**Documentation:** +- ✅ Rule docs show actual SQL query +- ✅ Better understanding of what each rule does +- ✅ Easier to debug and customize + +--- + +#### **Migration Path:** + +**Phase 3** (Configuration Integration): +1. Generate splinter configuration types +2. Wire into config system +3. Users can enable/disable via config (but all enabled rules still run) + +**Phase 5** (This Phase - Runtime Optimization): +1. Update `run_splinter()` to use filter +2. Build dynamic SQL queries +3. Performance benefit: only enabled rules execute + +This allows incremental rollout - config works in Phase 3, optimization comes in Phase 5. + +--- + ## Progress Tracking ### Current Status - [x] Requirements gathering & design -- [x] Architecture proposal (Option 1 - Dual-Track) -- [ ] Phase 1: Refactor pgls_analyse - **IN PROGRESS** -- [ ] Phase 2: Enhance pgls_splinter -- [ ] Phase 3: Update codegen +- [x] Architecture proposal (Option C - Hybrid Approach) +- [x] Phase 1: Refactor pgls_analyse - **COMPLETED** +- [x] Phase 2: Enhance pgls_splinter - **COMPLETED** +- [ ] Phase 3: Integrate configuration - **NEXT** - [ ] Phase 4: Rename to pgls_linter +- [ ] Phase 5: Runtime & Docs (FUTURE) ### Open Questions None currently diff --git a/crates/pgls_configuration/src/analyser/splinter/mod.rs b/crates/pgls_configuration/src/analyser/splinter/mod.rs new file mode 100644 index 000000000..086c6ce0f --- /dev/null +++ b/crates/pgls_configuration/src/analyser/splinter/mod.rs @@ -0,0 +1,58 @@ +mod rules; + +use biome_deserialize::StringSet; +use biome_deserialize_macros::{Merge, Partial}; +use bpaf::Bpaf; +pub use rules::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Eq, Partial, PartialEq, Serialize)] +#[partial(derive(Bpaf, Clone, Eq, Merge, PartialEq))] +#[partial(cfg_attr(feature = "schema", derive(schemars::JsonSchema)))] +#[partial(serde(rename_all = "camelCase", default, deny_unknown_fields))] +pub struct SplinterConfiguration { + /// if `false`, it disables the feature and the splinter won't be executed. `true` by default + #[partial(bpaf(hide))] + pub enabled: bool, + + /// List of rules + #[partial(bpaf(pure(Default::default()), optional, hide))] + pub rules: Rules, + + /// A list of Unix shell style patterns. The splinter will ignore files/folders that will + /// match these patterns. + #[partial(bpaf(hide))] + pub ignore: StringSet, + + /// A list of Unix shell style patterns. The splinter will include files/folders that will + /// match these patterns. + #[partial(bpaf(hide))] + pub include: StringSet, +} + +impl SplinterConfiguration { + pub const fn is_disabled(&self) -> bool { + !self.enabled + } +} + +impl Default for SplinterConfiguration { + fn default() -> Self { + Self { + enabled: true, + rules: Default::default(), + ignore: Default::default(), + include: Default::default(), + } + } +} + +impl PartialSplinterConfiguration { + pub const fn is_disabled(&self) -> bool { + matches!(self.enabled, Some(false)) + } + + pub fn get_rules(&self) -> Rules { + self.rules.clone().unwrap_or_default() + } +} diff --git a/crates/pgls_configuration/src/analyser/splinter/rules.rs b/crates/pgls_configuration/src/analyser/splinter/rules.rs new file mode 100644 index 000000000..9a85f8626 --- /dev/null +++ b/crates/pgls_configuration/src/analyser/splinter/rules.rs @@ -0,0 +1,754 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +use crate::analyser::{RuleConfiguration, RulePlainConfiguration}; +use biome_deserialize_macros::Merge; +use pgls_analyse::RuleFilter; +use pgls_analyser::RuleOptions; +use pgls_diagnostics::{Category, Severity}; +use rustc_hash::FxHashSet; +#[cfg(feature = "schema")] +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +#[derive( + Clone, + Copy, + Debug, + Eq, + Hash, + Merge, + Ord, + PartialEq, + PartialOrd, + serde :: Deserialize, + serde :: Serialize, +)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum RuleGroup { + Performance, + Security, +} +impl RuleGroup { + pub const fn as_str(self) -> &'static str { + match self { + Self::Performance => Performance::GROUP_NAME, + Self::Security => Security::GROUP_NAME, + } + } +} +impl std::str::FromStr for RuleGroup { + type Err = &'static str; + fn from_str(s: &str) -> Result { + match s { + Performance::GROUP_NAME => Ok(Self::Performance), + Security::GROUP_NAME => Ok(Self::Security), + _ => Err("This rule group doesn't exist."), + } + } +} +#[derive(Clone, Debug, Default, Deserialize, Eq, Merge, PartialEq, Serialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Rules { + #[doc = r" It enables the lint rules recommended by Postgres Language Server. `true` by default."] + #[serde(skip_serializing_if = "Option::is_none")] + pub recommended: Option, + #[doc = r" It enables ALL rules. The rules that belong to `nursery` won't be enabled."] + #[serde(skip_serializing_if = "Option::is_none")] + pub all: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub performance: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub security: Option, +} +impl Rules { + #[doc = r" Checks if the code coming from [pgls_diagnostics::Diagnostic] corresponds to a rule."] + #[doc = r" Usually the code is built like {group}/{rule_name}"] + pub fn has_rule(group: RuleGroup, rule_name: &str) -> Option<&'static str> { + match group { + RuleGroup::Performance => Performance::has_rule(rule_name), + RuleGroup::Security => Security::has_rule(rule_name), + } + } + #[doc = r" Given a category coming from [Diagnostic](pgls_diagnostics::Diagnostic), this function returns"] + #[doc = r" the [Severity](pgls_diagnostics::Severity) associated to the rule, if the configuration changed it."] + #[doc = r" If the severity is off or not set, then the function returns the default severity of the rule,"] + #[doc = r" which is configured at the rule definition."] + #[doc = r" The function can return `None` if the rule is not properly configured."] + pub fn get_severity_from_code(&self, category: &Category) -> Option { + let mut split_code = category.name().split('/'); + let _category = split_code.next(); + debug_assert_eq!(_category, Some("splinter")); + let group = ::from_str(split_code.next()?).ok()?; + let rule_name = split_code.next()?; + let rule_name = Self::has_rule(group, rule_name)?; + let severity = match group { + RuleGroup::Performance => self + .performance + .as_ref() + .and_then(|group| group.get_rule_configuration(rule_name)) + .filter(|(level, _)| !matches!(level, RulePlainConfiguration::Off)) + .map_or_else( + || Performance::severity(rule_name), + |(level, _)| level.into(), + ), + RuleGroup::Security => self + .security + .as_ref() + .and_then(|group| group.get_rule_configuration(rule_name)) + .filter(|(level, _)| !matches!(level, RulePlainConfiguration::Off)) + .map_or_else(|| Security::severity(rule_name), |(level, _)| level.into()), + }; + Some(severity) + } + #[doc = r" Ensure that `recommended` is set to `true` or implied."] + pub fn set_recommended(&mut self) { + if self.all != Some(true) && self.recommended == Some(false) { + self.recommended = Some(true) + } + if let Some(group) = &mut self.performance { + group.recommended = None; + } + if let Some(group) = &mut self.security { + group.recommended = None; + } + } + pub(crate) const fn is_recommended_false(&self) -> bool { + matches!(self.recommended, Some(false)) + } + pub(crate) const fn is_all_true(&self) -> bool { + matches!(self.all, Some(true)) + } + #[doc = r" It returns the enabled rules by default."] + #[doc = r""] + #[doc = r" The enabled rules are calculated from the difference with the disabled rules."] + pub fn as_enabled_rules(&self) -> FxHashSet> { + let mut enabled_rules = FxHashSet::default(); + let mut disabled_rules = FxHashSet::default(); + if let Some(group) = self.performance.as_ref() { + group.collect_preset_rules( + self.is_all_true(), + !self.is_recommended_false(), + &mut enabled_rules, + ); + enabled_rules.extend(&group.get_enabled_rules()); + disabled_rules.extend(&group.get_disabled_rules()); + } else if self.is_all_true() { + enabled_rules.extend(Performance::all_rules_as_filters()); + } else if !self.is_recommended_false() { + enabled_rules.extend(Performance::recommended_rules_as_filters()); + } + if let Some(group) = self.security.as_ref() { + group.collect_preset_rules( + self.is_all_true(), + !self.is_recommended_false(), + &mut enabled_rules, + ); + enabled_rules.extend(&group.get_enabled_rules()); + disabled_rules.extend(&group.get_disabled_rules()); + } else if self.is_all_true() { + enabled_rules.extend(Security::all_rules_as_filters()); + } else if !self.is_recommended_false() { + enabled_rules.extend(Security::recommended_rules_as_filters()); + } + enabled_rules.difference(&disabled_rules).copied().collect() + } + #[doc = r" It returns the disabled rules by configuration."] + pub fn as_disabled_rules(&self) -> FxHashSet> { + let mut disabled_rules = FxHashSet::default(); + if let Some(group) = self.performance.as_ref() { + disabled_rules.extend(&group.get_disabled_rules()); + } + if let Some(group) = self.security.as_ref() { + disabled_rules.extend(&group.get_disabled_rules()); + } + disabled_rules + } +} +#[derive(Clone, Debug, Default, Deserialize, Eq, Merge, PartialEq, Serialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase", default, deny_unknown_fields)] +#[doc = r" A list of rules that belong to this group"] +pub struct Performance { + #[doc = r" It enables the recommended rules for this group"] + #[serde(skip_serializing_if = "Option::is_none")] + pub recommended: Option, + #[doc = r" It enables ALL rules for this group."] + #[serde(skip_serializing_if = "Option::is_none")] + pub all: Option, + #[doc = "#title"] + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_rls_initplan: Option>, + #[doc = "#title"] + #[serde(skip_serializing_if = "Option::is_none")] + pub duplicate_index: Option>, + #[doc = "#title"] + #[serde(skip_serializing_if = "Option::is_none")] + pub multiple_permissive_policies: Option>, + #[doc = "#title"] + #[serde(skip_serializing_if = "Option::is_none")] + pub no_primary_key: Option>, + #[doc = "#title"] + #[serde(skip_serializing_if = "Option::is_none")] + pub table_bloat: Option>, + #[doc = "#title"] + #[serde(skip_serializing_if = "Option::is_none")] + pub unindexed_foreign_keys: Option>, + #[doc = "#title"] + #[serde(skip_serializing_if = "Option::is_none")] + pub unused_index: Option>, +} +impl Performance { + const GROUP_NAME: &'static str = "performance"; + pub(crate) const GROUP_RULES: &'static [&'static str] = &[ + "authRlsInitplan", + "duplicateIndex", + "multiplePermissivePolicies", + "noPrimaryKey", + "tableBloat", + "unindexedForeignKeys", + "unusedIndex", + ]; + const RECOMMENDED_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[]; + const ALL_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[ + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[1]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[2]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[3]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[4]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[5]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[6]), + ]; + #[doc = r" Retrieves the recommended rules"] + pub(crate) fn is_recommended_true(&self) -> bool { + matches!(self.recommended, Some(true)) + } + pub(crate) fn is_recommended_unset(&self) -> bool { + self.recommended.is_none() + } + pub(crate) fn is_all_true(&self) -> bool { + matches!(self.all, Some(true)) + } + pub(crate) fn is_all_unset(&self) -> bool { + self.all.is_none() + } + pub(crate) fn get_enabled_rules(&self) -> FxHashSet> { + let mut index_set = FxHashSet::default(); + if let Some(rule) = self.auth_rls_initplan.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0])); + } + } + if let Some(rule) = self.duplicate_index.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[1])); + } + } + if let Some(rule) = self.multiple_permissive_policies.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[2])); + } + } + if let Some(rule) = self.no_primary_key.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[3])); + } + } + if let Some(rule) = self.table_bloat.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[4])); + } + } + if let Some(rule) = self.unindexed_foreign_keys.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[5])); + } + } + if let Some(rule) = self.unused_index.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[6])); + } + } + index_set + } + pub(crate) fn get_disabled_rules(&self) -> FxHashSet> { + let mut index_set = FxHashSet::default(); + if let Some(rule) = self.auth_rls_initplan.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0])); + } + } + if let Some(rule) = self.duplicate_index.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[1])); + } + } + if let Some(rule) = self.multiple_permissive_policies.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[2])); + } + } + if let Some(rule) = self.no_primary_key.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[3])); + } + } + if let Some(rule) = self.table_bloat.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[4])); + } + } + if let Some(rule) = self.unindexed_foreign_keys.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[5])); + } + } + if let Some(rule) = self.unused_index.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[6])); + } + } + index_set + } + #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] + pub(crate) fn has_rule(rule_name: &str) -> Option<&'static str> { + Some(Self::GROUP_RULES[Self::GROUP_RULES.binary_search(&rule_name).ok()?]) + } + pub(crate) fn recommended_rules_as_filters() -> &'static [RuleFilter<'static>] { + Self::RECOMMENDED_RULES_AS_FILTERS + } + pub(crate) fn all_rules_as_filters() -> &'static [RuleFilter<'static>] { + Self::ALL_RULES_AS_FILTERS + } + #[doc = r" Select preset rules"] + pub(crate) fn collect_preset_rules( + &self, + parent_is_all: bool, + parent_is_recommended: bool, + enabled_rules: &mut FxHashSet>, + ) { + if self.is_all_true() || self.is_all_unset() && parent_is_all { + enabled_rules.extend(Self::all_rules_as_filters()); + } else if self.is_recommended_true() + || self.is_recommended_unset() && self.is_all_unset() && parent_is_recommended + { + enabled_rules.extend(Self::recommended_rules_as_filters()); + } + } + pub(crate) fn severity(rule_name: &str) -> Severity { + match rule_name { + "authRlsInitplan" => Severity::Warning, + "duplicateIndex" => Severity::Warning, + "multiplePermissivePolicies" => Severity::Warning, + "noPrimaryKey" => Severity::Information, + "tableBloat" => Severity::Information, + "unindexedForeignKeys" => Severity::Information, + "unusedIndex" => Severity::Information, + _ => unreachable!(), + } + } + pub(crate) fn get_rule_configuration( + &self, + rule_name: &str, + ) -> Option<(RulePlainConfiguration, Option)> { + match rule_name { + "authRlsInitplan" => self + .auth_rls_initplan + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "duplicateIndex" => self + .duplicate_index + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "multiplePermissivePolicies" => self + .multiple_permissive_policies + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "noPrimaryKey" => self + .no_primary_key + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "tableBloat" => self + .table_bloat + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "unindexedForeignKeys" => self + .unindexed_foreign_keys + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "unusedIndex" => self + .unused_index + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + _ => None, + } + } +} +#[derive(Clone, Debug, Default, Deserialize, Eq, Merge, PartialEq, Serialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase", default, deny_unknown_fields)] +#[doc = r" A list of rules that belong to this group"] +pub struct Security { + #[doc = r" It enables the recommended rules for this group"] + #[serde(skip_serializing_if = "Option::is_none")] + pub recommended: Option, + #[doc = r" It enables ALL rules for this group."] + #[serde(skip_serializing_if = "Option::is_none")] + pub all: Option, + #[doc = "#title"] + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_users_exposed: Option>, + #[doc = "#title"] + #[serde(skip_serializing_if = "Option::is_none")] + pub extension_in_public: Option>, + #[doc = "#title"] + #[serde(skip_serializing_if = "Option::is_none")] + pub extension_versions_outdated: Option>, + #[doc = "#title"] + #[serde(skip_serializing_if = "Option::is_none")] + pub fkey_to_auth_unique: Option>, + #[doc = "#title"] + #[serde(skip_serializing_if = "Option::is_none")] + pub foreign_table_in_api: Option>, + #[doc = "#title"] + #[serde(skip_serializing_if = "Option::is_none")] + pub function_search_path_mutable: Option>, + #[doc = "#title"] + #[serde(skip_serializing_if = "Option::is_none")] + pub insecure_queue_exposed_in_api: Option>, + #[doc = "#title"] + #[serde(skip_serializing_if = "Option::is_none")] + pub materialized_view_in_api: Option>, + #[doc = "#title"] + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_exists_rls_disabled: Option>, + #[doc = "#title"] + #[serde(skip_serializing_if = "Option::is_none")] + pub rls_disabled_in_public: Option>, + #[doc = "#title"] + #[serde(skip_serializing_if = "Option::is_none")] + pub rls_enabled_no_policy: Option>, + #[doc = "#title"] + #[serde(skip_serializing_if = "Option::is_none")] + pub rls_references_user_metadata: Option>, + #[doc = "#title"] + #[serde(skip_serializing_if = "Option::is_none")] + pub security_definer_view: Option>, + #[doc = "#title"] + #[serde(skip_serializing_if = "Option::is_none")] + pub unsupported_reg_types: Option>, +} +impl Security { + const GROUP_NAME: &'static str = "security"; + pub(crate) const GROUP_RULES: &'static [&'static str] = &[ + "authUsersExposed", + "extensionInPublic", + "extensionVersionsOutdated", + "fkeyToAuthUnique", + "foreignTableInApi", + "functionSearchPathMutable", + "insecureQueueExposedInApi", + "materializedViewInApi", + "policyExistsRlsDisabled", + "rlsDisabledInPublic", + "rlsEnabledNoPolicy", + "rlsReferencesUserMetadata", + "securityDefinerView", + "unsupportedRegTypes", + ]; + const RECOMMENDED_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[]; + const ALL_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[ + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[1]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[2]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[3]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[4]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[5]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[6]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[8]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[9]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[10]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[11]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[12]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[13]), + ]; + #[doc = r" Retrieves the recommended rules"] + pub(crate) fn is_recommended_true(&self) -> bool { + matches!(self.recommended, Some(true)) + } + pub(crate) fn is_recommended_unset(&self) -> bool { + self.recommended.is_none() + } + pub(crate) fn is_all_true(&self) -> bool { + matches!(self.all, Some(true)) + } + pub(crate) fn is_all_unset(&self) -> bool { + self.all.is_none() + } + pub(crate) fn get_enabled_rules(&self) -> FxHashSet> { + let mut index_set = FxHashSet::default(); + if let Some(rule) = self.auth_users_exposed.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0])); + } + } + if let Some(rule) = self.extension_in_public.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[1])); + } + } + if let Some(rule) = self.extension_versions_outdated.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[2])); + } + } + if let Some(rule) = self.fkey_to_auth_unique.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[3])); + } + } + if let Some(rule) = self.foreign_table_in_api.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[4])); + } + } + if let Some(rule) = self.function_search_path_mutable.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[5])); + } + } + if let Some(rule) = self.insecure_queue_exposed_in_api.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[6])); + } + } + if let Some(rule) = self.materialized_view_in_api.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7])); + } + } + if let Some(rule) = self.policy_exists_rls_disabled.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[8])); + } + } + if let Some(rule) = self.rls_disabled_in_public.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[9])); + } + } + if let Some(rule) = self.rls_enabled_no_policy.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[10])); + } + } + if let Some(rule) = self.rls_references_user_metadata.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[11])); + } + } + if let Some(rule) = self.security_definer_view.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[12])); + } + } + if let Some(rule) = self.unsupported_reg_types.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[13])); + } + } + index_set + } + pub(crate) fn get_disabled_rules(&self) -> FxHashSet> { + let mut index_set = FxHashSet::default(); + if let Some(rule) = self.auth_users_exposed.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0])); + } + } + if let Some(rule) = self.extension_in_public.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[1])); + } + } + if let Some(rule) = self.extension_versions_outdated.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[2])); + } + } + if let Some(rule) = self.fkey_to_auth_unique.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[3])); + } + } + if let Some(rule) = self.foreign_table_in_api.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[4])); + } + } + if let Some(rule) = self.function_search_path_mutable.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[5])); + } + } + if let Some(rule) = self.insecure_queue_exposed_in_api.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[6])); + } + } + if let Some(rule) = self.materialized_view_in_api.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7])); + } + } + if let Some(rule) = self.policy_exists_rls_disabled.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[8])); + } + } + if let Some(rule) = self.rls_disabled_in_public.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[9])); + } + } + if let Some(rule) = self.rls_enabled_no_policy.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[10])); + } + } + if let Some(rule) = self.rls_references_user_metadata.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[11])); + } + } + if let Some(rule) = self.security_definer_view.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[12])); + } + } + if let Some(rule) = self.unsupported_reg_types.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[13])); + } + } + index_set + } + #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] + pub(crate) fn has_rule(rule_name: &str) -> Option<&'static str> { + Some(Self::GROUP_RULES[Self::GROUP_RULES.binary_search(&rule_name).ok()?]) + } + pub(crate) fn recommended_rules_as_filters() -> &'static [RuleFilter<'static>] { + Self::RECOMMENDED_RULES_AS_FILTERS + } + pub(crate) fn all_rules_as_filters() -> &'static [RuleFilter<'static>] { + Self::ALL_RULES_AS_FILTERS + } + #[doc = r" Select preset rules"] + pub(crate) fn collect_preset_rules( + &self, + parent_is_all: bool, + parent_is_recommended: bool, + enabled_rules: &mut FxHashSet>, + ) { + if self.is_all_true() || self.is_all_unset() && parent_is_all { + enabled_rules.extend(Self::all_rules_as_filters()); + } else if self.is_recommended_true() + || self.is_recommended_unset() && self.is_all_unset() && parent_is_recommended + { + enabled_rules.extend(Self::recommended_rules_as_filters()); + } + } + pub(crate) fn severity(rule_name: &str) -> Severity { + match rule_name { + "authUsersExposed" => Severity::Error, + "extensionInPublic" => Severity::Warning, + "extensionVersionsOutdated" => Severity::Warning, + "fkeyToAuthUnique" => Severity::Error, + "foreignTableInApi" => Severity::Warning, + "functionSearchPathMutable" => Severity::Warning, + "insecureQueueExposedInApi" => Severity::Error, + "materializedViewInApi" => Severity::Warning, + "policyExistsRlsDisabled" => Severity::Error, + "rlsDisabledInPublic" => Severity::Error, + "rlsEnabledNoPolicy" => Severity::Information, + "rlsReferencesUserMetadata" => Severity::Error, + "securityDefinerView" => Severity::Error, + "unsupportedRegTypes" => Severity::Warning, + _ => unreachable!(), + } + } + pub(crate) fn get_rule_configuration( + &self, + rule_name: &str, + ) -> Option<(RulePlainConfiguration, Option)> { + match rule_name { + "authUsersExposed" => self + .auth_users_exposed + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "extensionInPublic" => self + .extension_in_public + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "extensionVersionsOutdated" => self + .extension_versions_outdated + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "fkeyToAuthUnique" => self + .fkey_to_auth_unique + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "foreignTableInApi" => self + .foreign_table_in_api + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "functionSearchPathMutable" => self + .function_search_path_mutable + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "insecureQueueExposedInApi" => self + .insecure_queue_exposed_in_api + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "materializedViewInApi" => self + .materialized_view_in_api + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "policyExistsRlsDisabled" => self + .policy_exists_rls_disabled + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "rlsDisabledInPublic" => self + .rls_disabled_in_public + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "rlsEnabledNoPolicy" => self + .rls_enabled_no_policy + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "rlsReferencesUserMetadata" => self + .rls_references_user_metadata + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "securityDefinerView" => self + .security_definer_view + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "unsupportedRegTypes" => self + .unsupported_reg_types + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + _ => None, + } + } +} +#[test] +fn test_order() { + for items in Performance::GROUP_RULES.windows(2) { + assert!(items[0] < items[1], "{} < {}", items[0], items[1]); + } + for items in Security::GROUP_RULES.windows(2) { + assert!(items[0] < items[1], "{} < {}", items[0], items[1]); + } +} diff --git a/crates/pgls_configuration/src/generated/splinter.rs b/crates/pgls_configuration/src/generated/splinter.rs new file mode 100644 index 000000000..ad6504041 --- /dev/null +++ b/crates/pgls_configuration/src/generated/splinter.rs @@ -0,0 +1,29 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +use crate::analyser::splinter::*; +use pgls_analyse::MetadataRegistry; +use pgls_analyser::LinterRules; +pub fn push_to_analyser_splinter( + rules: &Rules, + metadata: &MetadataRegistry, + analyser_rules: &mut LinterRules, +) { + if let Some(rules) = rules.performance.as_ref() { + for rule_name in Performance::GROUP_RULES { + if let Some((_, Some(rule_options))) = rules.get_rule_configuration(rule_name) { + if let Some(rule_key) = metadata.find_rule("performance", rule_name) { + analyser_rules.push_rule(rule_key, rule_options); + } + } + } + } + if let Some(rules) = rules.security.as_ref() { + for rule_name in Security::GROUP_RULES { + if let Some((_, Some(rule_options))) = rules.get_rule_configuration(rule_name) { + if let Some(rule_key) = metadata.find_rule("security", rule_name) { + analyser_rules.push_rule(rule_key, rule_options); + } + } + } + } +} diff --git a/crates/pgls_configuration/src/linter/rules.rs b/crates/pgls_configuration/src/linter/rules.rs index 4bc3d4c98..6336d88fa 100644 --- a/crates/pgls_configuration/src/linter/rules.rs +++ b/crates/pgls_configuration/src/linter/rules.rs @@ -72,8 +72,8 @@ impl Rules { #[doc = r" The function can return `None` if the rule is not properly configured."] pub fn get_severity_from_code(&self, category: &Category) -> Option { let mut split_code = category.name().split('/'); - let _lint = split_code.next(); - debug_assert_eq!(_lint, Some("lint")); + let _category = split_code.next(); + debug_assert_eq!(_category, Some("lint")); let group = ::from_str(split_code.next()?).ok()?; let rule_name = split_code.next()?; let rule_name = Self::has_rule(group, rule_name)?; diff --git a/crates/pgls_diagnostics_categories/src/categories.rs b/crates/pgls_diagnostics_categories/src/categories.rs index ce18c4d09..eb9e323ac 100644 --- a/crates/pgls_diagnostics_categories/src/categories.rs +++ b/crates/pgls_diagnostics_categories/src/categories.rs @@ -48,28 +48,27 @@ define_categories! { "lint/safety/transactionNesting": "https://pg-language-server.com/latest/reference/rules/transaction-nesting/", // end lint rules // splinter rules start - "splinter/performance/authRlsInitplan": "https://supabase.com/docs/guides/database/database-advisors?lint=0003_auth_rls_initplan", - "splinter/performance/duplicateIndex": "https://supabase.com/docs/guides/database/database-advisors?lint=0009_duplicate_index", - "splinter/performance/multiplePermissivePolicies": "https://supabase.com/docs/guides/database/database-advisors?lint=0006_multiple_permissive_policies", - "splinter/performance/noPrimaryKey": "https://supabase.com/docs/guides/database/database-advisors?lint=0004_no_primary_key", - "splinter/performance/tableBloat": "https://supabase.com/docs/guides/database/database-advisors", - "splinter/performance/unindexedForeignKeys": "https://supabase.com/docs/guides/database/database-advisors?lint=0001_unindexed_foreign_keys", - "splinter/performance/unusedIndex": "https://supabase.com/docs/guides/database/database-advisors?lint=0005_unused_index", - "splinter/security/authUsersExposed": "https://supabase.com/docs/guides/database/database-advisors?lint=0002_auth_users_exposed", - "splinter/security/extensionInPublic": "https://supabase.com/docs/guides/database/database-advisors?lint=0014_extension_in_public", - "splinter/security/extensionVersionsOutdated": "https://supabase.com/docs/guides/database/database-advisors?lint=0022_extension_versions_outdated", - "splinter/security/fkeyToAuthUnique": "https://supabase.com/docs/guides/database/database-advisors", - "splinter/security/foreignTableInApi": "https://supabase.com/docs/guides/database/database-advisors?lint=0017_foreign_table_in_api", - "splinter/security/functionSearchPathMutable": "https://supabase.com/docs/guides/database/database-advisors?lint=0011_function_search_path_mutable", - "splinter/security/insecureQueueExposedInApi": "https://supabase.com/docs/guides/database/database-advisors?lint=0019_insecure_queue_exposed_in_api", - "splinter/security/materializedViewInApi": "https://supabase.com/docs/guides/database/database-advisors?lint=0016_materialized_view_in_api", - "splinter/security/policyExistsRlsDisabled": "https://supabase.com/docs/guides/database/database-advisors?lint=0007_policy_exists_rls_disabled", - "splinter/security/rlsDisabledInPublic": "https://supabase.com/docs/guides/database/database-advisors?lint=0013_rls_disabled_in_public", - "splinter/security/rlsEnabledNoPolicy": "https://supabase.com/docs/guides/database/database-advisors?lint=0008_rls_enabled_no_policy", - "splinter/security/rlsReferencesUserMetadata": "https://supabase.com/docs/guides/database/database-advisors?lint=0015_rls_references_user_metadata", - "splinter/security/securityDefinerView": "https://supabase.com/docs/guides/database/database-advisors?lint=0010_security_definer_view", - "splinter/security/unsupportedRegTypes": "https://supabase.com/docs/guides/database/database-advisors?lint=unsupported_reg_types", - "splinter/unknown/unknown": "https://supabase.com/docs/guides/database/database-advisors", + "splinter/performance/authRlsInitplan": "https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan", + "splinter/performance/duplicateIndex": "https://supabase.com/docs/guides/database/database-linter?lint=0009_duplicate_index", + "splinter/performance/multiplePermissivePolicies": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies", + "splinter/performance/noPrimaryKey": "https://supabase.com/docs/guides/database/database-linter?lint=0004_no_primary_key", + "splinter/performance/tableBloat": "Consider running vacuum full (WARNING: incurs downtime) and tweaking autovacuum settings to reduce bloat.", + "splinter/performance/unindexedForeignKeys": "https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys", + "splinter/performance/unusedIndex": "https://supabase.com/docs/guides/database/database-linter?lint=0005_unused_index", + "splinter/security/authUsersExposed": "https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed", + "splinter/security/extensionInPublic": "https://supabase.com/docs/guides/database/database-linter?lint=0014_extension_in_public", + "splinter/security/extensionVersionsOutdated": "https://supabase.com/docs/guides/database/database-linter?lint=0022_extension_versions_outdated", + "splinter/security/fkeyToAuthUnique": "Drop the foreign key constraint that references the auth schema.", + "splinter/security/foreignTableInApi": "https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api", + "splinter/security/functionSearchPathMutable": "https://supabase.com/docs/guides/database/database-linter?lint=0011_function_search_path_mutable", + "splinter/security/insecureQueueExposedInApi": "https://supabase.com/docs/guides/database/database-linter?lint=0019_insecure_queue_exposed_in_api", + "splinter/security/materializedViewInApi": "https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api", + "splinter/security/policyExistsRlsDisabled": "https://supabase.com/docs/guides/database/database-linter?lint=0007_policy_exists_rls_disabled", + "splinter/security/rlsDisabledInPublic": "https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public", + "splinter/security/rlsEnabledNoPolicy": "https://supabase.com/docs/guides/database/database-linter?lint=0008_rls_enabled_no_policy", + "splinter/security/rlsReferencesUserMetadata": "https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata", + "splinter/security/securityDefinerView": "https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view", + "splinter/security/unsupportedRegTypes": "https://supabase.com/docs/guides/database/database-linter?lint=unsupported_reg_types", // splinter rules end ; // General categories @@ -98,6 +97,5 @@ define_categories! { "splinter", "splinter/performance", "splinter/security", - "splinter/unknown", // Splinter groups end } diff --git a/crates/pgls_splinter/Cargo.toml b/crates/pgls_splinter/Cargo.toml index f07273e2a..4455d4f6e 100644 --- a/crates/pgls_splinter/Cargo.toml +++ b/crates/pgls_splinter/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true version = "0.0.0" [dependencies] +pgls_analyse.workspace = true pgls_diagnostics.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/pgls_splinter/src/convert.rs b/crates/pgls_splinter/src/convert.rs index c5d0c7904..81e2e8db5 100644 --- a/crates/pgls_splinter/src/convert.rs +++ b/crates/pgls_splinter/src/convert.rs @@ -103,7 +103,11 @@ fn rule_name_to_category(name: &str, group: &str) -> &'static Category { category!("splinter/security/insecureQueueExposedInApi") } ("security", "fkey_to_auth_unique") => category!("splinter/security/fkeyToAuthUnique"), - _ => category!("splinter/unknown/unknown"), + _ => { + // Log a warning for unknown rules but provide a fallback + eprintln!("Warning: Unknown splinter rule: {}/{}", group, name); + category!("splinter/performance/unindexedForeignKeys") // Fallback to first rule + } } } diff --git a/crates/pgls_splinter/src/lib.rs b/crates/pgls_splinter/src/lib.rs index b32df2329..a42f39439 100644 --- a/crates/pgls_splinter/src/lib.rs +++ b/crates/pgls_splinter/src/lib.rs @@ -1,11 +1,15 @@ mod convert; mod diagnostics; mod query; +pub mod registry; +pub mod rule; +pub mod rules; use sqlx::PgPool; pub use diagnostics::{SplinterAdvices, SplinterDiagnostic}; pub use query::SplinterQueryResult; +pub use rule::SplinterRule; #[derive(Debug)] pub struct SplinterParams<'a> { diff --git a/crates/pgls_splinter/src/registry.rs b/crates/pgls_splinter/src/registry.rs new file mode 100644 index 000000000..64f211b8d --- /dev/null +++ b/crates/pgls_splinter/src/registry.rs @@ -0,0 +1,79 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use pgls_analyse::RegistryVisitor; +#[doc = r" Visit all splinter rules using the visitor pattern"] +#[doc = r" This is called during registry building to collect enabled rules"] +pub fn visit_registry(registry: &mut V) { + registry.record_category::(); +} +#[doc = r" Map rule name (camelCase) to SQL file path"] +#[doc = r" Returns None if rule not found"] +pub fn get_sql_file_path(rule_name: &str) -> Option<&'static str> { + match rule_name { + "authRlsInitplan" => Some( + "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/performance/auth_rls_initplan.sql", + ), + "authUsersExposed" => Some( + "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/auth_users_exposed.sql", + ), + "duplicateIndex" => Some( + "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/performance/duplicate_index.sql", + ), + "extensionInPublic" => Some( + "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/extension_in_public.sql", + ), + "extensionVersionsOutdated" => Some( + "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/extension_versions_outdated.sql", + ), + "fkeyToAuthUnique" => Some( + "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/fkey_to_auth_unique.sql", + ), + "foreignTableInApi" => Some( + "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/foreign_table_in_api.sql", + ), + "functionSearchPathMutable" => Some( + "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/function_search_path_mutable.sql", + ), + "insecureQueueExposedInApi" => Some( + "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/insecure_queue_exposed_in_api.sql", + ), + "materializedViewInApi" => Some( + "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/materialized_view_in_api.sql", + ), + "multiplePermissivePolicies" => Some( + "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/performance/multiple_permissive_policies.sql", + ), + "noPrimaryKey" => Some( + "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/performance/no_primary_key.sql", + ), + "policyExistsRlsDisabled" => Some( + "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/policy_exists_rls_disabled.sql", + ), + "rlsDisabledInPublic" => Some( + "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/rls_disabled_in_public.sql", + ), + "rlsEnabledNoPolicy" => Some( + "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/rls_enabled_no_policy.sql", + ), + "rlsReferencesUserMetadata" => Some( + "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/rls_references_user_metadata.sql", + ), + "securityDefinerView" => Some( + "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/security_definer_view.sql", + ), + "tableBloat" => Some( + "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/performance/table_bloat.sql", + ), + "unindexedForeignKeys" => Some( + "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/performance/unindexed_foreign_keys.sql", + ), + "unsupportedRegTypes" => Some( + "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/unsupported_reg_types.sql", + ), + "unusedIndex" => Some( + "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/performance/unused_index.sql", + ), + _ => None, + } +} diff --git a/crates/pgls_splinter/src/rule.rs b/crates/pgls_splinter/src/rule.rs new file mode 100644 index 000000000..0b1ef1046 --- /dev/null +++ b/crates/pgls_splinter/src/rule.rs @@ -0,0 +1,14 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use pgls_analyse::RuleMeta; +#[doc = r" Trait for splinter (database-level) rules"] +#[doc = r""] +#[doc = r" Splinter rules are different from linter rules:"] +#[doc = r" - They execute SQL queries against the database"] +#[doc = r" - They don't have AST-based execution"] +#[doc = r" - Rule logic is in SQL files, not Rust"] +pub trait SplinterRule: RuleMeta { + #[doc = r" Path to the SQL file containing the rule query"] + fn sql_file_path() -> &'static str; +} diff --git a/crates/pgls_splinter/src/rules/mod.rs b/crates/pgls_splinter/src/rules/mod.rs new file mode 100644 index 000000000..715803f0c --- /dev/null +++ b/crates/pgls_splinter/src/rules/mod.rs @@ -0,0 +1,6 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +pub mod performance; +pub mod security; +::pgls_analyse::declare_category! { pub Splinter { kind : Lint , groups : [self :: performance :: Performance , self :: security :: Security ,] } } diff --git a/crates/pgls_splinter/src/rules/performance/auth_rls_initplan.rs b/crates/pgls_splinter/src/rules/performance/auth_rls_initplan.rs new file mode 100644 index 000000000..fd64ce41f --- /dev/null +++ b/crates/pgls_splinter/src/rules/performance/auth_rls_initplan.rs @@ -0,0 +1,11 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use crate::rule::SplinterRule; +use pgls_analyse::RuleMeta; +::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub AuthRlsInitplan { version : "1.0.0" , name : "authRlsInitplan" , severity : pgls_diagnostics :: Severity :: Warning , } } +impl SplinterRule for AuthRlsInitplan { + fn sql_file_path() -> &'static str { + "performance/auth_rls_initplan.sql" + } +} diff --git a/crates/pgls_splinter/src/rules/performance/duplicate_index.rs b/crates/pgls_splinter/src/rules/performance/duplicate_index.rs new file mode 100644 index 000000000..81394496f --- /dev/null +++ b/crates/pgls_splinter/src/rules/performance/duplicate_index.rs @@ -0,0 +1,11 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use crate::rule::SplinterRule; +use pgls_analyse::RuleMeta; +::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub DuplicateIndex { version : "1.0.0" , name : "duplicateIndex" , severity : pgls_diagnostics :: Severity :: Warning , } } +impl SplinterRule for DuplicateIndex { + fn sql_file_path() -> &'static str { + "performance/duplicate_index.sql" + } +} diff --git a/crates/pgls_splinter/src/rules/performance/mod.rs b/crates/pgls_splinter/src/rules/performance/mod.rs new file mode 100644 index 000000000..6192266d3 --- /dev/null +++ b/crates/pgls_splinter/src/rules/performance/mod.rs @@ -0,0 +1,11 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +pub mod auth_rls_initplan; +pub mod duplicate_index; +pub mod multiple_permissive_policies; +pub mod no_primary_key; +pub mod table_bloat; +pub mod unindexed_foreign_keys; +pub mod unused_index; +::pgls_analyse::declare_lint_group! { pub Performance { name : "performance" , rules : [self :: auth_rls_initplan :: AuthRlsInitplan , self :: duplicate_index :: DuplicateIndex , self :: multiple_permissive_policies :: MultiplePermissivePolicies , self :: no_primary_key :: NoPrimaryKey , self :: table_bloat :: TableBloat , self :: unindexed_foreign_keys :: UnindexedForeignKeys , self :: unused_index :: UnusedIndex ,] } } diff --git a/crates/pgls_splinter/src/rules/performance/multiple_permissive_policies.rs b/crates/pgls_splinter/src/rules/performance/multiple_permissive_policies.rs new file mode 100644 index 000000000..45d45733f --- /dev/null +++ b/crates/pgls_splinter/src/rules/performance/multiple_permissive_policies.rs @@ -0,0 +1,11 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use crate::rule::SplinterRule; +use pgls_analyse::RuleMeta; +::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub MultiplePermissivePolicies { version : "1.0.0" , name : "multiplePermissivePolicies" , severity : pgls_diagnostics :: Severity :: Warning , } } +impl SplinterRule for MultiplePermissivePolicies { + fn sql_file_path() -> &'static str { + "performance/multiple_permissive_policies.sql" + } +} diff --git a/crates/pgls_splinter/src/rules/performance/no_primary_key.rs b/crates/pgls_splinter/src/rules/performance/no_primary_key.rs new file mode 100644 index 000000000..3127f7bf3 --- /dev/null +++ b/crates/pgls_splinter/src/rules/performance/no_primary_key.rs @@ -0,0 +1,11 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use crate::rule::SplinterRule; +use pgls_analyse::RuleMeta; +::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub NoPrimaryKey { version : "1.0.0" , name : "noPrimaryKey" , severity : pgls_diagnostics :: Severity :: Information , } } +impl SplinterRule for NoPrimaryKey { + fn sql_file_path() -> &'static str { + "performance/no_primary_key.sql" + } +} diff --git a/crates/pgls_splinter/src/rules/performance/table_bloat.rs b/crates/pgls_splinter/src/rules/performance/table_bloat.rs new file mode 100644 index 000000000..1efe0d43a --- /dev/null +++ b/crates/pgls_splinter/src/rules/performance/table_bloat.rs @@ -0,0 +1,11 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use crate::rule::SplinterRule; +use pgls_analyse::RuleMeta; +::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub TableBloat { version : "1.0.0" , name : "tableBloat" , severity : pgls_diagnostics :: Severity :: Information , } } +impl SplinterRule for TableBloat { + fn sql_file_path() -> &'static str { + "performance/table_bloat.sql" + } +} diff --git a/crates/pgls_splinter/src/rules/performance/unindexed_foreign_keys.rs b/crates/pgls_splinter/src/rules/performance/unindexed_foreign_keys.rs new file mode 100644 index 000000000..4e75899b6 --- /dev/null +++ b/crates/pgls_splinter/src/rules/performance/unindexed_foreign_keys.rs @@ -0,0 +1,11 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use crate::rule::SplinterRule; +use pgls_analyse::RuleMeta; +::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub UnindexedForeignKeys { version : "1.0.0" , name : "unindexedForeignKeys" , severity : pgls_diagnostics :: Severity :: Information , } } +impl SplinterRule for UnindexedForeignKeys { + fn sql_file_path() -> &'static str { + "performance/unindexed_foreign_keys.sql" + } +} diff --git a/crates/pgls_splinter/src/rules/performance/unused_index.rs b/crates/pgls_splinter/src/rules/performance/unused_index.rs new file mode 100644 index 000000000..afdf18c36 --- /dev/null +++ b/crates/pgls_splinter/src/rules/performance/unused_index.rs @@ -0,0 +1,11 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use crate::rule::SplinterRule; +use pgls_analyse::RuleMeta; +::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub UnusedIndex { version : "1.0.0" , name : "unusedIndex" , severity : pgls_diagnostics :: Severity :: Information , } } +impl SplinterRule for UnusedIndex { + fn sql_file_path() -> &'static str { + "performance/unused_index.sql" + } +} diff --git a/crates/pgls_splinter/src/rules/security/auth_users_exposed.rs b/crates/pgls_splinter/src/rules/security/auth_users_exposed.rs new file mode 100644 index 000000000..c159f470d --- /dev/null +++ b/crates/pgls_splinter/src/rules/security/auth_users_exposed.rs @@ -0,0 +1,11 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use crate::rule::SplinterRule; +use pgls_analyse::RuleMeta; +::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub AuthUsersExposed { version : "1.0.0" , name : "authUsersExposed" , severity : pgls_diagnostics :: Severity :: Error , } } +impl SplinterRule for AuthUsersExposed { + fn sql_file_path() -> &'static str { + "security/auth_users_exposed.sql" + } +} diff --git a/crates/pgls_splinter/src/rules/security/extension_in_public.rs b/crates/pgls_splinter/src/rules/security/extension_in_public.rs new file mode 100644 index 000000000..e341ddf02 --- /dev/null +++ b/crates/pgls_splinter/src/rules/security/extension_in_public.rs @@ -0,0 +1,11 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use crate::rule::SplinterRule; +use pgls_analyse::RuleMeta; +::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub ExtensionInPublic { version : "1.0.0" , name : "extensionInPublic" , severity : pgls_diagnostics :: Severity :: Warning , } } +impl SplinterRule for ExtensionInPublic { + fn sql_file_path() -> &'static str { + "security/extension_in_public.sql" + } +} diff --git a/crates/pgls_splinter/src/rules/security/extension_versions_outdated.rs b/crates/pgls_splinter/src/rules/security/extension_versions_outdated.rs new file mode 100644 index 000000000..3596a6b99 --- /dev/null +++ b/crates/pgls_splinter/src/rules/security/extension_versions_outdated.rs @@ -0,0 +1,11 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use crate::rule::SplinterRule; +use pgls_analyse::RuleMeta; +::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub ExtensionVersionsOutdated { version : "1.0.0" , name : "extensionVersionsOutdated" , severity : pgls_diagnostics :: Severity :: Warning , } } +impl SplinterRule for ExtensionVersionsOutdated { + fn sql_file_path() -> &'static str { + "security/extension_versions_outdated.sql" + } +} diff --git a/crates/pgls_splinter/src/rules/security/fkey_to_auth_unique.rs b/crates/pgls_splinter/src/rules/security/fkey_to_auth_unique.rs new file mode 100644 index 000000000..12986b7ef --- /dev/null +++ b/crates/pgls_splinter/src/rules/security/fkey_to_auth_unique.rs @@ -0,0 +1,11 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use crate::rule::SplinterRule; +use pgls_analyse::RuleMeta; +::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub FkeyToAuthUnique { version : "1.0.0" , name : "fkeyToAuthUnique" , severity : pgls_diagnostics :: Severity :: Error , } } +impl SplinterRule for FkeyToAuthUnique { + fn sql_file_path() -> &'static str { + "security/fkey_to_auth_unique.sql" + } +} diff --git a/crates/pgls_splinter/src/rules/security/foreign_table_in_api.rs b/crates/pgls_splinter/src/rules/security/foreign_table_in_api.rs new file mode 100644 index 000000000..9bd223d86 --- /dev/null +++ b/crates/pgls_splinter/src/rules/security/foreign_table_in_api.rs @@ -0,0 +1,11 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use crate::rule::SplinterRule; +use pgls_analyse::RuleMeta; +::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub ForeignTableInApi { version : "1.0.0" , name : "foreignTableInApi" , severity : pgls_diagnostics :: Severity :: Warning , } } +impl SplinterRule for ForeignTableInApi { + fn sql_file_path() -> &'static str { + "security/foreign_table_in_api.sql" + } +} diff --git a/crates/pgls_splinter/src/rules/security/function_search_path_mutable.rs b/crates/pgls_splinter/src/rules/security/function_search_path_mutable.rs new file mode 100644 index 000000000..0786cffb1 --- /dev/null +++ b/crates/pgls_splinter/src/rules/security/function_search_path_mutable.rs @@ -0,0 +1,11 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use crate::rule::SplinterRule; +use pgls_analyse::RuleMeta; +::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub FunctionSearchPathMutable { version : "1.0.0" , name : "functionSearchPathMutable" , severity : pgls_diagnostics :: Severity :: Warning , } } +impl SplinterRule for FunctionSearchPathMutable { + fn sql_file_path() -> &'static str { + "security/function_search_path_mutable.sql" + } +} diff --git a/crates/pgls_splinter/src/rules/security/insecure_queue_exposed_in_api.rs b/crates/pgls_splinter/src/rules/security/insecure_queue_exposed_in_api.rs new file mode 100644 index 000000000..9916e69da --- /dev/null +++ b/crates/pgls_splinter/src/rules/security/insecure_queue_exposed_in_api.rs @@ -0,0 +1,11 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use crate::rule::SplinterRule; +use pgls_analyse::RuleMeta; +::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub InsecureQueueExposedInApi { version : "1.0.0" , name : "insecureQueueExposedInApi" , severity : pgls_diagnostics :: Severity :: Error , } } +impl SplinterRule for InsecureQueueExposedInApi { + fn sql_file_path() -> &'static str { + "security/insecure_queue_exposed_in_api.sql" + } +} diff --git a/crates/pgls_splinter/src/rules/security/materialized_view_in_api.rs b/crates/pgls_splinter/src/rules/security/materialized_view_in_api.rs new file mode 100644 index 000000000..8402712cf --- /dev/null +++ b/crates/pgls_splinter/src/rules/security/materialized_view_in_api.rs @@ -0,0 +1,11 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use crate::rule::SplinterRule; +use pgls_analyse::RuleMeta; +::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub MaterializedViewInApi { version : "1.0.0" , name : "materializedViewInApi" , severity : pgls_diagnostics :: Severity :: Warning , } } +impl SplinterRule for MaterializedViewInApi { + fn sql_file_path() -> &'static str { + "security/materialized_view_in_api.sql" + } +} diff --git a/crates/pgls_splinter/src/rules/security/mod.rs b/crates/pgls_splinter/src/rules/security/mod.rs new file mode 100644 index 000000000..1d4eb9c0f --- /dev/null +++ b/crates/pgls_splinter/src/rules/security/mod.rs @@ -0,0 +1,18 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +pub mod auth_users_exposed; +pub mod extension_in_public; +pub mod extension_versions_outdated; +pub mod fkey_to_auth_unique; +pub mod foreign_table_in_api; +pub mod function_search_path_mutable; +pub mod insecure_queue_exposed_in_api; +pub mod materialized_view_in_api; +pub mod policy_exists_rls_disabled; +pub mod rls_disabled_in_public; +pub mod rls_enabled_no_policy; +pub mod rls_references_user_metadata; +pub mod security_definer_view; +pub mod unsupported_reg_types; +::pgls_analyse::declare_lint_group! { pub Security { name : "security" , rules : [self :: auth_users_exposed :: AuthUsersExposed , self :: extension_in_public :: ExtensionInPublic , self :: extension_versions_outdated :: ExtensionVersionsOutdated , self :: fkey_to_auth_unique :: FkeyToAuthUnique , self :: foreign_table_in_api :: ForeignTableInApi , self :: function_search_path_mutable :: FunctionSearchPathMutable , self :: insecure_queue_exposed_in_api :: InsecureQueueExposedInApi , self :: materialized_view_in_api :: MaterializedViewInApi , self :: policy_exists_rls_disabled :: PolicyExistsRlsDisabled , self :: rls_disabled_in_public :: RlsDisabledInPublic , self :: rls_enabled_no_policy :: RlsEnabledNoPolicy , self :: rls_references_user_metadata :: RlsReferencesUserMetadata , self :: security_definer_view :: SecurityDefinerView , self :: unsupported_reg_types :: UnsupportedRegTypes ,] } } diff --git a/crates/pgls_splinter/src/rules/security/policy_exists_rls_disabled.rs b/crates/pgls_splinter/src/rules/security/policy_exists_rls_disabled.rs new file mode 100644 index 000000000..eb4bb493d --- /dev/null +++ b/crates/pgls_splinter/src/rules/security/policy_exists_rls_disabled.rs @@ -0,0 +1,11 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use crate::rule::SplinterRule; +use pgls_analyse::RuleMeta; +::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub PolicyExistsRlsDisabled { version : "1.0.0" , name : "policyExistsRlsDisabled" , severity : pgls_diagnostics :: Severity :: Error , } } +impl SplinterRule for PolicyExistsRlsDisabled { + fn sql_file_path() -> &'static str { + "security/policy_exists_rls_disabled.sql" + } +} diff --git a/crates/pgls_splinter/src/rules/security/rls_disabled_in_public.rs b/crates/pgls_splinter/src/rules/security/rls_disabled_in_public.rs new file mode 100644 index 000000000..0844e5108 --- /dev/null +++ b/crates/pgls_splinter/src/rules/security/rls_disabled_in_public.rs @@ -0,0 +1,11 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use crate::rule::SplinterRule; +use pgls_analyse::RuleMeta; +::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub RlsDisabledInPublic { version : "1.0.0" , name : "rlsDisabledInPublic" , severity : pgls_diagnostics :: Severity :: Error , } } +impl SplinterRule for RlsDisabledInPublic { + fn sql_file_path() -> &'static str { + "security/rls_disabled_in_public.sql" + } +} diff --git a/crates/pgls_splinter/src/rules/security/rls_enabled_no_policy.rs b/crates/pgls_splinter/src/rules/security/rls_enabled_no_policy.rs new file mode 100644 index 000000000..d184ef5dc --- /dev/null +++ b/crates/pgls_splinter/src/rules/security/rls_enabled_no_policy.rs @@ -0,0 +1,11 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use crate::rule::SplinterRule; +use pgls_analyse::RuleMeta; +::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub RlsEnabledNoPolicy { version : "1.0.0" , name : "rlsEnabledNoPolicy" , severity : pgls_diagnostics :: Severity :: Information , } } +impl SplinterRule for RlsEnabledNoPolicy { + fn sql_file_path() -> &'static str { + "security/rls_enabled_no_policy.sql" + } +} diff --git a/crates/pgls_splinter/src/rules/security/rls_references_user_metadata.rs b/crates/pgls_splinter/src/rules/security/rls_references_user_metadata.rs new file mode 100644 index 000000000..c93d3aa10 --- /dev/null +++ b/crates/pgls_splinter/src/rules/security/rls_references_user_metadata.rs @@ -0,0 +1,11 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use crate::rule::SplinterRule; +use pgls_analyse::RuleMeta; +::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub RlsReferencesUserMetadata { version : "1.0.0" , name : "rlsReferencesUserMetadata" , severity : pgls_diagnostics :: Severity :: Error , } } +impl SplinterRule for RlsReferencesUserMetadata { + fn sql_file_path() -> &'static str { + "security/rls_references_user_metadata.sql" + } +} diff --git a/crates/pgls_splinter/src/rules/security/security_definer_view.rs b/crates/pgls_splinter/src/rules/security/security_definer_view.rs new file mode 100644 index 000000000..b58714b03 --- /dev/null +++ b/crates/pgls_splinter/src/rules/security/security_definer_view.rs @@ -0,0 +1,11 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use crate::rule::SplinterRule; +use pgls_analyse::RuleMeta; +::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub SecurityDefinerView { version : "1.0.0" , name : "securityDefinerView" , severity : pgls_diagnostics :: Severity :: Error , } } +impl SplinterRule for SecurityDefinerView { + fn sql_file_path() -> &'static str { + "security/security_definer_view.sql" + } +} diff --git a/crates/pgls_splinter/src/rules/security/unsupported_reg_types.rs b/crates/pgls_splinter/src/rules/security/unsupported_reg_types.rs new file mode 100644 index 000000000..79453c31f --- /dev/null +++ b/crates/pgls_splinter/src/rules/security/unsupported_reg_types.rs @@ -0,0 +1,11 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use crate::rule::SplinterRule; +use pgls_analyse::RuleMeta; +::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub UnsupportedRegTypes { version : "1.0.0" , name : "unsupportedRegTypes" , severity : pgls_diagnostics :: Severity :: Warning , } } +impl SplinterRule for UnsupportedRegTypes { + fn sql_file_path() -> &'static str { + "security/unsupported_reg_types.sql" + } +} diff --git a/crates/pgls_splinter/vendor/performance/auth_rls_initplan.sql b/crates/pgls_splinter/vendor/performance/auth_rls_initplan.sql new file mode 100644 index 000000000..dd2b7edd5 --- /dev/null +++ b/crates/pgls_splinter/vendor/performance/auth_rls_initplan.sql @@ -0,0 +1,107 @@ +-- meta: name = authRlsInitplan +-- meta: title = Auth RLS Initialization Plan +-- meta: severity = WARN +-- meta: category = PERFORMANCE +-- meta: description = Detects if calls to \`current_setting()\` and \`auth.()\` in RLS policies are being unnecessarily re-evaluated for each row +-- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan + +( +with policies as ( + select + nsp.nspname as schema_name, + pb.tablename as table_name, + pc.relrowsecurity as is_rls_active, + polname as policy_name, + polpermissive as is_permissive, -- if not, then restrictive + (select array_agg(r::regrole) from unnest(polroles) as x(r)) as roles, + case polcmd + when 'r' then 'SELECT' + when 'a' then 'INSERT' + when 'w' then 'UPDATE' + when 'd' then 'DELETE' + when '*' then 'ALL' + end as command, + qual, + with_check + from + pg_catalog.pg_policy pa + join pg_catalog.pg_class pc + on pa.polrelid = pc.oid + join pg_catalog.pg_namespace nsp + on pc.relnamespace = nsp.oid + join pg_catalog.pg_policies pb + on pc.relname = pb.tablename + and nsp.nspname = pb.schemaname + and pa.polname = pb.policyname +) +select + 'auth_rls_initplan' as "name!", + 'Auth RLS Initialization Plan' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['PERFORMANCE'] as "categories!", + 'Detects if calls to \`current_setting()\` and \`auth.()\` in RLS policies are being unnecessarily re-evaluated for each row' as "description!", + format( + 'Table \`%s.%s\` has a row level security policy \`%s\` that re-evaluates current_setting() or auth.() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing \`auth.()\` with \`(select auth.())\`. See [docs](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) for more info.', + schema_name, + table_name, + policy_name + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan' as "remediation!", + jsonb_build_object( + 'schema', schema_name, + 'name', table_name, + 'type', 'table' + ) as "metadata!", + format('auth_rls_init_plan_%s_%s_%s', schema_name, table_name, policy_name) as "cache_key!" +from + policies +where + is_rls_active + -- NOTE: does not include realtime in support of monitoring policies on realtime.messages + and schema_name not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + and ( + -- Example: auth.uid() + ( + qual like '%auth.uid()%' + and lower(qual) not like '%select auth.uid()%' + ) + or ( + qual like '%auth.jwt()%' + and lower(qual) not like '%select auth.jwt()%' + ) + or ( + qual like '%auth.role()%' + and lower(qual) not like '%select auth.role()%' + ) + or ( + qual like '%auth.email()%' + and lower(qual) not like '%select auth.email()%' + ) + or ( + qual like '%current\_setting(%)%' + and lower(qual) not like '%select current\_setting(%)%' + ) + or ( + with_check like '%auth.uid()%' + and lower(with_check) not like '%select auth.uid()%' + ) + or ( + with_check like '%auth.jwt()%' + and lower(with_check) not like '%select auth.jwt()%' + ) + or ( + with_check like '%auth.role()%' + and lower(with_check) not like '%select auth.role()%' + ) + or ( + with_check like '%auth.email()%' + and lower(with_check) not like '%select auth.email()%' + ) + or ( + with_check like '%current\_setting(%)%' + and lower(with_check) not like '%select current\_setting(%)%' + ) + )) diff --git a/crates/pgls_splinter/vendor/performance/duplicate_index.sql b/crates/pgls_splinter/vendor/performance/duplicate_index.sql new file mode 100644 index 000000000..aa469dbf1 --- /dev/null +++ b/crates/pgls_splinter/vendor/performance/duplicate_index.sql @@ -0,0 +1,61 @@ +-- meta: name = duplicateIndex +-- meta: title = Duplicate Index +-- meta: severity = WARN +-- meta: category = PERFORMANCE +-- meta: description = Detects cases where two ore more identical indexes exist. +-- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0009_duplicate_index + +( +select + 'duplicate_index' as "name!", + 'Duplicate Index' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['PERFORMANCE'] as "categories!", + 'Detects cases where two ore more identical indexes exist.' as "description!", + format( + 'Table \`%s.%s\` has identical indexes %s. Drop all except one of them', + n.nspname, + c.relname, + array_agg(pi.indexname order by pi.indexname) + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0009_duplicate_index' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', case + when c.relkind = 'r' then 'table' + when c.relkind = 'm' then 'materialized view' + else 'ERROR' + end, + 'indexes', array_agg(pi.indexname order by pi.indexname) + ) as "metadata!", + format( + 'duplicate_index_%s_%s_%s', + n.nspname, + c.relname, + array_agg(pi.indexname order by pi.indexname) + ) as "cache_key!" +from + pg_catalog.pg_indexes pi + join pg_catalog.pg_namespace n + on n.nspname = pi.schemaname + join pg_catalog.pg_class c + on pi.tablename = c.relname + and n.oid = c.relnamespace + left join pg_catalog.pg_depend dep + on c.oid = dep.objid + and dep.deptype = 'e' +where + c.relkind in ('r', 'm') -- tables and materialized views + and n.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + and dep.objid is null -- exclude tables owned by extensions +group by + n.nspname, + c.relkind, + c.relname, + replace(pi.indexdef, pi.indexname, '') +having + count(*) > 1) diff --git a/crates/pgls_splinter/vendor/performance/multiple_permissive_policies.sql b/crates/pgls_splinter/vendor/performance/multiple_permissive_policies.sql new file mode 100644 index 000000000..ed933a136 --- /dev/null +++ b/crates/pgls_splinter/vendor/performance/multiple_permissive_policies.sql @@ -0,0 +1,79 @@ +-- meta: name = multiplePermissivePolicies +-- meta: title = Multiple Permissive Policies +-- meta: severity = WARN +-- meta: category = PERFORMANCE +-- meta: description = Detects if multiple permissive row level security policies are present on a table for the same \`role\` and \`action\` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query. +-- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies + +( +select + 'multiple_permissive_policies' as "name!", + 'Multiple Permissive Policies' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['PERFORMANCE'] as "categories!", + 'Detects if multiple permissive row level security policies are present on a table for the same \`role\` and \`action\` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.' as "description!", + format( + 'Table \`%s.%s\` has multiple permissive policies for role \`%s\` for action \`%s\`. Policies include \`%s\`', + n.nspname, + c.relname, + r.rolname, + act.cmd, + array_agg(p.polname order by p.polname) + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'table' + ) as "metadata!", + format( + 'multiple_permissive_policies_%s_%s_%s_%s', + n.nspname, + c.relname, + r.rolname, + act.cmd + ) as "cache_key!" +from + pg_catalog.pg_policy p + join pg_catalog.pg_class c + on p.polrelid = c.oid + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid + join pg_catalog.pg_roles r + on p.polroles @> array[r.oid] + or p.polroles = array[0::oid] + left join pg_catalog.pg_depend dep + on c.oid = dep.objid + and dep.deptype = 'e', + lateral ( + select x.cmd + from unnest(( + select + case p.polcmd + when 'r' then array['SELECT'] + when 'a' then array['INSERT'] + when 'w' then array['UPDATE'] + when 'd' then array['DELETE'] + when '*' then array['SELECT', 'INSERT', 'UPDATE', 'DELETE'] + else array['ERROR'] + end as actions + )) x(cmd) + ) act(cmd) +where + c.relkind = 'r' -- regular tables + and p.polpermissive -- policy is permissive + and n.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + and r.rolname not like 'pg_%' + and r.rolname not like 'supabase%admin' + and not r.rolbypassrls + and dep.objid is null -- exclude tables owned by extensions +group by + n.nspname, + c.relname, + r.rolname, + act.cmd +having + count(1) > 1) diff --git a/crates/pgls_splinter/vendor/performance/no_primary_key.sql b/crates/pgls_splinter/vendor/performance/no_primary_key.sql new file mode 100644 index 000000000..4b35000ca --- /dev/null +++ b/crates/pgls_splinter/vendor/performance/no_primary_key.sql @@ -0,0 +1,52 @@ +-- meta: name = noPrimaryKey +-- meta: title = No Primary Key +-- meta: severity = INFO +-- meta: category = PERFORMANCE +-- meta: description = Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale. +-- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0004_no_primary_key + +( +select + 'no_primary_key' as "name!", + 'No Primary Key' as "title!", + 'INFO' as "level!", + 'EXTERNAL' as "facing!", + array['PERFORMANCE'] as "categories!", + 'Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale.' as "description!", + format( + 'Table \`%s.%s\` does not have a primary key', + pgns.nspname, + pgc.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0004_no_primary_key' as "remediation!", + jsonb_build_object( + 'schema', pgns.nspname, + 'name', pgc.relname, + 'type', 'table' + ) as "metadata!", + format( + 'no_primary_key_%s_%s', + pgns.nspname, + pgc.relname + ) as "cache_key!" +from + pg_catalog.pg_class pgc + join pg_catalog.pg_namespace pgns + on pgns.oid = pgc.relnamespace + left join pg_catalog.pg_index pgi + on pgi.indrelid = pgc.oid + left join pg_catalog.pg_depend dep + on pgc.oid = dep.objid + and dep.deptype = 'e' +where + pgc.relkind = 'r' -- regular tables + and pgns.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + and dep.objid is null -- exclude tables owned by extensions +group by + pgc.oid, + pgns.nspname, + pgc.relname +having + max(coalesce(pgi.indisprimary, false)::int) = 0) diff --git a/crates/pgls_splinter/vendor/performance/table_bloat.sql b/crates/pgls_splinter/vendor/performance/table_bloat.sql new file mode 100644 index 000000000..465e2047a --- /dev/null +++ b/crates/pgls_splinter/vendor/performance/table_bloat.sql @@ -0,0 +1,105 @@ +-- meta: name = tableBloat +-- meta: title = Table Bloat +-- meta: severity = INFO +-- meta: category = PERFORMANCE +-- meta: description = Detects if a table has excess bloat and may benefit from maintenance operations like vacuum full or cluster. +-- meta: remediation = Consider running vacuum full (WARNING: incurs downtime) and tweaking autovacuum settings to reduce bloat. + +( +with constants as ( + select current_setting('block_size')::numeric as bs, 23 as hdr, 4 as ma +), + +bloat_info as ( + select + ma, + bs, + schemaname, + tablename, + (datawidth + (hdr + ma - (case when hdr % ma = 0 then ma else hdr % ma end)))::numeric as datahdr, + (maxfracsum * (nullhdr + ma - (case when nullhdr % ma = 0 then ma else nullhdr % ma end))) as nullhdr2 + from ( + select + schemaname, + tablename, + hdr, + ma, + bs, + sum((1 - null_frac) * avg_width) as datawidth, + max(null_frac) as maxfracsum, + hdr + ( + select 1 + count(*) / 8 + from pg_stats s2 + where + null_frac <> 0 + and s2.schemaname = s.schemaname + and s2.tablename = s.tablename + ) as nullhdr + from pg_stats s, constants + group by 1, 2, 3, 4, 5 + ) as foo +), + +table_bloat as ( + select + schemaname, + tablename, + cc.relpages, + bs, + ceil((cc.reltuples * ((datahdr + ma - + (case when datahdr % ma = 0 then ma else datahdr % ma end)) + nullhdr2 + 4)) / (bs - 20::float)) as otta + from + bloat_info + join pg_class cc + on cc.relname = bloat_info.tablename + join pg_namespace nn + on cc.relnamespace = nn.oid + and nn.nspname = bloat_info.schemaname + and nn.nspname <> 'information_schema' + where + cc.relkind = 'r' + and cc.relam = (select oid from pg_am where amname = 'heap') +), + +bloat_data as ( + select + 'table' as type, + schemaname, + tablename as object_name, + round(case when otta = 0 then 0.0 else table_bloat.relpages / otta::numeric end, 1) as bloat, + case when relpages < otta then 0 else (bs * (table_bloat.relpages - otta)::bigint)::bigint end as raw_waste + from + table_bloat +) + +select + 'table_bloat' as "name!", + 'Table Bloat' as "title!", + 'INFO' as "level!", + 'EXTERNAL' as "facing!", + array['PERFORMANCE'] as "categories!", + 'Detects if a table has excess bloat and may benefit from maintenance operations like vacuum full or cluster.' as "description!", + format( + 'Table `%s`.`%s` has excessive bloat', + bloat_data.schemaname, + bloat_data.object_name + ) as "detail!", + 'Consider running vacuum full (WARNING: incurs downtime) and tweaking autovacuum settings to reduce bloat.' as "remediation!", + jsonb_build_object( + 'schema', bloat_data.schemaname, + 'name', bloat_data.object_name, + 'type', bloat_data.type + ) as "metadata!", + format( + 'table_bloat_%s_%s', + bloat_data.schemaname, + bloat_data.object_name + ) as "cache_key!" +from + bloat_data +where + bloat > 70.0 + and raw_waste > (20 * 1024 * 1024) -- filter for waste > 200 MB +order by + schemaname, + object_name) diff --git a/crates/pgls_splinter/vendor/performance/unindexed_foreign_keys.sql b/crates/pgls_splinter/vendor/performance/unindexed_foreign_keys.sql new file mode 100644 index 000000000..e98a80dbb --- /dev/null +++ b/crates/pgls_splinter/vendor/performance/unindexed_foreign_keys.sql @@ -0,0 +1,78 @@ +-- meta: name = unindexedForeignKeys +-- meta: title = Unindexed foreign keys +-- meta: severity = INFO +-- meta: category = PERFORMANCE +-- meta: description = Identifies foreign key constraints without a covering index, which can impact database performance. +-- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys + +with foreign_keys as ( + select + cl.relnamespace::regnamespace::text as schema_name, + cl.relname as table_name, + cl.oid as table_oid, + ct.conname as fkey_name, + ct.conkey as col_attnums + from + pg_catalog.pg_constraint ct + join pg_catalog.pg_class cl -- fkey owning table + on ct.conrelid = cl.oid + left join pg_catalog.pg_depend d + on d.objid = cl.oid + and d.deptype = 'e' + where + ct.contype = 'f' -- foreign key constraints + and d.objid is null -- exclude tables that are dependencies of extensions + and cl.relnamespace::regnamespace::text not in ( + 'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'extensions' + ) +), +index_ as ( + select + pi.indrelid as table_oid, + indexrelid::regclass as index_, + string_to_array(indkey::text, ' ')::smallint[] as col_attnums + from + pg_catalog.pg_index pi + where + indisvalid +) +select + 'unindexed_foreign_keys' as "name!", + 'Unindexed foreign keys' as "title!", + 'INFO' as "level!", + 'EXTERNAL' as "facing!", + array['PERFORMANCE'] as "categories!", + 'Identifies foreign key constraints without a covering index, which can impact database performance.' as "description!", + format( + 'Table `%s.%s` has a foreign key `%s` without a covering index. This can lead to suboptimal query performance.', + fk.schema_name, + fk.table_name, + fk.fkey_name + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys' as "remediation!", + jsonb_build_object( + 'schema', fk.schema_name, + 'name', fk.table_name, + 'type', 'table', + 'fkey_name', fk.fkey_name, + 'fkey_columns', fk.col_attnums + ) as "metadata!", + format('unindexed_foreign_keys_%s_%s_%s', fk.schema_name, fk.table_name, fk.fkey_name) as "cache_key!" +from + foreign_keys fk + left join index_ idx + on fk.table_oid = idx.table_oid + and fk.col_attnums = idx.col_attnums[1:array_length(fk.col_attnums, 1)] + left join pg_catalog.pg_depend dep + on idx.table_oid = dep.objid + and dep.deptype = 'e' +where + idx.index_ is null + and fk.schema_name not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + and dep.objid is null -- exclude tables owned by extensions +order by + fk.schema_name, + fk.table_name, + fk.fkey_name diff --git a/crates/pgls_splinter/vendor/performance/unused_index.sql b/crates/pgls_splinter/vendor/performance/unused_index.sql new file mode 100644 index 000000000..15fa0a82f --- /dev/null +++ b/crates/pgls_splinter/vendor/performance/unused_index.sql @@ -0,0 +1,49 @@ +-- meta: name = unusedIndex +-- meta: title = Unused Index +-- meta: severity = INFO +-- meta: category = PERFORMANCE +-- meta: description = Detects if an index has never been used and may be a candidate for removal. +-- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0005_unused_index + +( +select + 'unused_index' as "name!", + 'Unused Index' as "title!", + 'INFO' as "level!", + 'EXTERNAL' as "facing!", + array['PERFORMANCE'] as "categories!", + 'Detects if an index has never been used and may be a candidate for removal.' as "description!", + format( + 'Index \`%s\` on table \`%s.%s\` has not been used', + psui.indexrelname, + psui.schemaname, + psui.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0005_unused_index' as "remediation!", + jsonb_build_object( + 'schema', psui.schemaname, + 'name', psui.relname, + 'type', 'table' + ) as "metadata!", + format( + 'unused_index_%s_%s_%s', + psui.schemaname, + psui.relname, + psui.indexrelname + ) as "cache_key!" + +from + pg_catalog.pg_stat_user_indexes psui + join pg_catalog.pg_index pi + on psui.indexrelid = pi.indexrelid + left join pg_catalog.pg_depend dep + on psui.relid = dep.objid + and dep.deptype = 'e' +where + psui.idx_scan = 0 + and not pi.indisunique + and not pi.indisprimary + and dep.objid is null -- exclude tables owned by extensions + and psui.schemaname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + )) diff --git a/crates/pgls_splinter/vendor/security/auth_users_exposed.sql b/crates/pgls_splinter/vendor/security/auth_users_exposed.sql new file mode 100644 index 000000000..e73592b8c --- /dev/null +++ b/crates/pgls_splinter/vendor/security/auth_users_exposed.sql @@ -0,0 +1,95 @@ +-- meta: name = authUsersExposed +-- meta: title = Exposed Auth Users +-- meta: severity = ERROR +-- meta: category = SECURITY +-- meta: description = Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security. +-- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed + +( +select + 'auth_users_exposed' as "name!", + 'Exposed Auth Users' as "title!", + 'ERROR' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security.' as "description!", + format( + 'View/Materialized View "%s" in the public schema may expose \`auth.users\` data to anon or authenticated roles.', + c.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'view', + 'exposed_to', array_remove(array_agg(DISTINCT case when pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') then 'anon' when pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') then 'authenticated' end), null) + ) as "metadata!", + format('auth_users_exposed_%s_%s', n.nspname, c.relname) as "cache_key!" +from + -- Identify the oid for auth.users + pg_catalog.pg_class auth_users_pg_class + join pg_catalog.pg_namespace auth_users_pg_namespace + on auth_users_pg_class.relnamespace = auth_users_pg_namespace.oid + and auth_users_pg_class.relname = 'users' + and auth_users_pg_namespace.nspname = 'auth' + -- Depends on auth.users + join pg_catalog.pg_depend d + on d.refobjid = auth_users_pg_class.oid + join pg_catalog.pg_rewrite r + on r.oid = d.objid + join pg_catalog.pg_class c + on c.oid = r.ev_class + join pg_catalog.pg_namespace n + on n.oid = c.relnamespace + join pg_catalog.pg_class pg_class_auth_users + on d.refobjid = pg_class_auth_users.oid +where + d.deptype = 'n' + and ( + pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') + or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') + ) + and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + -- Exclude self + and c.relname <> '0002_auth_users_exposed' + -- There are 3 insecure configurations + and + ( + -- Materialized views don't support RLS so this is insecure by default + (c.relkind in ('m')) -- m for materialized view + or + -- Standard View, accessible to anon or authenticated that is security_definer + ( + c.relkind = 'v' -- v for view + -- Exclude security invoker views + and not ( + lower(coalesce(c.reloptions::text,'{}'))::text[] + && array[ + 'security_invoker=1', + 'security_invoker=true', + 'security_invoker=yes', + 'security_invoker=on' + ] + ) + ) + or + -- Standard View, security invoker, but no RLS enabled on auth.users + ( + c.relkind in ('v') -- v for view + -- is security invoker + and ( + lower(coalesce(c.reloptions::text,'{}'))::text[] + && array[ + 'security_invoker=1', + 'security_invoker=true', + 'security_invoker=yes', + 'security_invoker=on' + ] + ) + and not pg_class_auth_users.relrowsecurity + ) + ) +group by + n.nspname, + c.relname, + c.oid) diff --git a/crates/pgls_splinter/vendor/security/extension_in_public.sql b/crates/pgls_splinter/vendor/security/extension_in_public.sql new file mode 100644 index 000000000..ce2b83101 --- /dev/null +++ b/crates/pgls_splinter/vendor/security/extension_in_public.sql @@ -0,0 +1,40 @@ +-- meta: name = extensionInPublic +-- meta: title = Extension in Public +-- meta: severity = WARN +-- meta: category = SECURITY +-- meta: description = Detects extensions installed in the \`public\` schema. +-- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0014_extension_in_public + +( +select + 'extension_in_public' as "name!", + 'Extension in Public' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects extensions installed in the \`public\` schema.' as "description!", + format( + 'Extension \`%s\` is installed in the public schema. Move it to another schema.', + pe.extname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0014_extension_in_public' as "remediation!", + jsonb_build_object( + 'schema', pe.extnamespace::regnamespace, + 'name', pe.extname, + 'type', 'extension' + ) as "metadata!", + format( + 'extension_in_public_%s', + pe.extname + ) as "cache_key!" +from + pg_catalog.pg_extension pe +where + -- plpgsql is installed by default in public and outside user control + -- confirmed safe + pe.extname not in ('plpgsql') + -- Scoping this to public is not optimal. Ideally we would use the postgres + -- search path. That currently isn't available via SQL. In other lints + -- we have used has_schema_privilege('anon', 'extensions', 'USAGE') but that + -- is not appropriate here as it would evaluate true for the extensions schema + and pe.extnamespace::regnamespace::text = 'public') diff --git a/crates/pgls_splinter/vendor/security/extension_versions_outdated.sql b/crates/pgls_splinter/vendor/security/extension_versions_outdated.sql new file mode 100644 index 000000000..ad83574d2 --- /dev/null +++ b/crates/pgls_splinter/vendor/security/extension_versions_outdated.sql @@ -0,0 +1,45 @@ +-- meta: name = extensionVersionsOutdated +-- meta: title = Extension Versions Outdated +-- meta: severity = WARN +-- meta: category = SECURITY +-- meta: description = Detects extensions that are not using the default (recommended) version. +-- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0022_extension_versions_outdated + +( +select + 'extension_versions_outdated' as "name!", + 'Extension Versions Outdated' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects extensions that are not using the default (recommended) version.' as "description!", + format( + 'Extension `%s` is using version `%s` but version `%s` is available. Using outdated extension versions may expose the database to security vulnerabilities.', + ext.name, + ext.installed_version, + ext.default_version + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0022_extension_versions_outdated' as "remediation!", + jsonb_build_object( + 'extension_name', ext.name, + 'installed_version', ext.installed_version, + 'default_version', ext.default_version + ) as "metadata!", + format( + 'extension_versions_outdated_%s_%s', + ext.name, + ext.installed_version + ) as "cache_key!" +from + pg_catalog.pg_available_extensions ext +join + -- ignore versions not in pg_available_extension_versions + -- e.g. residue of pg_upgrade + pg_catalog.pg_available_extension_versions extv + on extv.name = ext.name and extv.installed +where + ext.installed_version is not null + and ext.default_version is not null + and ext.installed_version != ext.default_version +order by + ext.name) diff --git a/crates/pgls_splinter/vendor/security/fkey_to_auth_unique.sql b/crates/pgls_splinter/vendor/security/fkey_to_auth_unique.sql new file mode 100644 index 000000000..183f9ca9a --- /dev/null +++ b/crates/pgls_splinter/vendor/security/fkey_to_auth_unique.sql @@ -0,0 +1,49 @@ +-- meta: name = fkeyToAuthUnique +-- meta: title = Foreign Key to Auth Unique Constraint +-- meta: severity = ERROR +-- meta: category = SECURITY +-- meta: description = Detects user defined foreign keys to unique constraints in the auth schema. +-- meta: remediation = Drop the foreign key constraint that references the auth schema. + +( +select + 'fkey_to_auth_unique' as "name!", + 'Foreign Key to Auth Unique Constraint' as "title!", + 'ERROR' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects user defined foreign keys to unique constraints in the auth schema.' as "description!", + format( + 'Table `%s`.`%s` has a foreign key `%s` referencing an auth unique constraint', + n.nspname, -- referencing schema + c_rel.relname, -- referencing table + c.conname -- fkey name + ) as "detail!", + 'Drop the foreign key constraint that references the auth schema.' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c_rel.relname, + 'foreign_key', c.conname + ) as "metadata!", + format( + 'fkey_to_auth_unique_%s_%s_%s', + n.nspname, -- referencing schema + c_rel.relname, -- referencing table + c.conname + ) as "cache_key!" +from + pg_catalog.pg_constraint c + join pg_catalog.pg_class c_rel + on c.conrelid = c_rel.oid + join pg_catalog.pg_namespace n + on c_rel.relnamespace = n.oid + join pg_catalog.pg_class ref_rel + on c.confrelid = ref_rel.oid + join pg_catalog.pg_namespace cn + on ref_rel.relnamespace = cn.oid + join pg_catalog.pg_index i + on c.conindid = i.indexrelid +where c.contype = 'f' + and cn.nspname = 'auth' + and i.indisunique + and not i.indisprimary) diff --git a/crates/pgls_splinter/vendor/security/foreign_table_in_api.sql b/crates/pgls_splinter/vendor/security/foreign_table_in_api.sql new file mode 100644 index 000000000..9e205606e --- /dev/null +++ b/crates/pgls_splinter/vendor/security/foreign_table_in_api.sql @@ -0,0 +1,49 @@ +-- meta: name = foreignTableInApi +-- meta: title = Foreign Table in API +-- meta: severity = WARN +-- meta: category = SECURITY +-- meta: description = Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies. +-- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api + +( +select + 'foreign_table_in_api' as "name!", + 'Foreign Table in API' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies.' as "description!", + format( + 'Foreign table \`%s.%s\` is accessible over APIs', + n.nspname, + c.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'foreign table' + ) as "metadata!", + format( + 'foreign_table_in_api_%s_%s', + n.nspname, + c.relname + ) as "cache_key!" +from + pg_catalog.pg_class c + join pg_catalog.pg_namespace n + on n.oid = c.relnamespace + left join pg_catalog.pg_depend dep + on c.oid = dep.objid + and dep.deptype = 'e' +where + c.relkind = 'f' + and ( + pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') + or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') + ) + and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + and dep.objid is null) diff --git a/crates/pgls_splinter/vendor/security/function_search_path_mutable.sql b/crates/pgls_splinter/vendor/security/function_search_path_mutable.sql new file mode 100644 index 000000000..efe47ffc7 --- /dev/null +++ b/crates/pgls_splinter/vendor/security/function_search_path_mutable.sql @@ -0,0 +1,50 @@ +-- meta: name = functionSearchPathMutable +-- meta: title = Function Search Path Mutable +-- meta: severity = WARN +-- meta: category = SECURITY +-- meta: description = Detects functions where the search_path parameter is not set. +-- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0011_function_search_path_mutable + +( +select + 'function_search_path_mutable' as "name!", + 'Function Search Path Mutable' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects functions where the search_path parameter is not set.' as "description!", + format( + 'Function \`%s.%s\` has a role mutable search_path', + n.nspname, + p.proname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0011_function_search_path_mutable' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', p.proname, + 'type', 'function' + ) as "metadata!", + format( + 'function_search_path_mutable_%s_%s_%s', + n.nspname, + p.proname, + md5(p.prosrc) -- required when function is polymorphic + ) as "cache_key!" +from + pg_catalog.pg_proc p + join pg_catalog.pg_namespace n + on p.pronamespace = n.oid + left join pg_catalog.pg_depend dep + on p.oid = dep.objid + and dep.deptype = 'e' +where + n.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + and dep.objid is null -- exclude functions owned by extensions + -- Search path not set + and not exists ( + select 1 + from unnest(coalesce(p.proconfig, '{}')) as config + where config like 'search_path=%' + )) diff --git a/crates/pgls_splinter/vendor/security/insecure_queue_exposed_in_api.sql b/crates/pgls_splinter/vendor/security/insecure_queue_exposed_in_api.sql new file mode 100644 index 000000000..2a35f5e42 --- /dev/null +++ b/crates/pgls_splinter/vendor/security/insecure_queue_exposed_in_api.sql @@ -0,0 +1,46 @@ +-- meta: name = insecureQueueExposedInApi +-- meta: title = Insecure Queue Exposed in API +-- meta: severity = ERROR +-- meta: category = SECURITY +-- meta: description = Detects cases where an insecure Queue is exposed over Data APIs +-- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0019_insecure_queue_exposed_in_api + +( +select + 'insecure_queue_exposed_in_api' as "name!", + 'Insecure Queue Exposed in API' as "title!", + 'ERROR' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects cases where an insecure Queue is exposed over Data APIs' as "description!", + format( + 'Table \`%s.%s\` is public, but RLS has not been enabled.', + n.nspname, + c.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0019_insecure_queue_exposed_in_api' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'table' + ) as "metadata!", + format( + 'rls_disabled_in_public_%s_%s', + n.nspname, + c.relname + ) as "cache_key!" +from + pg_catalog.pg_class c + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid +where + c.relkind in ('r', 'I') -- regular or partitioned tables + and not c.relrowsecurity -- RLS is disabled + and ( + pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') + or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') + ) + and n.nspname = 'pgmq' -- tables in the pgmq schema + and c.relname like 'q_%' -- only queue tables + -- Constant requirements + and 'pgmq_public' = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))) diff --git a/crates/pgls_splinter/vendor/security/materialized_view_in_api.sql b/crates/pgls_splinter/vendor/security/materialized_view_in_api.sql new file mode 100644 index 000000000..9c1546614 --- /dev/null +++ b/crates/pgls_splinter/vendor/security/materialized_view_in_api.sql @@ -0,0 +1,49 @@ +-- meta: name = materializedViewInApi +-- meta: title = Materialized View in API +-- meta: severity = WARN +-- meta: category = SECURITY +-- meta: description = Detects materialized views that are accessible over the Data APIs. +-- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api + +( +select + 'materialized_view_in_api' as "name!", + 'Materialized View in API' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects materialized views that are accessible over the Data APIs.' as "description!", + format( + 'Materialized view \`%s.%s\` is selectable by anon or authenticated roles', + n.nspname, + c.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'materialized view' + ) as "metadata!", + format( + 'materialized_view_in_api_%s_%s', + n.nspname, + c.relname + ) as "cache_key!" +from + pg_catalog.pg_class c + join pg_catalog.pg_namespace n + on n.oid = c.relnamespace + left join pg_catalog.pg_depend dep + on c.oid = dep.objid + and dep.deptype = 'e' +where + c.relkind = 'm' + and ( + pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') + or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') + ) + and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + and dep.objid is null) diff --git a/crates/pgls_splinter/vendor/security/policy_exists_rls_disabled.sql b/crates/pgls_splinter/vendor/security/policy_exists_rls_disabled.sql new file mode 100644 index 000000000..9a1ae1256 --- /dev/null +++ b/crates/pgls_splinter/vendor/security/policy_exists_rls_disabled.sql @@ -0,0 +1,52 @@ +-- meta: name = policyExistsRlsDisabled +-- meta: title = Policy Exists RLS Disabled +-- meta: severity = ERROR +-- meta: category = SECURITY +-- meta: description = Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table. +-- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0007_policy_exists_rls_disabled + +( +select + 'policy_exists_rls_disabled' as "name!", + 'Policy Exists RLS Disabled' as "title!", + 'ERROR' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table.' as "description!", + format( + 'Table \`%s.%s\` has RLS policies but RLS is not enabled on the table. Policies include %s.', + n.nspname, + c.relname, + array_agg(p.polname order by p.polname) + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0007_policy_exists_rls_disabled' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'table' + ) as "metadata!", + format( + 'policy_exists_rls_disabled_%s_%s', + n.nspname, + c.relname + ) as "cache_key!" +from + pg_catalog.pg_policy p + join pg_catalog.pg_class c + on p.polrelid = c.oid + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid + left join pg_catalog.pg_depend dep + on c.oid = dep.objid + and dep.deptype = 'e' +where + c.relkind = 'r' -- regular tables + and n.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + -- RLS is disabled + and not c.relrowsecurity + and dep.objid is null -- exclude tables owned by extensions +group by + n.nspname, + c.relname) diff --git a/crates/pgls_splinter/vendor/security/rls_disabled_in_public.sql b/crates/pgls_splinter/vendor/security/rls_disabled_in_public.sql new file mode 100644 index 000000000..ae9fc3162 --- /dev/null +++ b/crates/pgls_splinter/vendor/security/rls_disabled_in_public.sql @@ -0,0 +1,47 @@ +-- meta: name = rlsDisabledInPublic +-- meta: title = RLS Disabled in Public +-- meta: severity = ERROR +-- meta: category = SECURITY +-- meta: description = Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST +-- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public + +( +select + 'rls_disabled_in_public' as "name!", + 'RLS Disabled in Public' as "title!", + 'ERROR' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST' as "description!", + format( + 'Table \`%s.%s\` is public, but RLS has not been enabled.', + n.nspname, + c.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'table' + ) as "metadata!", + format( + 'rls_disabled_in_public_%s_%s', + n.nspname, + c.relname + ) as "cache_key!" +from + pg_catalog.pg_class c + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid +where + c.relkind = 'r' -- regular tables + -- RLS is disabled + and not c.relrowsecurity + and ( + pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') + or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') + ) + and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + )) diff --git a/crates/pgls_splinter/vendor/security/rls_enabled_no_policy.sql b/crates/pgls_splinter/vendor/security/rls_enabled_no_policy.sql new file mode 100644 index 000000000..d1d34c0a1 --- /dev/null +++ b/crates/pgls_splinter/vendor/security/rls_enabled_no_policy.sql @@ -0,0 +1,52 @@ +-- meta: name = rlsEnabledNoPolicy +-- meta: title = RLS Enabled No Policy +-- meta: severity = INFO +-- meta: category = SECURITY +-- meta: description = Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created. +-- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0008_rls_enabled_no_policy + +( +select + 'rls_enabled_no_policy' as "name!", + 'RLS Enabled No Policy' as "title!", + 'INFO' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created.' as "description!", + format( + 'Table \`%s.%s\` has RLS enabled, but no policies exist', + n.nspname, + c.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0008_rls_enabled_no_policy' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'table' + ) as "metadata!", + format( + 'rls_enabled_no_policy_%s_%s', + n.nspname, + c.relname + ) as "cache_key!" +from + pg_catalog.pg_class c + left join pg_catalog.pg_policy p + on p.polrelid = c.oid + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid + left join pg_catalog.pg_depend dep + on c.oid = dep.objid + and dep.deptype = 'e' +where + c.relkind = 'r' -- regular tables + and n.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + -- RLS is enabled + and c.relrowsecurity + and p.polname is null + and dep.objid is null -- exclude tables owned by extensions +group by + n.nspname, + c.relname) diff --git a/crates/pgls_splinter/vendor/security/rls_references_user_metadata.sql b/crates/pgls_splinter/vendor/security/rls_references_user_metadata.sql new file mode 100644 index 000000000..5dc91ca6f --- /dev/null +++ b/crates/pgls_splinter/vendor/security/rls_references_user_metadata.sql @@ -0,0 +1,61 @@ +-- meta: name = rlsReferencesUserMetadata +-- meta: title = RLS references user metadata +-- meta: severity = ERROR +-- meta: category = SECURITY +-- meta: description = Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy. +-- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata + +( +with policies as ( + select + nsp.nspname as schema_name, + pb.tablename as table_name, + polname as policy_name, + qual, + with_check + from + pg_catalog.pg_policy pa + join pg_catalog.pg_class pc + on pa.polrelid = pc.oid + join pg_catalog.pg_namespace nsp + on pc.relnamespace = nsp.oid + join pg_catalog.pg_policies pb + on pc.relname = pb.tablename + and nsp.nspname = pb.schemaname + and pa.polname = pb.policyname +) +select + 'rls_references_user_metadata' as "name!", + 'RLS references user metadata' as "title!", + 'ERROR' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy.' as "description!", + format( + 'Table \`%s.%s\` has a row level security policy \`%s\` that references Supabase Auth \`user_metadata\`. \`user_metadata\` is editable by end users and should never be used in a security context.', + schema_name, + table_name, + policy_name + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata' as "remediation!", + jsonb_build_object( + 'schema', schema_name, + 'name', table_name, + 'type', 'table' + ) as "metadata!", + format('rls_references_user_metadata_%s_%s_%s', schema_name, table_name, policy_name) as "cache_key!" +from + policies +where + schema_name not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + and ( + -- Example: auth.jwt() -> 'user_metadata' + -- False positives are possible, but it isn't practical to string match + -- If false positive rate is too high, this expression can iterate + qual like '%auth.jwt()%user_metadata%' + or qual like '%current_setting(%request.jwt.claims%)%user_metadata%' + or with_check like '%auth.jwt()%user_metadata%' + or with_check like '%current_setting(%request.jwt.claims%)%user_metadata%' + )) diff --git a/crates/pgls_splinter/vendor/security/security_definer_view.sql b/crates/pgls_splinter/vendor/security/security_definer_view.sql new file mode 100644 index 000000000..102141fc2 --- /dev/null +++ b/crates/pgls_splinter/vendor/security/security_definer_view.sql @@ -0,0 +1,59 @@ +-- meta: name = securityDefinerView +-- meta: title = Security Definer View +-- meta: severity = ERROR +-- meta: category = SECURITY +-- meta: description = Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user +-- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view + +( +select + 'security_definer_view' as "name!", + 'Security Definer View' as "title!", + 'ERROR' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user' as "description!", + format( + 'View \`%s.%s\` is defined with the SECURITY DEFINER property', + n.nspname, + c.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'view' + ) as "metadata!", + format( + 'security_definer_view_%s_%s', + n.nspname, + c.relname + ) as "cache_key!" +from + pg_catalog.pg_class c + join pg_catalog.pg_namespace n + on n.oid = c.relnamespace + left join pg_catalog.pg_depend dep + on c.oid = dep.objid + and dep.deptype = 'e' +where + c.relkind = 'v' + and ( + pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') + or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') + ) + and substring(pg_catalog.version() from 'PostgreSQL ([0-9]+)') >= '15' -- security invoker was added in pg15 + and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + and dep.objid is null -- exclude views owned by extensions + and not ( + lower(coalesce(c.reloptions::text,'{}'))::text[] + && array[ + 'security_invoker=1', + 'security_invoker=true', + 'security_invoker=yes', + 'security_invoker=on' + ] + )) diff --git a/crates/pgls_splinter/vendor/security/unsupported_reg_types.sql b/crates/pgls_splinter/vendor/security/unsupported_reg_types.sql new file mode 100644 index 000000000..859c86005 --- /dev/null +++ b/crates/pgls_splinter/vendor/security/unsupported_reg_types.sql @@ -0,0 +1,49 @@ +-- meta: name = unsupportedRegTypes +-- meta: title = Unsupported reg types +-- meta: severity = WARN +-- meta: category = SECURITY +-- meta: description = Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade. +-- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=unsupported_reg_types + +( +select + 'unsupported_reg_types' as "name!", + 'Unsupported reg types' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade.' as "description!", + format( + 'Table \`%s.%s\` has a column \`%s\` with unsupported reg* type \`%s\`.', + n.nspname, + c.relname, + a.attname, + t.typname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=unsupported_reg_types' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'column', a.attname, + 'type', 'table' + ) as "metadata!", + format( + 'unsupported_reg_types_%s_%s_%s', + n.nspname, + c.relname, + a.attname + ) AS cache_key +from + pg_catalog.pg_attribute a + join pg_catalog.pg_class c + on a.attrelid = c.oid + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid + join pg_catalog.pg_type t + on a.atttypid = t.oid + join pg_catalog.pg_namespace tn + on t.typnamespace = tn.oid +where + tn.nspname = 'pg_catalog' + and t.typname in ('regcollation', 'regconfig', 'regdictionary', 'regnamespace', 'regoper', 'regoperator', 'regproc', 'regprocedure') + and n.nspname not in ('pg_catalog', 'information_schema', 'pgsodium')) diff --git a/xtask/codegen/Cargo.toml b/xtask/codegen/Cargo.toml index 8b8bbeb9c..c5e95ebe4 100644 --- a/xtask/codegen/Cargo.toml +++ b/xtask/codegen/Cargo.toml @@ -16,6 +16,7 @@ pgls_analyse = { workspace = true } pgls_analyser = { workspace = true } pgls_diagnostics = { workspace = true } pgls_env = { workspace = true } +pgls_splinter = { workspace = true } pgls_workspace = { workspace = true, features = ["schema"] } proc-macro2 = { workspace = true, features = ["span-locations"] } pulldown-cmark = { version = "0.12.2" } diff --git a/xtask/codegen/src/generate_splinter.rs b/xtask/codegen/src/generate_splinter.rs index b44a66aed..d0fba86b4 100644 --- a/xtask/codegen/src/generate_splinter.rs +++ b/xtask/codegen/src/generate_splinter.rs @@ -1,176 +1,426 @@ use anyhow::{Context, Result}; use biome_string_case::Case; +use quote::{format_ident, quote}; use std::collections::BTreeMap; use std::fs; -use xtask::{glue::fs2, project_root}; +use std::path::{Path, PathBuf}; +use xtask::{glue::fs2, project_root, Mode}; + +use crate::update; + +/// Metadata extracted from SQL file comments +#[derive(Debug, Clone)] +struct SqlRuleMetadata { + /// Rule name in camelCase (from meta comment) + name: String, + /// Rule name in snake_case (from filename) + snake_name: String, + /// Human-readable title + title: String, + /// Severity level (INFO, WARN, ERROR) + severity: String, + /// Category (PERFORMANCE, SECURITY, etc.) + category: String, + /// Description of what the rule detects + description: String, + /// Remediation URL or text + remediation: String, + /// Path to SQL file relative to vendor/ + sql_file_path: PathBuf, +} -/// Generate splinter categories from the SQL files (both generic and Supabase-specific) +/// Generate splinter rules, registry, and categories from individual SQL files pub fn generate_splinter() -> Result<()> { + let vendor_dir = project_root().join("crates/pgls_splinter/vendor"); + + // Scan for SQL files in performance/ and security/ directories let mut all_rules = BTreeMap::new(); - // Process generic rules - let generic_sql_path = project_root().join("crates/pgls_splinter/vendor/splinter_generic.sql"); - if generic_sql_path.exists() { - let sql_content = fs::read_to_string(&generic_sql_path) - .with_context(|| format!("Failed to read SQL file at {generic_sql_path:?}"))?; - let rules = extract_rules_from_sql(&sql_content)?; - all_rules.extend(rules); - } + for category in &["performance", "security"] { + let category_dir = vendor_dir.join(category); + if !category_dir.exists() { + continue; + } + + for entry in fs::read_dir(&category_dir)? { + let entry = entry?; + let path = entry.path(); - // Process Supabase-specific rules - let supabase_sql_path = - project_root().join("crates/pgls_splinter/vendor/splinter_supabase.sql"); - if supabase_sql_path.exists() { - let sql_content = fs::read_to_string(&supabase_sql_path) - .with_context(|| format!("Failed to read SQL file at {supabase_sql_path:?}"))?; - let rules = extract_rules_from_sql(&sql_content)?; - all_rules.extend(rules); + if path.extension().and_then(|s| s.to_str()) == Some("sql") { + let metadata = extract_metadata_from_sql(&path, category)?; + all_rules.insert(metadata.snake_name.clone(), metadata); + } + } } - update_categories_file(all_rules)?; + // Generate Rust rule files + generate_rule_trait()?; + generate_rule_files(&all_rules)?; + generate_registry(&all_rules)?; + + // Update categories file (keep existing logic for backward compat) + update_categories_file(&all_rules)?; Ok(()) } -/// Extract rule information from the SQL file -fn extract_rules_from_sql(content: &str) -> Result> { - let mut rules = BTreeMap::new(); +/// Extract metadata from SQL file comment headers +fn extract_metadata_from_sql(sql_path: &Path, category: &str) -> Result { + let content = fs::read_to_string(sql_path) + .with_context(|| format!("Failed to read SQL file: {sql_path:?}"))?; - let lines: Vec<&str> = content.lines().collect(); + let mut name = None; + let mut title = None; + let mut severity = None; + let mut meta_category = None; + let mut description = None; + let mut remediation = None; - for (i, line) in lines.iter().enumerate() { + // Parse metadata comments + for line in content.lines() { let line = line.trim(); + if !line.starts_with("--") { + break; // Stop at first non-comment line + } - // Look for pattern: 'rule_name' as "name!", - if line.contains(" as \"name!\"") { - if let Some(name) = extract_string_literal(line) { - // Look ahead for categories and remediation URL - let mut categories = None; - let mut remediation_url = None; - - for next_line in lines[i..].iter().take(30) { - let next_line = next_line.trim(); - - // Extract categories from pattern: array['CATEGORY'] as "categories!", - if next_line.contains(" as \"categories!\"") { - categories = extract_categories(next_line); - } - - // Extract remediation URL from pattern: 'url' as "remediation!", - if next_line.contains(" as \"remediation!\"") { - remediation_url = extract_string_literal(next_line); - } - - // Stop once we have both - if categories.is_some() && remediation_url.is_some() { - break; - } - } - - let cats = categories - .with_context(|| format!("Failed to find categories for rule '{name}'"))?; - - // Convert old database-linter URLs to database-advisors - let updated_url = remediation_url - .map(|url| url.replace("/database-linter", "/database-advisors")) - .or(Some( - "https://supabase.com/docs/guides/database/database-advisors".to_string(), - )); - - rules.insert( - name.clone(), - RuleInfo { - snake_case: name.clone(), - camel_case: snake_to_camel_case(&name), - categories: cats, - url: updated_url, - }, - ); + if line.starts_with("-- meta:") { + let meta_line = &line[8..].trim(); // Remove "-- meta:" + + if let Some(value) = extract_meta_value(meta_line, "name") { + name = Some(value); + } else if let Some(value) = extract_meta_value(meta_line, "title") { + title = Some(value); + } else if let Some(value) = extract_meta_value(meta_line, "severity") { + severity = Some(value); + } else if let Some(value) = extract_meta_value(meta_line, "category") { + meta_category = Some(value); + } else if let Some(value) = extract_meta_value(meta_line, "description") { + description = Some(value); + } else if let Some(value) = extract_meta_value(meta_line, "remediation") { + remediation = Some(value); } } } - // Add the "unknown" fallback rule - rules.insert( - "unknown".to_string(), - RuleInfo { - snake_case: "unknown".to_string(), - camel_case: "unknown".to_string(), - categories: vec!["UNKNOWN".to_string()], - url: Some("https://supabase.com/docs/guides/database/database-advisors".to_string()), - }, - ); - - Ok(rules) + // Get snake_case name from filename + let snake_name = sql_path + .file_stem() + .and_then(|s| s.to_str()) + .context("Invalid filename")? + .to_string(); + + // Build metadata + let name = name.context("Missing 'name' in metadata comments")?; + let title = title.context("Missing 'title' in metadata comments")?; + let severity = severity.context("Missing 'severity' in metadata comments")?; + let category_from_meta = meta_category.context("Missing 'category' in metadata comments")?; + let description = description.context("Missing 'description' in metadata comments")?; + let remediation = remediation.unwrap_or_else(|| { + "https://supabase.com/docs/guides/database/database-advisors".to_string() + }); + + // Verify category matches directory + if category_from_meta.to_lowercase() != category { + anyhow::bail!( + "Category mismatch: file in {category}/ but metadata says {category_from_meta}" + ); + } + + let sql_file_path = PathBuf::from(category).join(format!("{}.sql", snake_name)); + + Ok(SqlRuleMetadata { + name, + snake_name, + title, + severity, + category: category_from_meta, + description, + remediation, + sql_file_path, + }) +} + +/// Extract value from metadata line like "name = value" +fn extract_meta_value(line: &str, key: &str) -> Option { + if let Some(pos) = line.find(&format!("{key} =")) { + let value_start = pos + key.len() + " =".len(); + let value = line[value_start..].trim(); + return Some(value.to_string()); + } + None +} + +/// Generate src/rule.rs with SplinterRule trait +fn generate_rule_trait() -> Result<()> { + let rule_path = project_root().join("crates/pgls_splinter/src/rule.rs"); + + let content = quote! { + //! Generated file, do not edit by hand, see `xtask/codegen` + + use pgls_analyse::RuleMeta; + + /// Trait for splinter (database-level) rules + /// + /// Splinter rules are different from linter rules: + /// - They execute SQL queries against the database + /// - They don't have AST-based execution + /// - Rule logic is in SQL files, not Rust + pub trait SplinterRule: RuleMeta { + /// Path to the SQL file containing the rule query + fn sql_file_path() -> &'static str; + } + }; + + let formatted = xtask::reformat(content)?; + update(&rule_path, &formatted, &Mode::Overwrite)?; + + Ok(()) } -/// Extract a string literal from a line like "'some_string' as ..." -fn extract_string_literal(line: &str) -> Option { - let trimmed = line.trim(); +/// Generate rule files in src/rules/{category}/{rule_name}.rs +fn generate_rule_files(rules: &BTreeMap) -> Result<()> { + let rules_dir = project_root().join("crates/pgls_splinter/src/rules"); + + // Group rules by category + let mut rules_by_category: BTreeMap> = BTreeMap::new(); + for rule in rules.values() { + rules_by_category + .entry(rule.category.to_lowercase()) + .or_default() + .push(rule); + } + + // Generate category mod files and rule files + for (category, category_rules) in &rules_by_category { + let category_dir = rules_dir.join(category); + fs2::create_dir_all(&category_dir)?; - if let Some(start_single) = trimmed.find('\'') { - if let Some(end) = trimmed[start_single + 1..].find('\'') { - return Some(trimmed[start_single + 1..start_single + 1 + end].to_string()); + // Generate individual rule files + for rule in category_rules { + generate_rule_file(&category_dir, rule)?; } + + // Generate category mod.rs + generate_category_mod(&category_dir, category, category_rules)?; } - None + // Generate main rules mod.rs + generate_rules_mod(&rules_dir, &rules_by_category)?; + + Ok(()) } -/// Extract categories from a line like "array['CATEGORY'] as "categories!"," -fn extract_categories(line: &str) -> Option> { - let trimmed = line.trim(); - - // Look for array['...'] - if let Some(start) = trimmed.find("array[") { - if let Some(end) = trimmed[start..].find(']') { - let array_content = &trimmed[start + 6..start + end]; - - // Extract all string literals within the array - let categories: Vec = array_content - .split(',') - .filter_map(|s| { - let s = s.trim(); - if let Some(start_quote) = s.find('\'') { - if let Some(end_quote) = s[start_quote + 1..].find('\'') { - return Some( - s[start_quote + 1..start_quote + 1 + end_quote].to_string(), - ); - } - } - None - }) - .collect(); - - if !categories.is_empty() { - return Some(categories); +/// Generate individual rule file +fn generate_rule_file(category_dir: &Path, metadata: &SqlRuleMetadata) -> Result<()> { + let rule_file = category_dir.join(format!("{}.rs", metadata.snake_name)); + + let struct_name = Case::Pascal.convert(&metadata.snake_name); + let struct_name = format_ident!("{}", struct_name); + + // These will be used as string literals in the quote! + let title = &metadata.title; + let description = &metadata.description; + let name = &metadata.name; // camelCase name + let category_upper = metadata.category.to_uppercase(); + let category_ident = format_ident!("{}", category_upper); + let sql_path = metadata.sql_file_path.display().to_string(); + + // Parse severity - this will be a Rust expression + let severity = match metadata.severity.as_str() { + "INFO" => quote! { pgls_diagnostics::Severity::Information }, + "WARN" => quote! { pgls_diagnostics::Severity::Warning }, + "ERROR" => quote! { pgls_diagnostics::Severity::Error }, + _ => quote! { pgls_diagnostics::Severity::Information }, + }; + + let content = quote! { + //! Generated file, do not edit by hand, see `xtask/codegen` + + use crate::rule::SplinterRule; + use pgls_analyse::RuleMeta; + + ::pgls_analyse::declare_rule! { + /// #title + /// + /// #description + pub #struct_name { + version: "1.0.0", + name: #name, + severity: #severity, } } - } - None + impl SplinterRule for #struct_name { + fn sql_file_path() -> &'static str { + #sql_path + } + } + }; + + let formatted = xtask::reformat(content)?; + update(&rule_file, &formatted, &Mode::Overwrite)?; + + Ok(()) } -/// Convert snake_case to camelCase -fn snake_to_camel_case(s: &str) -> String { - Case::Camel.convert(s) +/// Generate category mod.rs that exports all rules in the category +fn generate_category_mod( + category_dir: &Path, + category: &str, + rules: &[&SqlRuleMetadata], +) -> Result<()> { + let mod_file = category_dir.join("mod.rs"); + + let category_title = Case::Pascal.convert(category); + let category_struct = format_ident!("{}", category_title); + + // Generate mod declarations + let mod_names: Vec<_> = rules + .iter() + .map(|r| format_ident!("{}", r.snake_name)) + .collect(); + + // Generate rule paths for declare_lint_group! + let rule_paths: Vec<_> = rules + .iter() + .map(|r| { + let mod_name = format_ident!("{}", r.snake_name); + let struct_name = format_ident!("{}", Case::Pascal.convert(&r.snake_name)); + quote! { self::#mod_name::#struct_name } + }) + .collect(); + + let content = quote! { + //! Generated file, do not edit by hand, see `xtask/codegen` + + #( pub mod #mod_names; )* + + ::pgls_analyse::declare_lint_group! { + pub #category_struct { + name: #category, + rules: [ + #( #rule_paths, )* + ] + } + } + }; + + let formatted = xtask::reformat(content)?; + update(&mod_file, &formatted, &Mode::Overwrite)?; + + Ok(()) } -/// Check if a string is a valid URL (simple check for http/https) -fn is_valid_url(s: &str) -> bool { - s.starts_with("http://") || s.starts_with("https://") +/// Generate main rules/mod.rs +fn generate_rules_mod( + rules_dir: &Path, + rules_by_category: &BTreeMap>, +) -> Result<()> { + let mod_file = rules_dir.join("mod.rs"); + + let category_mods: Vec<_> = rules_by_category + .keys() + .map(|cat| { + let mod_name = format_ident!("{}", cat); + quote! { pub mod #mod_name; } + }) + .collect(); + + // Generate group paths for declare_category! + let group_paths: Vec<_> = rules_by_category + .keys() + .map(|cat| { + let mod_name = format_ident!("{}", cat); + let group_name = format_ident!("{}", Case::Pascal.convert(cat)); + quote! { self::#mod_name::#group_name } + }) + .collect(); + + let content = quote! { + //! Generated file, do not edit by hand, see `xtask/codegen` + + #( #category_mods )* + + ::pgls_analyse::declare_category! { + pub Splinter { + kind: Lint, + groups: [ + #( #group_paths, )* + ] + } + } + }; + + let formatted = xtask::reformat(content)?; + update(&mod_file, &formatted, &Mode::Overwrite)?; + + Ok(()) } -struct RuleInfo { - #[allow(dead_code)] - snake_case: String, - camel_case: String, - categories: Vec, - url: Option, +/// Generate src/registry.rs with visit_registry() and get_sql_file_path() +fn generate_registry(rules: &BTreeMap) -> Result<()> { + let registry_path = project_root().join("crates/pgls_splinter/src/registry.rs"); + + // Group rules by category for organized output + let mut rules_by_category: BTreeMap> = BTreeMap::new(); + for rule in rules.values() { + rules_by_category + .entry(rule.category.to_lowercase()) + .or_default() + .push(rule); + } + + // Record the top-level category (which contains all groups) + let mut record_calls = vec![quote! { + registry.record_category::(); + }]; + + // Generate match arms for SQL file path mapping + let sql_path_arms: Vec<_> = rules + .values() + .map(|rule| { + let name = &rule.name; + let vendor_path = project_root() + .join("crates/pgls_splinter/vendor") + .join(&rule.sql_file_path); + let path_str = vendor_path.display().to_string(); + + quote! { + #name => Some(#path_str) + } + }) + .collect(); + + let content = quote! { + //! Generated file, do not edit by hand, see `xtask/codegen` + + use pgls_analyse::RegistryVisitor; + + /// Visit all splinter rules using the visitor pattern + /// This is called during registry building to collect enabled rules + pub fn visit_registry(registry: &mut V) { + #( #record_calls )* + } + + /// Map rule name (camelCase) to SQL file path + /// Returns None if rule not found + pub fn get_sql_file_path(rule_name: &str) -> Option<&'static str> { + match rule_name { + #( #sql_path_arms, )* + _ => None, + } + } + }; + + let formatted = xtask::reformat(content)?; + update(®istry_path, &formatted, &Mode::Overwrite)?; + + Ok(()) } /// Update the categories.rs file with splinter rules -fn update_categories_file(rules: BTreeMap) -> Result<()> { +/// This maintains backward compatibility with existing category system +fn update_categories_file(rules: &BTreeMap) -> Result<()> { let categories_path = project_root().join("crates/pgls_diagnostics_categories/src/categories.rs"); @@ -179,30 +429,16 @@ fn update_categories_file(rules: BTreeMap) -> Result<()> { // Generate splinter rule entries grouped by category let mut splinter_rules: Vec<(String, String)> = rules .values() - .flat_map(|rule| { - // For each rule, create entries for all its categories - // In practice, splinter rules have only one category - rule.categories.iter().map(|category| { - let group = category.to_lowercase(); - - // Use extracted URL if it's a valid URL, otherwise fallback to default - let url = rule - .url - .as_ref() - .filter(|u| is_valid_url(u)) - .map(|u| u.as_str()) - .unwrap_or("https://supabase.com/docs/guides/database/database-advisors"); - - ( - group.clone(), - format!( - " \"splinter/{}/{}\": \"{}\",", - group, rule.camel_case, url - ), - ) - }) + .map(|rule| { + let group = rule.category.to_lowercase(); + let url = &rule.remediation; + + ( + group.clone(), + format!(" \"splinter/{}/{}\": \"{}\",", group, rule.name, url), + ) }) - .collect::>(); + .collect(); // Sort by group, then by entry splinter_rules.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1))); From 821dd3a776f8a4b9cb6fee99746940a4a35f4d67 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 15 Dec 2025 11:15:42 +0100 Subject: [PATCH 07/32] fix: update bun.lock and remove unused mut --- xtask/codegen/src/generate_splinter.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xtask/codegen/src/generate_splinter.rs b/xtask/codegen/src/generate_splinter.rs index d0fba86b4..3291f4fcf 100644 --- a/xtask/codegen/src/generate_splinter.rs +++ b/xtask/codegen/src/generate_splinter.rs @@ -371,7 +371,7 @@ fn generate_registry(rules: &BTreeMap) -> Result<()> { } // Record the top-level category (which contains all groups) - let mut record_calls = vec![quote! { + let record_calls = vec![quote! { registry.record_category::(); }]; From 03797590adb7b0bbd955c147113caa0048c114bc Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 15 Dec 2025 13:34:25 +0100 Subject: [PATCH 08/32] chore: rebase onto refactor/rules with test import fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picked up test import fixes from base branch and applied codegen updates. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- crates/pgls_configuration/src/linter/rules.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/pgls_configuration/src/linter/rules.rs b/crates/pgls_configuration/src/linter/rules.rs index 6336d88fa..4bc3d4c98 100644 --- a/crates/pgls_configuration/src/linter/rules.rs +++ b/crates/pgls_configuration/src/linter/rules.rs @@ -72,8 +72,8 @@ impl Rules { #[doc = r" The function can return `None` if the rule is not properly configured."] pub fn get_severity_from_code(&self, category: &Category) -> Option { let mut split_code = category.name().split('/'); - let _category = split_code.next(); - debug_assert_eq!(_category, Some("lint")); + let _lint = split_code.next(); + debug_assert_eq!(_lint, Some("lint")); let group = ::from_str(split_code.next()?).ok()?; let rule_name = split_code.next()?; let rule_name = Self::has_rule(group, rule_name)?; From cc43d1f7cb64836d4c562347689e9113ec2abc28 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 15 Dec 2025 13:53:41 +0100 Subject: [PATCH 09/32] fix: use relative paths in splinter registry codegen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed SQL file path generation to use relative paths from crate root instead of absolute paths, fixing CI codegen check failures. Also removed unused splinter/unknown category from TypeScript types. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- crates/pgls_splinter/src/registry.rs | 84 +++++-------------- .../backend-jsonrpc/src/workspace.ts | 4 +- .../backend-jsonrpc/src/workspace.ts | 4 +- xtask/codegen/src/generate_splinter.rs | 8 +- 4 files changed, 26 insertions(+), 74 deletions(-) diff --git a/crates/pgls_splinter/src/registry.rs b/crates/pgls_splinter/src/registry.rs index 64f211b8d..911b22891 100644 --- a/crates/pgls_splinter/src/registry.rs +++ b/crates/pgls_splinter/src/registry.rs @@ -11,69 +11,27 @@ pub fn visit_registry(registry: &mut V) { #[doc = r" Returns None if rule not found"] pub fn get_sql_file_path(rule_name: &str) -> Option<&'static str> { match rule_name { - "authRlsInitplan" => Some( - "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/performance/auth_rls_initplan.sql", - ), - "authUsersExposed" => Some( - "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/auth_users_exposed.sql", - ), - "duplicateIndex" => Some( - "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/performance/duplicate_index.sql", - ), - "extensionInPublic" => Some( - "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/extension_in_public.sql", - ), - "extensionVersionsOutdated" => Some( - "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/extension_versions_outdated.sql", - ), - "fkeyToAuthUnique" => Some( - "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/fkey_to_auth_unique.sql", - ), - "foreignTableInApi" => Some( - "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/foreign_table_in_api.sql", - ), - "functionSearchPathMutable" => Some( - "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/function_search_path_mutable.sql", - ), - "insecureQueueExposedInApi" => Some( - "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/insecure_queue_exposed_in_api.sql", - ), - "materializedViewInApi" => Some( - "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/materialized_view_in_api.sql", - ), - "multiplePermissivePolicies" => Some( - "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/performance/multiple_permissive_policies.sql", - ), - "noPrimaryKey" => Some( - "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/performance/no_primary_key.sql", - ), - "policyExistsRlsDisabled" => Some( - "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/policy_exists_rls_disabled.sql", - ), - "rlsDisabledInPublic" => Some( - "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/rls_disabled_in_public.sql", - ), - "rlsEnabledNoPolicy" => Some( - "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/rls_enabled_no_policy.sql", - ), - "rlsReferencesUserMetadata" => Some( - "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/rls_references_user_metadata.sql", - ), - "securityDefinerView" => Some( - "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/security_definer_view.sql", - ), - "tableBloat" => Some( - "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/performance/table_bloat.sql", - ), - "unindexedForeignKeys" => Some( - "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/performance/unindexed_foreign_keys.sql", - ), - "unsupportedRegTypes" => Some( - "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/security/unsupported_reg_types.sql", - ), - "unusedIndex" => Some( - "/Users/psteinroe/Developer/postgres-language-server.git/refactor/rules/crates/pgls_splinter/vendor/performance/unused_index.sql", - ), + "authRlsInitplan" => Some("vendor/performance/auth_rls_initplan.sql"), + "authUsersExposed" => Some("vendor/security/auth_users_exposed.sql"), + "duplicateIndex" => Some("vendor/performance/duplicate_index.sql"), + "extensionInPublic" => Some("vendor/security/extension_in_public.sql"), + "extensionVersionsOutdated" => Some("vendor/security/extension_versions_outdated.sql"), + "fkeyToAuthUnique" => Some("vendor/security/fkey_to_auth_unique.sql"), + "foreignTableInApi" => Some("vendor/security/foreign_table_in_api.sql"), + "functionSearchPathMutable" => Some("vendor/security/function_search_path_mutable.sql"), + "insecureQueueExposedInApi" => Some("vendor/security/insecure_queue_exposed_in_api.sql"), + "materializedViewInApi" => Some("vendor/security/materialized_view_in_api.sql"), + "multiplePermissivePolicies" => Some("vendor/performance/multiple_permissive_policies.sql"), + "noPrimaryKey" => Some("vendor/performance/no_primary_key.sql"), + "policyExistsRlsDisabled" => Some("vendor/security/policy_exists_rls_disabled.sql"), + "rlsDisabledInPublic" => Some("vendor/security/rls_disabled_in_public.sql"), + "rlsEnabledNoPolicy" => Some("vendor/security/rls_enabled_no_policy.sql"), + "rlsReferencesUserMetadata" => Some("vendor/security/rls_references_user_metadata.sql"), + "securityDefinerView" => Some("vendor/security/security_definer_view.sql"), + "tableBloat" => Some("vendor/performance/table_bloat.sql"), + "unindexedForeignKeys" => Some("vendor/performance/unindexed_foreign_keys.sql"), + "unsupportedRegTypes" => Some("vendor/security/unsupported_reg_types.sql"), + "unusedIndex" => Some("vendor/performance/unused_index.sql"), _ => None, } } diff --git a/packages/@postgres-language-server/backend-jsonrpc/src/workspace.ts b/packages/@postgres-language-server/backend-jsonrpc/src/workspace.ts index 4771e6c4b..180fc6b60 100644 --- a/packages/@postgres-language-server/backend-jsonrpc/src/workspace.ts +++ b/packages/@postgres-language-server/backend-jsonrpc/src/workspace.ts @@ -116,7 +116,6 @@ export type Category = | "splinter/security/rlsReferencesUserMetadata" | "splinter/security/securityDefinerView" | "splinter/security/unsupportedRegTypes" - | "splinter/unknown/unknown" | "stdin" | "check" | "configuration" @@ -136,8 +135,7 @@ export type Category = | "lint/safety" | "splinter" | "splinter/performance" - | "splinter/security" - | "splinter/unknown"; + | "splinter/security"; export interface Location { path?: Resource_for_String; sourceCode?: string; diff --git a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts index 4771e6c4b..180fc6b60 100644 --- a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts +++ b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts @@ -116,7 +116,6 @@ export type Category = | "splinter/security/rlsReferencesUserMetadata" | "splinter/security/securityDefinerView" | "splinter/security/unsupportedRegTypes" - | "splinter/unknown/unknown" | "stdin" | "check" | "configuration" @@ -136,8 +135,7 @@ export type Category = | "lint/safety" | "splinter" | "splinter/performance" - | "splinter/security" - | "splinter/unknown"; + | "splinter/security"; export interface Location { path?: Resource_for_String; sourceCode?: string; diff --git a/xtask/codegen/src/generate_splinter.rs b/xtask/codegen/src/generate_splinter.rs index 3291f4fcf..b3fc58de0 100644 --- a/xtask/codegen/src/generate_splinter.rs +++ b/xtask/codegen/src/generate_splinter.rs @@ -380,13 +380,11 @@ fn generate_registry(rules: &BTreeMap) -> Result<()> { .values() .map(|rule| { let name = &rule.name; - let vendor_path = project_root() - .join("crates/pgls_splinter/vendor") - .join(&rule.sql_file_path); - let path_str = vendor_path.display().to_string(); + // Use relative path from crate root + let relative_path = format!("vendor/{}", rule.sql_file_path.display()); quote! { - #name => Some(#path_str) + #name => Some(#relative_path) } }) .collect(); From 85b1802a33b369113791565419c10541f42c101c Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 15 Dec 2025 14:07:07 +0100 Subject: [PATCH 10/32] fix: remove unused RuleMeta import and apply clippy fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed unused `use pgls_analyse::RuleMeta;` import from generated splinter rule files and applied clippy format! macro fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../pgls_splinter/src/rules/performance/auth_rls_initplan.rs | 1 - crates/pgls_splinter/src/rules/performance/duplicate_index.rs | 1 - .../src/rules/performance/multiple_permissive_policies.rs | 1 - crates/pgls_splinter/src/rules/performance/no_primary_key.rs | 1 - crates/pgls_splinter/src/rules/performance/table_bloat.rs | 1 - .../src/rules/performance/unindexed_foreign_keys.rs | 1 - crates/pgls_splinter/src/rules/performance/unused_index.rs | 1 - crates/pgls_splinter/src/rules/security/auth_users_exposed.rs | 1 - crates/pgls_splinter/src/rules/security/extension_in_public.rs | 1 - .../src/rules/security/extension_versions_outdated.rs | 1 - crates/pgls_splinter/src/rules/security/fkey_to_auth_unique.rs | 1 - .../pgls_splinter/src/rules/security/foreign_table_in_api.rs | 1 - .../src/rules/security/function_search_path_mutable.rs | 1 - .../src/rules/security/insecure_queue_exposed_in_api.rs | 1 - .../src/rules/security/materialized_view_in_api.rs | 1 - .../src/rules/security/policy_exists_rls_disabled.rs | 1 - .../pgls_splinter/src/rules/security/rls_disabled_in_public.rs | 1 - .../pgls_splinter/src/rules/security/rls_enabled_no_policy.rs | 1 - .../src/rules/security/rls_references_user_metadata.rs | 1 - .../pgls_splinter/src/rules/security/security_definer_view.rs | 1 - .../pgls_splinter/src/rules/security/unsupported_reg_types.rs | 1 - xtask/codegen/src/generate_splinter.rs | 3 +-- 22 files changed, 1 insertion(+), 23 deletions(-) diff --git a/crates/pgls_splinter/src/rules/performance/auth_rls_initplan.rs b/crates/pgls_splinter/src/rules/performance/auth_rls_initplan.rs index fd64ce41f..6b733217c 100644 --- a/crates/pgls_splinter/src/rules/performance/auth_rls_initplan.rs +++ b/crates/pgls_splinter/src/rules/performance/auth_rls_initplan.rs @@ -2,7 +2,6 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -use pgls_analyse::RuleMeta; ::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub AuthRlsInitplan { version : "1.0.0" , name : "authRlsInitplan" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for AuthRlsInitplan { fn sql_file_path() -> &'static str { diff --git a/crates/pgls_splinter/src/rules/performance/duplicate_index.rs b/crates/pgls_splinter/src/rules/performance/duplicate_index.rs index 81394496f..85077c948 100644 --- a/crates/pgls_splinter/src/rules/performance/duplicate_index.rs +++ b/crates/pgls_splinter/src/rules/performance/duplicate_index.rs @@ -2,7 +2,6 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -use pgls_analyse::RuleMeta; ::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub DuplicateIndex { version : "1.0.0" , name : "duplicateIndex" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for DuplicateIndex { fn sql_file_path() -> &'static str { diff --git a/crates/pgls_splinter/src/rules/performance/multiple_permissive_policies.rs b/crates/pgls_splinter/src/rules/performance/multiple_permissive_policies.rs index 45d45733f..e53653482 100644 --- a/crates/pgls_splinter/src/rules/performance/multiple_permissive_policies.rs +++ b/crates/pgls_splinter/src/rules/performance/multiple_permissive_policies.rs @@ -2,7 +2,6 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -use pgls_analyse::RuleMeta; ::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub MultiplePermissivePolicies { version : "1.0.0" , name : "multiplePermissivePolicies" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for MultiplePermissivePolicies { fn sql_file_path() -> &'static str { diff --git a/crates/pgls_splinter/src/rules/performance/no_primary_key.rs b/crates/pgls_splinter/src/rules/performance/no_primary_key.rs index 3127f7bf3..584aa0474 100644 --- a/crates/pgls_splinter/src/rules/performance/no_primary_key.rs +++ b/crates/pgls_splinter/src/rules/performance/no_primary_key.rs @@ -2,7 +2,6 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -use pgls_analyse::RuleMeta; ::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub NoPrimaryKey { version : "1.0.0" , name : "noPrimaryKey" , severity : pgls_diagnostics :: Severity :: Information , } } impl SplinterRule for NoPrimaryKey { fn sql_file_path() -> &'static str { diff --git a/crates/pgls_splinter/src/rules/performance/table_bloat.rs b/crates/pgls_splinter/src/rules/performance/table_bloat.rs index 1efe0d43a..734cb8acd 100644 --- a/crates/pgls_splinter/src/rules/performance/table_bloat.rs +++ b/crates/pgls_splinter/src/rules/performance/table_bloat.rs @@ -2,7 +2,6 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -use pgls_analyse::RuleMeta; ::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub TableBloat { version : "1.0.0" , name : "tableBloat" , severity : pgls_diagnostics :: Severity :: Information , } } impl SplinterRule for TableBloat { fn sql_file_path() -> &'static str { diff --git a/crates/pgls_splinter/src/rules/performance/unindexed_foreign_keys.rs b/crates/pgls_splinter/src/rules/performance/unindexed_foreign_keys.rs index 4e75899b6..aa59e3e09 100644 --- a/crates/pgls_splinter/src/rules/performance/unindexed_foreign_keys.rs +++ b/crates/pgls_splinter/src/rules/performance/unindexed_foreign_keys.rs @@ -2,7 +2,6 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -use pgls_analyse::RuleMeta; ::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub UnindexedForeignKeys { version : "1.0.0" , name : "unindexedForeignKeys" , severity : pgls_diagnostics :: Severity :: Information , } } impl SplinterRule for UnindexedForeignKeys { fn sql_file_path() -> &'static str { diff --git a/crates/pgls_splinter/src/rules/performance/unused_index.rs b/crates/pgls_splinter/src/rules/performance/unused_index.rs index afdf18c36..1563989d0 100644 --- a/crates/pgls_splinter/src/rules/performance/unused_index.rs +++ b/crates/pgls_splinter/src/rules/performance/unused_index.rs @@ -2,7 +2,6 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -use pgls_analyse::RuleMeta; ::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub UnusedIndex { version : "1.0.0" , name : "unusedIndex" , severity : pgls_diagnostics :: Severity :: Information , } } impl SplinterRule for UnusedIndex { fn sql_file_path() -> &'static str { diff --git a/crates/pgls_splinter/src/rules/security/auth_users_exposed.rs b/crates/pgls_splinter/src/rules/security/auth_users_exposed.rs index c159f470d..25d76b2df 100644 --- a/crates/pgls_splinter/src/rules/security/auth_users_exposed.rs +++ b/crates/pgls_splinter/src/rules/security/auth_users_exposed.rs @@ -2,7 +2,6 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -use pgls_analyse::RuleMeta; ::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub AuthUsersExposed { version : "1.0.0" , name : "authUsersExposed" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for AuthUsersExposed { fn sql_file_path() -> &'static str { diff --git a/crates/pgls_splinter/src/rules/security/extension_in_public.rs b/crates/pgls_splinter/src/rules/security/extension_in_public.rs index e341ddf02..d4b85b743 100644 --- a/crates/pgls_splinter/src/rules/security/extension_in_public.rs +++ b/crates/pgls_splinter/src/rules/security/extension_in_public.rs @@ -2,7 +2,6 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -use pgls_analyse::RuleMeta; ::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub ExtensionInPublic { version : "1.0.0" , name : "extensionInPublic" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for ExtensionInPublic { fn sql_file_path() -> &'static str { diff --git a/crates/pgls_splinter/src/rules/security/extension_versions_outdated.rs b/crates/pgls_splinter/src/rules/security/extension_versions_outdated.rs index 3596a6b99..22ff28507 100644 --- a/crates/pgls_splinter/src/rules/security/extension_versions_outdated.rs +++ b/crates/pgls_splinter/src/rules/security/extension_versions_outdated.rs @@ -2,7 +2,6 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -use pgls_analyse::RuleMeta; ::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub ExtensionVersionsOutdated { version : "1.0.0" , name : "extensionVersionsOutdated" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for ExtensionVersionsOutdated { fn sql_file_path() -> &'static str { diff --git a/crates/pgls_splinter/src/rules/security/fkey_to_auth_unique.rs b/crates/pgls_splinter/src/rules/security/fkey_to_auth_unique.rs index 12986b7ef..e4cfced1d 100644 --- a/crates/pgls_splinter/src/rules/security/fkey_to_auth_unique.rs +++ b/crates/pgls_splinter/src/rules/security/fkey_to_auth_unique.rs @@ -2,7 +2,6 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -use pgls_analyse::RuleMeta; ::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub FkeyToAuthUnique { version : "1.0.0" , name : "fkeyToAuthUnique" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for FkeyToAuthUnique { fn sql_file_path() -> &'static str { diff --git a/crates/pgls_splinter/src/rules/security/foreign_table_in_api.rs b/crates/pgls_splinter/src/rules/security/foreign_table_in_api.rs index 9bd223d86..eda8a8576 100644 --- a/crates/pgls_splinter/src/rules/security/foreign_table_in_api.rs +++ b/crates/pgls_splinter/src/rules/security/foreign_table_in_api.rs @@ -2,7 +2,6 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -use pgls_analyse::RuleMeta; ::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub ForeignTableInApi { version : "1.0.0" , name : "foreignTableInApi" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for ForeignTableInApi { fn sql_file_path() -> &'static str { diff --git a/crates/pgls_splinter/src/rules/security/function_search_path_mutable.rs b/crates/pgls_splinter/src/rules/security/function_search_path_mutable.rs index 0786cffb1..0e67b7d10 100644 --- a/crates/pgls_splinter/src/rules/security/function_search_path_mutable.rs +++ b/crates/pgls_splinter/src/rules/security/function_search_path_mutable.rs @@ -2,7 +2,6 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -use pgls_analyse::RuleMeta; ::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub FunctionSearchPathMutable { version : "1.0.0" , name : "functionSearchPathMutable" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for FunctionSearchPathMutable { fn sql_file_path() -> &'static str { diff --git a/crates/pgls_splinter/src/rules/security/insecure_queue_exposed_in_api.rs b/crates/pgls_splinter/src/rules/security/insecure_queue_exposed_in_api.rs index 9916e69da..077801ea2 100644 --- a/crates/pgls_splinter/src/rules/security/insecure_queue_exposed_in_api.rs +++ b/crates/pgls_splinter/src/rules/security/insecure_queue_exposed_in_api.rs @@ -2,7 +2,6 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -use pgls_analyse::RuleMeta; ::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub InsecureQueueExposedInApi { version : "1.0.0" , name : "insecureQueueExposedInApi" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for InsecureQueueExposedInApi { fn sql_file_path() -> &'static str { diff --git a/crates/pgls_splinter/src/rules/security/materialized_view_in_api.rs b/crates/pgls_splinter/src/rules/security/materialized_view_in_api.rs index 8402712cf..2f7f71e72 100644 --- a/crates/pgls_splinter/src/rules/security/materialized_view_in_api.rs +++ b/crates/pgls_splinter/src/rules/security/materialized_view_in_api.rs @@ -2,7 +2,6 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -use pgls_analyse::RuleMeta; ::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub MaterializedViewInApi { version : "1.0.0" , name : "materializedViewInApi" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for MaterializedViewInApi { fn sql_file_path() -> &'static str { diff --git a/crates/pgls_splinter/src/rules/security/policy_exists_rls_disabled.rs b/crates/pgls_splinter/src/rules/security/policy_exists_rls_disabled.rs index eb4bb493d..daabdb6ad 100644 --- a/crates/pgls_splinter/src/rules/security/policy_exists_rls_disabled.rs +++ b/crates/pgls_splinter/src/rules/security/policy_exists_rls_disabled.rs @@ -2,7 +2,6 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -use pgls_analyse::RuleMeta; ::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub PolicyExistsRlsDisabled { version : "1.0.0" , name : "policyExistsRlsDisabled" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for PolicyExistsRlsDisabled { fn sql_file_path() -> &'static str { diff --git a/crates/pgls_splinter/src/rules/security/rls_disabled_in_public.rs b/crates/pgls_splinter/src/rules/security/rls_disabled_in_public.rs index 0844e5108..2ab01fde4 100644 --- a/crates/pgls_splinter/src/rules/security/rls_disabled_in_public.rs +++ b/crates/pgls_splinter/src/rules/security/rls_disabled_in_public.rs @@ -2,7 +2,6 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -use pgls_analyse::RuleMeta; ::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub RlsDisabledInPublic { version : "1.0.0" , name : "rlsDisabledInPublic" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for RlsDisabledInPublic { fn sql_file_path() -> &'static str { diff --git a/crates/pgls_splinter/src/rules/security/rls_enabled_no_policy.rs b/crates/pgls_splinter/src/rules/security/rls_enabled_no_policy.rs index d184ef5dc..1b1576d48 100644 --- a/crates/pgls_splinter/src/rules/security/rls_enabled_no_policy.rs +++ b/crates/pgls_splinter/src/rules/security/rls_enabled_no_policy.rs @@ -2,7 +2,6 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -use pgls_analyse::RuleMeta; ::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub RlsEnabledNoPolicy { version : "1.0.0" , name : "rlsEnabledNoPolicy" , severity : pgls_diagnostics :: Severity :: Information , } } impl SplinterRule for RlsEnabledNoPolicy { fn sql_file_path() -> &'static str { diff --git a/crates/pgls_splinter/src/rules/security/rls_references_user_metadata.rs b/crates/pgls_splinter/src/rules/security/rls_references_user_metadata.rs index c93d3aa10..9b2f24b3c 100644 --- a/crates/pgls_splinter/src/rules/security/rls_references_user_metadata.rs +++ b/crates/pgls_splinter/src/rules/security/rls_references_user_metadata.rs @@ -2,7 +2,6 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -use pgls_analyse::RuleMeta; ::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub RlsReferencesUserMetadata { version : "1.0.0" , name : "rlsReferencesUserMetadata" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for RlsReferencesUserMetadata { fn sql_file_path() -> &'static str { diff --git a/crates/pgls_splinter/src/rules/security/security_definer_view.rs b/crates/pgls_splinter/src/rules/security/security_definer_view.rs index b58714b03..53a11c04f 100644 --- a/crates/pgls_splinter/src/rules/security/security_definer_view.rs +++ b/crates/pgls_splinter/src/rules/security/security_definer_view.rs @@ -2,7 +2,6 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -use pgls_analyse::RuleMeta; ::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub SecurityDefinerView { version : "1.0.0" , name : "securityDefinerView" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for SecurityDefinerView { fn sql_file_path() -> &'static str { diff --git a/crates/pgls_splinter/src/rules/security/unsupported_reg_types.rs b/crates/pgls_splinter/src/rules/security/unsupported_reg_types.rs index 79453c31f..a5672c772 100644 --- a/crates/pgls_splinter/src/rules/security/unsupported_reg_types.rs +++ b/crates/pgls_splinter/src/rules/security/unsupported_reg_types.rs @@ -2,7 +2,6 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -use pgls_analyse::RuleMeta; ::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub UnsupportedRegTypes { version : "1.0.0" , name : "unsupportedRegTypes" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for UnsupportedRegTypes { fn sql_file_path() -> &'static str { diff --git a/xtask/codegen/src/generate_splinter.rs b/xtask/codegen/src/generate_splinter.rs index b3fc58de0..833ce9240 100644 --- a/xtask/codegen/src/generate_splinter.rs +++ b/xtask/codegen/src/generate_splinter.rs @@ -126,7 +126,7 @@ fn extract_metadata_from_sql(sql_path: &Path, category: &str) -> Result Result //! Generated file, do not edit by hand, see `xtask/codegen` use crate::rule::SplinterRule; - use pgls_analyse::RuleMeta; ::pgls_analyse::declare_rule! { /// #title From a197a12d1ed40b1097911c87113a6a18d0c997d6 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 15 Dec 2025 14:19:44 +0100 Subject: [PATCH 11/32] fix: apply clippy format! macro fix in convert.rs --- crates/pgls_splinter/src/convert.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/pgls_splinter/src/convert.rs b/crates/pgls_splinter/src/convert.rs index 81e2e8db5..d0ac8b597 100644 --- a/crates/pgls_splinter/src/convert.rs +++ b/crates/pgls_splinter/src/convert.rs @@ -105,7 +105,7 @@ fn rule_name_to_category(name: &str, group: &str) -> &'static Category { ("security", "fkey_to_auth_unique") => category!("splinter/security/fkeyToAuthUnique"), _ => { // Log a warning for unknown rules but provide a fallback - eprintln!("Warning: Unknown splinter rule: {}/{}", group, name); + eprintln!("Warning: Unknown splinter rule: {group}/{name}"); category!("splinter/performance/unindexedForeignKeys") // Fallback to first rule } } From 5f46e5370fab11fc5339fb04d60d275756d2f7fc Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 15 Dec 2025 14:44:35 +0100 Subject: [PATCH 12/32] fix: panic on unknown splinter rule instead of fallback --- crates/pgls_splinter/src/convert.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/pgls_splinter/src/convert.rs b/crates/pgls_splinter/src/convert.rs index d0ac8b597..0707a7135 100644 --- a/crates/pgls_splinter/src/convert.rs +++ b/crates/pgls_splinter/src/convert.rs @@ -103,11 +103,7 @@ fn rule_name_to_category(name: &str, group: &str) -> &'static Category { category!("splinter/security/insecureQueueExposedInApi") } ("security", "fkey_to_auth_unique") => category!("splinter/security/fkeyToAuthUnique"), - _ => { - // Log a warning for unknown rules but provide a fallback - eprintln!("Warning: Unknown splinter rule: {group}/{name}"); - category!("splinter/performance/unindexedForeignKeys") // Fallback to first rule - } + _ => panic!("Unknown splinter rule: {group}/{name}") } } From df44cfd7e7b9088dcd220264e2916fdcc19ca4aa Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 15 Dec 2025 14:51:01 +0100 Subject: [PATCH 13/32] fix: apply formatting --- crates/pgls_splinter/src/convert.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/pgls_splinter/src/convert.rs b/crates/pgls_splinter/src/convert.rs index 0707a7135..a28d75d0e 100644 --- a/crates/pgls_splinter/src/convert.rs +++ b/crates/pgls_splinter/src/convert.rs @@ -103,7 +103,7 @@ fn rule_name_to_category(name: &str, group: &str) -> &'static Category { category!("splinter/security/insecureQueueExposedInApi") } ("security", "fkey_to_auth_unique") => category!("splinter/security/fkeyToAuthUnique"), - _ => panic!("Unknown splinter rule: {group}/{name}") + _ => panic!("Unknown splinter rule: {group}/{name}"), } } From 8c982d803c7c20e3eae7aa10198ebd211692b5b3 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 15 Dec 2025 09:38:43 +0100 Subject: [PATCH 14/32] chore: integrate splinter runtime with analyse --- Cargo.lock | 1 + PLAN.md | 531 +++++++++++++++--- .../src/rules/configuration.rs | 129 +++++ crates/pgls_splinter/Cargo.toml | 11 +- crates/pgls_splinter/src/lib.rs | 106 +++- crates/pgls_splinter/src/query.rs | 56 +- crates/pgls_splinter/src/registry.rs | 216 +++++++ .../rules/performance/auth_rls_initplan.rs | 2 +- .../src/rules/performance/duplicate_index.rs | 2 +- .../multiple_permissive_policies.rs | 2 +- .../src/rules/performance/no_primary_key.rs | 2 +- .../src/rules/performance/table_bloat.rs | 2 +- .../performance/unindexed_foreign_keys.rs | 2 +- .../src/rules/performance/unused_index.rs | 2 +- .../src/rules/security/auth_users_exposed.rs | 2 +- .../src/rules/security/extension_in_public.rs | 2 +- .../security/extension_versions_outdated.rs | 2 +- .../src/rules/security/fkey_to_auth_unique.rs | 2 +- .../rules/security/foreign_table_in_api.rs | 2 +- .../security/function_search_path_mutable.rs | 2 +- .../security/insecure_queue_exposed_in_api.rs | 2 +- .../security/materialized_view_in_api.rs | 2 +- .../security/policy_exists_rls_disabled.rs | 2 +- .../rules/security/rls_disabled_in_public.rs | 2 +- .../rules/security/rls_enabled_no_policy.rs | 2 +- .../security/rls_references_user_metadata.rs | 2 +- .../rules/security/security_definer_view.rs | 2 +- .../rules/security/unsupported_reg_types.rs | 2 +- crates/pgls_splinter/tests/diagnostics.rs | 42 +- .../vendor/performance/auth_rls_initplan.sql | 1 + .../vendor/security/auth_users_exposed.sql | 1 + .../vendor/security/fkey_to_auth_unique.sql | 1 + .../vendor/security/foreign_table_in_api.sql | 1 + .../insecure_queue_exposed_in_api.sql | 1 + .../security/materialized_view_in_api.sql | 1 + .../security/rls_disabled_in_public.sql | 1 + .../security/rls_references_user_metadata.sql | 1 + .../vendor/security/security_definer_view.sql | 1 + xtask/codegen/src/generate_splinter.rs | 163 +++++- 39 files changed, 1128 insertions(+), 178 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 677328fa3..a575a902a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2999,6 +2999,7 @@ dependencies = [ "pgls_analyse", "pgls_console", "pgls_diagnostics", + "pgls_schema_cache", "pgls_test_utils", "serde", "serde_json", diff --git a/PLAN.md b/PLAN.md index 72c8b279f..a1e3ee745 100644 --- a/PLAN.md +++ b/PLAN.md @@ -32,7 +32,7 @@ Integrate splinter into the codegen/rule setup used for the analyser, providing ## Implementation Phases -### Phase 1: Refactor pgls_analyse ⏳ IN PROGRESS +### Phase 1: Refactor pgls_analyse ✅ COMPLETED Extract AST-specific code into pgls_linter, keep only generic framework in pgls_analyse. **Tasks:** @@ -51,13 +51,12 @@ Extract AST-specific code into pgls_linter, keep only generic framework in pgls_ - [x] Update generated files (options.rs, registry.rs) - [x] Fix imports in all rule files - [x] Add rustc-hash dependency -- [x] Verify compilation completes - **RESOLVED** +- [x] Verify compilation completes - [x] Separate visitor concerns from executor creation - [x] Update codegen to generate factory function - [x] Fix all import paths across workspace - [x] Verify full workspace compiles - [x] Optimize executor creation (zero-cost abstraction) -- [ ] Run tests **Resolution:** Separated two concerns: @@ -161,57 +160,90 @@ pgls_splinter/vendor/ --- -### Phase 3: Integrate configuration and documentation 📋 PLANNED -Complete integration of splinter into the configuration and documentation systems. +### Phase 3: Configuration Integration ✅ COMPLETED +Integrate splinter into the configuration system. **Tasks:** -- [ ] **Configuration Generation**: - - [ ] Create `pgls_configuration/src/analyser/splinter/` directory - - [ ] Generate splinter configuration types (groups, rules) - - [ ] Update `generate_configuration.rs` to visit splinter registry - - [ ] Generate `crates/pgls_configuration/src/generated/splinter.rs` - - [ ] Update `analyser/mod.rs` to export splinter config - - [ ] Add splinter to RuleSelector enum - -- [ ] **Documentation Enhancement** (FUTURE): - - [ ] Add SQL query examples to splinter rule docs (similar to linter) +- [x] **Configuration Generation**: + - [x] Create `pgls_configuration/src/analyser/splinter/` directory + - [x] Generate splinter configuration types (Performance/Security groups, Rules struct) + - [x] Update `generate_configuration.rs` to visit splinter registry + - [x] Add `SplinterRulesVisitor` for rule collection + - [x] Generate `crates/pgls_configuration/src/analyser/splinter/rules.rs` with: + - [x] `RuleGroup` enum (Performance, Security) + - [x] `Rules` struct with recommended/all/group fields + - [x] `Performance` struct with 7 rules (using `RuleConfiguration<()>`) + - [x] `Security` struct with 14 rules (using `RuleConfiguration<()>`) + - [x] All helper methods (has_rule, severity, get_enabled_rules, etc.) + - [x] Generate `crates/pgls_configuration/src/generated/splinter.rs` with `push_to_analyser_splinter()` + - [x] Update `analyser/mod.rs` to export splinter config + - [x] Fix imports: use `pgls_analyser::RuleOptions` instead of `pgls_analyse::options::RuleOptions` + - [x] Fix type references: use `LinterRules` instead of `AnalyserRules` + - [x] Run `just gen-lint` successfully + - [x] Verify full workspace compiles + +- [ ] **Documentation Enhancement** (PHASE 5): + - [ ] Add SQL query examples to splinter rule docs - [ ] Extract SQL from vendor/*.sql files into doc comments - [ ] Add usage examples and remediation steps - [ ] Generate rule documentation via docs_codegen -- [ ] **Runtime Integration** (DEFERRED): - - [ ] Update `run_splinter()` to use visitor pattern with AnalysisFilter - - [ ] Build dynamic SQL queries from enabled rules only - - [ ] Remove hardcoded SQL query execution - - [ ] Remove hardcoded category mapping in convert.rs - -- [ ] **Testing**: - - [ ] Run `just gen-lint` successfully - - [ ] Verify linter configuration still works - - [ ] Verify splinter configuration generates - - [ ] Test enabling/disabling splinter rules via config - - [ ] Verify full workspace compiles +- [x] **Runtime Integration** (PHASE 5 - COMPLETED): + - [x] Update `run_splinter()` to use visitor pattern with AnalysisFilter + - [x] Build dynamic SQL queries from enabled rules only + - [x] Remove hardcoded SQL query execution (removed load_generic/load_supabase functions) + - [ ] Remove hardcoded category mapping in convert.rs (DEFERRED - requires codegen improvements) + - [ ] Add splinter to RuleSelector enum (DEFERRED - requires design decisions for multi-analyzer support) **Codegen Outputs After Phase 3:** ``` Linter: - - crates/pgls_analyser/src/registry.rs (generated) - - crates/pgls_analyser/src/options.rs (generated) - - crates/pgls_configuration/src/analyser/linter/ (generated) - - crates/pgls_configuration/src/generated/linter.rs (generated) + - crates/pgls_analyser/src/registry.rs (generated - ✅ DONE) + - crates/pgls_analyser/src/options.rs (generated - ✅ DONE) + - crates/pgls_configuration/src/analyser/linter/rules.rs (generated - ✅ DONE) + - crates/pgls_configuration/src/generated/linter.rs (generated - ✅ DONE) Splinter: - crates/pgls_splinter/src/rules/ (generated - ✅ DONE) - crates/pgls_splinter/src/rule.rs (generated - ✅ DONE) - crates/pgls_splinter/src/registry.rs (generated - ✅ DONE) - - crates/pgls_configuration/src/analyser/splinter/ (TODO) - - crates/pgls_configuration/src/generated/splinter.rs (TODO) + - crates/pgls_configuration/src/analyser/splinter/mod.rs (created - ✅ DONE) + - crates/pgls_configuration/src/analyser/splinter/rules.rs (generated - ✅ DONE) + - crates/pgls_configuration/src/generated/splinter.rs (generated - ✅ DONE) +``` + +**Implementation Details:** +- Configuration structure mirrors linter configuration for consistency +- Splinter rules use `RuleConfiguration<()>` since they have no rule-specific options +- All 21 rules (7 performance + 14 security) are properly configured with severities from SQL metadata +- Category name in `get_severity_from_code()` correctly uses "splinter" prefix +- No recommended rules by default (RECOMMENDED_RULES_AS_FILTERS is empty) + +**Config File Example:** +```json +{ + "splinter": { + "enabled": true, + "rules": { + "all": true, + "performance": { + "unindexedForeignKeys": "warn", + "noPrimaryKey": "off" + }, + "security": { + "authUsersExposed": "error" + } + } + } +} ``` **Notes:** -- Runtime integration (dynamic SQL query building) is deferred as it requires more complex changes to `run_splinter()` -- Documentation enhancement with SQL examples is marked as FUTURE work -- Focus Phase 3 on configuration integration to enable rule enable/disable via config files +- ✅ Configuration generation is complete and tested +- ✅ Runtime integration (dynamic SQL query building) completed in Phase 5 +- 📋 Documentation enhancement with SQL examples planned for Phase 5 (Part C) +- 📋 RuleSelector integration deferred (requires design for multi-analyzer support) +- 📋 Category mapping in convert.rs still hardcoded (can be improved with codegen) --- @@ -227,8 +259,67 @@ Final rename to clarify purpose. --- -### Phase 5: Runtime & Documentation Enhancements 📋 FUTURE -Advanced features for splinter integration (optional future work). +### Phase 5: Runtime & Documentation Enhancements ✅ COMPLETED +Advanced features for splinter integration. + +**Part B: Dynamic SQL Query Building** ✅ +**Part C: Enhanced Documentation** ✅ +**Deferred Items: All Completed** ✅ + +**Implementation Summary (Part B):** + +The runtime integration has been completed with the following changes: + +1. **Updated `run_splinter()` signature** (`crates/pgls_splinter/src/lib.rs`): + - Now accepts `filter: &AnalysisFilter<'_>` parameter + - Uses visitor pattern to collect enabled rules based on filter + - Returns early if no rules are enabled (performance optimization) + +2. **Implemented `SplinterRuleCollector` visitor**: + - Properly implements all RegistryVisitor methods (record_category, record_group, record_rule) + - Filters at each level (category, group, rule) for efficiency + - Collects rule names (camelCase) for enabled rules only + +3. **Dynamic SQL query building**: + - Reads individual SQL files from `vendor/` directory based on enabled rules + - Uses `crate::registry::get_sql_file_path()` to map rule names to SQL file paths + - Combines multiple SQL queries with `UNION ALL` + - Only executes SQL for enabled rules (major performance improvement) + +4. **Removed hardcoded functions**: + - Deleted `load_generic_splinter_results()` + - Deleted `load_supabase_splinter_results()` + - Removed Supabase role checking logic (rules are now filtered by configuration) + +5. **Updated test call sites**: + - All tests now pass `AnalysisFilter::default()` to enable all rules + - Maintains backward compatibility for test behavior + +6. **Added manual `FromRow` implementation**: + - `SplinterQueryResult` now implements `FromRow` manually (was using compile-time macro) + - Enables dynamic SQL execution while maintaining type safety + +**Performance Benefits:** +- 🚀 Only enabled rules execute SQL queries +- 🚀 Can disable expensive rules individually via configuration +- 🚀 Example: Disabling 18/21 rules means only 3 SQL queries execute instead of all 21 + +**Deferred Items (Now Completed):** +- ✅ Category mapping in `convert.rs` - **COMPLETED** + - Generated `get_rule_category()` function in `registry.rs` via codegen + - Replaced 120-line match statement with single function call + - Maps snake_case SQL result names to static Category references +- ✅ RuleSelector enum multi-analyzer support - **COMPLETED** + - Added splinter-specific variants: `SplinterGroup`, `SplinterRule` + - Implemented prefix-based parsing (`lint/`, `splinter/`) + - Maintains backward compatibility (tries linter first) +- ✅ Supabase role checking - **COMPLETED** + - Added `requires_supabase` metadata to SQL files + - Generated `rule_requires_supabase()` function + - Implemented in-memory role checking via `SchemaCache` + - Automatically filters Supabase rules when roles don't exist + - Zero configuration needed from users +- ✅ Documentation enhancement (Part C) - **COMPLETED** --- @@ -416,36 +507,62 @@ pub struct Security { /* ... */ } --- -#### **Part C: Enhanced Documentation** +#### **Part C: Enhanced Documentation** ✅ + +**Status:** COMPLETED **Tasks:** -- [ ] Extract SQL queries into rule doc comments: - ```rust - // In generate_splinter.rs codegen - let sql_content = std::fs::read_to_string(&sql_path)?; - let sql_query = extract_sql_query(&sql_content)?; // Remove metadata comments - - let content = quote! { - /// #title - /// - /// #description - /// - /// ## SQL Query - /// - /// ```sql - /// #sql_query - /// ``` - /// - /// ## Remediation - /// - /// #remediation - pub #struct_name { ... } - }; - ``` +- [x] Extract SQL queries into rule doc comments +- [x] Add configuration examples to documentation +- [x] Include Supabase requirement warnings +- [x] Link to remediation documentation +- [x] Generate comprehensive doc strings via codegen + +**Implementation:** + +The codegen (`xtask/codegen/src/generate_splinter.rs`) now generates rich documentation for all splinter rules: + +1. **Added `sql_query` field to `SqlRuleMetadata`:** + - Extracts SQL content after metadata comment headers + - Preserves formatting for readability + - Strips metadata lines (`-- meta:` prefix) + +2. **Generated comprehensive doc strings** including: + - **Title and description** from SQL metadata + - **Supabase requirement note** (conditional): + ``` + **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). + It will be automatically skipped if these roles don't exist in your database. + ``` + - **Full SQL query** in code fence with `/// ` prefix on each line + - **Configuration JSON example** showing how to enable/disable: + ```json + { + "splinter": { + "rules": { + "security": { + "authUsersExposed": "warn" + } + } + } + } + ``` + - **Remediation link** to Supabase docs or custom guidance + +3. **Generated documentation visible via `cargo doc`:** + - All 21 rules now have comprehensive documentation + - Developers can view SQL queries directly in IDE + - Easy to understand what each rule checks + +**Example Generated Documentation:** +```rust +#[doc = "/// # Unindexed foreign keys\n///\n/// Identifies foreign key constraints without a covering index, which can impact database performance.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// with foreign_keys as (\n/// select\n/// cl.relnamespace::regnamespace::text as schema_name,\n/// ... [full SQL query]\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"performance\": {\n/// \"unindexedForeignKeys\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] +pub UnindexedForeignKeys { ... } +``` -- [ ] Add example SQL snippets showing what triggers the rule -- [ ] Update docs_codegen to process splinter rules -- [ ] Generate markdown documentation for website +**Files Updated:** +- `xtask/codegen/src/generate_splinter.rs` - Enhanced documentation generation +- All 21 rule files in `crates/pgls_splinter/src/rules/` - Regenerated with rich docs --- @@ -497,9 +614,27 @@ This allows incremental rollout - config works in Phase 3, optimization comes in - [x] Architecture proposal (Option C - Hybrid Approach) - [x] Phase 1: Refactor pgls_analyse - **COMPLETED** - [x] Phase 2: Enhance pgls_splinter - **COMPLETED** -- [ ] Phase 3: Integrate configuration - **NEXT** -- [ ] Phase 4: Rename to pgls_linter -- [ ] Phase 5: Runtime & Docs (FUTURE) +- [x] Phase 3: Configuration Integration - **COMPLETED** +- [x] Phase 5 Part B: Runtime Integration - **COMPLETED** +- [x] Phase 5 Part C: Documentation Enhancement - **COMPLETED** +- [x] Phase 5 Deferred Items: Category mapping, RuleSelector, Supabase roles - **COMPLETED** +- [ ] Phase 4: Rename to pgls_linter - **PLANNED** + +### Summary +**✅ Integration Complete (Phases 1-5):** +- Generic framework (`pgls_analyse`) successfully extracted +- Splinter rules generated from SQL files with metadata +- Configuration system mirrors linter structure +- All 21 splinter rules (7 performance + 14 security) properly configured +- Dynamic SQL query building with configuration-based filtering +- Hardcoded category mapping replaced with generated functions +- RuleSelector supports both linter and splinter prefixes +- Automatic Supabase role detection via schema cache +- Comprehensive documentation generated with SQL queries and examples +- Full workspace compiles successfully + +**📋 Remaining Work:** +- Phase 4: Crate rename `pgls_analyser` → `pgls_linter` (planned) ### Open Questions None currently @@ -513,12 +648,260 @@ None currently --- ## Testing Strategy -- [ ] Existing linter tests continue to pass -- [ ] Splinter rules generate correctly from SQL -- [ ] Configuration schema validates -- [ ] Integration test: enable/disable rules via config -- [ ] Integration test: severity overrides work +- [x] Full workspace compiles successfully +- [x] Splinter rules generate correctly from SQL +- [x] Configuration generation runs without errors +- [ ] Existing linter tests continue to pass (not verified) +- [ ] Configuration schema validates (not verified) +- [ ] Integration test: enable/disable rules via config (requires runtime integration) +- [ ] Integration test: severity overrides work (requires runtime integration) + +**Status:** Basic compilation testing complete. Full integration testing deferred to when runtime integration is implemented. + +--- + +Last updated: 2025-12-15 + +## Phase 5 Part B Implementation Notes + +**Date Completed:** 2025-12-15 + +**Changes Made:** + +1. **File: `crates/pgls_splinter/src/lib.rs`** + - Added `SplinterRuleCollector` struct implementing `RegistryVisitor` + - Updated `run_splinter()` to accept `AnalysisFilter` parameter + - Implemented dynamic SQL query building from enabled rules + - Removed dependency on hardcoded `load_generic/load_supabase` functions + +2. **File: `crates/pgls_splinter/src/query.rs`** + - Added manual `FromRow` implementation for `SplinterQueryResult` + - Removed `load_generic_splinter_results()` function + - Removed `load_supabase_splinter_results()` function + - Added note explaining the removal + +3. **File: `crates/pgls_splinter/tests/diagnostics.rs`** + - Updated all test call sites to pass `AnalysisFilter::default()` + - Added import for `pgls_analyse::AnalysisFilter` + +**Testing:** +- Full workspace compiles successfully: ✅ +- `cargo check -p pgls_splinter` passes with only generated code warnings: ✅ +- No functional regressions expected (behavior is equivalent but more efficient) + +**Migration Notes for Users:** +- Any code calling `run_splinter()` must now pass an `AnalysisFilter` +- For "run all rules" behavior, use `AnalysisFilter::default()` +- Tests updated to demonstrate correct usage + +--- + +## Phase 5 Deferred Items - Implementation Notes + +**Date Completed:** 2025-12-15 + +### 1. Category Mapping Removal + +**Problem:** `convert.rs` contained a 120-line hardcoded `rule_name_to_category()` function mapping rule names to categories. + +**Solution:** +- Extended codegen to generate `get_rule_category()` function in `registry.rs` +- Maps snake_case SQL result names (e.g., "unindexed_foreign_keys") to static Category references +- Automatically stays in sync with SQL file metadata + +**Files Changed:** +- `xtask/codegen/src/generate_splinter.rs` - Added category lookup generation +- `crates/pgls_splinter/src/registry.rs` - Generated function (auto-generated) +- `crates/pgls_splinter/src/convert.rs` - Replaced match statement with single function call + +**Example:** +```rust +// Before: 120 lines of match statements +fn rule_name_to_category(name: &str, group: &str) -> &'static Category { + match (group, name) { + ("performance", "unindexed_foreign_keys") => category!("splinter/performance/unindexedForeignKeys"), + // ... 60+ more lines + } +} + +// After: Single function call +let category = crate::registry::get_rule_category(&result.name) + .expect("Rule name should map to a valid category"); +``` + +--- + +### 2. RuleSelector Multi-Analyzer Support + +**Problem:** `RuleSelector` enum only supported linter rules (groups: Safety). + +**Solution:** +- Split enum variants into analyzer-specific types: + - `LinterGroup` / `LinterRule` for linter rules + - `SplinterGroup` / `SplinterRule` for splinter rules +- Added prefix-based parsing (`lint/`, `splinter/`) +- Maintained backward compatibility (unprefixed selectors try linter first) + +**Files Changed:** +- `crates/pgls_configuration/src/analyser/mod.rs` - Updated RuleSelector enum and parsing + +**Example Configuration:** +```json +{ + "linter": { + "ignore": ["lint/safety/banDropTable"] // Linter rule with prefix + }, + "overrides": [ + { + "ignore": [ + "splinter/security/authUsersExposed", // Splinter rule with prefix + "multipleAlterTable" // Linter rule (backward compatible) + ] + } + ] +} +``` --- -Last updated: 2025-12-14 +### 3. Supabase Role Checking + +**Problem:** Supabase-specific rules (9 out of 21) should automatically be skipped on non-Supabase databases without requiring configuration changes. + +**Solution:** +- Added `-- meta: requires_supabase = true` to 9 SQL files +- Generated `rule_requires_supabase()` function in `registry.rs` +- Updated `SplinterParams` to accept optional `SchemaCache` +- Implemented in-memory role checking (looks for `anon`, `authenticated`, `service_role` roles) +- Filters rules before building SQL query (performance optimization) + +**Files Changed:** +- 9 SQL files in `crates/pgls_splinter/vendor/` - Added metadata +- `xtask/codegen/src/generate_splinter.rs` - Extract and generate metadata +- `crates/pgls_splinter/src/lib.rs` - Added role checking logic +- `crates/pgls_splinter/Cargo.toml` - Added `pgls_schema_cache` dependency + +**Example:** +```rust +// Check if Supabase roles exist +let has_supabase_roles = params.schema_cache.map_or(false, |cache| { + let required_roles = ["anon", "authenticated", "service_role"]; + required_roles.iter().all(|role_name| { + cache.roles.iter().any(|role| role.name.as_str() == *role_name) + }) +}); + +// Skip Supabase-specific rules if roles don't exist +for rule_name in &collector.enabled_rules { + if !has_supabase_roles && crate::registry::rule_requires_supabase(rule_name) { + continue; // Automatically skipped - zero config needed! + } + // ... load and execute SQL +} +``` + +**Supabase-Specific Rules:** +1. `authRlsInitplan` (performance) +2. `authUsersExposed` (security) +3. `fkeyToAuthUnique` (security) +4. `foreignTableInApi` (security) +5. `insecureQueueExposedInApi` (security) +6. `materializedViewInApi` (security) +7. `rlsDisabledInPublic` (security) +8. `rlsReferencesUserMetadata` (security) +9. `securityDefinerView` (security) + +--- + +## Phase 5 Part C - Implementation Notes + +**Date Completed:** 2025-12-15 + +### Documentation Enhancement + +**Goal:** Generate comprehensive documentation for all splinter rules, including SQL queries, configuration examples, and remediation links. + +**Implementation:** + +1. **Extended `SqlRuleMetadata` struct:** + - Added `sql_query: String` field + - Added `requires_supabase: bool` field + - Extracts SQL content after metadata comment headers + - Preserves formatting and removes metadata lines + +2. **Generated comprehensive doc strings:** + - Built using `format!` macro with multiple sections + - Includes title, description, Supabase warning, SQL query, configuration example, and remediation link + - Each line prefixed with `/// ` for Rust doc comments + - SQL query wrapped in triple-backtick code fence + +3. **Documentation Sections:** + - **Title and Description**: From SQL metadata + - **Supabase Note** (conditional): Warns about role requirements + - **SQL Query**: Full query in code fence with syntax highlighting + - **Configuration**: JSON example showing how to enable/disable + - **Remediation**: Link to documentation or custom guidance + +**Files Changed:** +- `xtask/codegen/src/generate_splinter.rs` - Added doc string generation +- All 21 rule files in `crates/pgls_splinter/src/rules/` - Regenerated with rich docs + +**Example Output:** +```rust +/// # Unindexed foreign keys +/// +/// Identifies foreign key constraints without a covering index, which can impact database performance. +/// +/// ## SQL Query +/// +/// ```sql +/// with foreign_keys as ( +/// select +/// cl.relnamespace::regnamespace::text as schema_name, +/// cl.relname as table_name, +/// ... +/// ) +/// select * from foreign_keys where ... +/// ``` +/// +/// ## Configuration +/// +/// Enable or disable this rule in your configuration: +/// +/// ```json +/// { +/// "splinter": { +/// "rules": { +/// "performance": { +/// "unindexedForeignKeys": "warn" +/// } +/// } +/// } +/// } +/// ``` +/// +/// ## Remediation +/// +/// See: +pub struct UnindexedForeignKeys { ... } +``` + +**Benefits:** +- ✅ Developers can view SQL queries directly in IDE via hover/goto-definition +- ✅ `cargo doc` generates comprehensive documentation +- ✅ Easy to understand what each rule checks without reading SQL files +- ✅ Configuration examples reduce setup friction +- ✅ Remediation links provide actionable next steps + +--- + +**Overall Phase 5 Status:** ✅ FULLY COMPLETED + +All planned work and deferred items have been successfully implemented: +- Dynamic SQL query building with configuration filtering +- Hardcoded category mapping replaced with generated functions +- Multi-analyzer RuleSelector support +- Automatic Supabase role detection +- Comprehensive documentation generation + +Full workspace compiles successfully with no errors. diff --git a/crates/pgls_configuration/src/rules/configuration.rs b/crates/pgls_configuration/src/rules/configuration.rs index 6b86fef69..625d6e3fb 100644 --- a/crates/pgls_configuration/src/rules/configuration.rs +++ b/crates/pgls_configuration/src/rules/configuration.rs @@ -1,3 +1,7 @@ +pub mod linter; +pub mod splinter; + +pub use crate::analyser::linter::*; use biome_deserialize::Merge; use biome_deserialize_macros::Deserializable; use pgls_analyser::RuleOptions; @@ -5,6 +9,7 @@ use pgls_diagnostics::Severity; #[cfg(feature = "schema")] use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use std::str::FromStr; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[cfg_attr(feature = "schema", derive(JsonSchema))] @@ -295,3 +300,127 @@ impl Merge for RuleWithFixOptions { self.options = other.options; } } + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum RuleSelector { + LinterGroup(linter::RuleGroup), + LinterRule(linter::RuleGroup, &'static str), + SplinterGroup(splinter::RuleGroup), + SplinterRule(splinter::RuleGroup, &'static str), +} + +impl From for RuleFilter<'static> { + fn from(value: RuleSelector) -> Self { + match value { + RuleSelector::LinterGroup(group) => RuleFilter::Group(group.as_str()), + RuleSelector::LinterRule(group, name) => RuleFilter::Rule(group.as_str(), name), + RuleSelector::SplinterGroup(group) => RuleFilter::Group(group.as_str()), + RuleSelector::SplinterRule(group, name) => RuleFilter::Rule(group.as_str(), name), + } + } +} + +impl<'a> From<&'a RuleSelector> for RuleFilter<'static> { + fn from(value: &'a RuleSelector) -> Self { + match value { + RuleSelector::LinterGroup(group) => RuleFilter::Group(group.as_str()), + RuleSelector::LinterRule(group, name) => RuleFilter::Rule(group.as_str(), name), + RuleSelector::SplinterGroup(group) => RuleFilter::Group(group.as_str()), + RuleSelector::SplinterRule(group, name) => RuleFilter::Rule(group.as_str(), name), + } + } +} + +impl FromStr for RuleSelector { + type Err = &'static str; + fn from_str(selector: &str) -> Result { + // Check for explicit prefixes + if let Some(linter_selector) = selector.strip_prefix("lint/") { + return parse_linter_selector(linter_selector); + } + if let Some(splinter_selector) = selector.strip_prefix("splinter/") { + return parse_splinter_selector(splinter_selector); + } + + // No prefix: try linter first (for backward compatibility), then splinter + parse_linter_selector(selector) + .or_else(|_| parse_splinter_selector(selector)) + .map_err(|_| "This rule or group doesn't exist in linter or splinter.") + } +} + +fn parse_linter_selector(selector: &str) -> Result { + if let Some((group_name, rule_name)) = selector.split_once('/') { + let group = linter::RuleGroup::from_str(group_name)?; + if let Some(rule_name) = linter::Rules::has_rule(group, rule_name) { + Ok(RuleSelector::LinterRule(group, rule_name)) + } else { + Err("This linter rule doesn't exist.") + } + } else { + let group = linter::RuleGroup::from_str(selector)?; + Ok(RuleSelector::LinterGroup(group)) + } +} + +fn parse_splinter_selector(selector: &str) -> Result { + if let Some((group_name, rule_name)) = selector.split_once('/') { + let group = splinter::RuleGroup::from_str(group_name)?; + if let Some(rule_name) = splinter::Rules::has_rule(group, rule_name) { + Ok(RuleSelector::SplinterRule(group, rule_name)) + } else { + Err("This splinter rule doesn't exist.") + } + } else { + let group = splinter::RuleGroup::from_str(selector)?; + Ok(RuleSelector::SplinterGroup(group)) + } +} + +impl serde::Serialize for RuleSelector { + fn serialize(&self, serializer: S) -> Result { + match self { + RuleSelector::LinterGroup(group) => serializer.serialize_str(group.as_str()), + RuleSelector::LinterRule(group, rule_name) => { + let group_name = group.as_str(); + serializer.serialize_str(&format!("{group_name}/{rule_name}")) + } + RuleSelector::SplinterGroup(group) => { + serializer.serialize_str(&format!("splinter/{}", group.as_str())) + } + RuleSelector::SplinterRule(group, rule_name) => { + let group_name = group.as_str(); + serializer.serialize_str(&format!("splinter/{group_name}/{rule_name}")) + } + } + } +} + +impl<'de> serde::Deserialize<'de> for RuleSelector { + fn deserialize>(deserializer: D) -> Result { + struct Visitor; + impl serde::de::Visitor<'_> for Visitor { + type Value = RuleSelector; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("/") + } + fn visit_str(self, v: &str) -> Result { + match RuleSelector::from_str(v) { + Ok(result) => Ok(result), + Err(error) => Err(serde::de::Error::custom(error)), + } + } + } + deserializer.deserialize_str(Visitor) + } +} + +#[cfg(feature = "schema")] +impl schemars::JsonSchema for RuleSelector { + fn schema_name() -> String { + "RuleCode".to_string() + } + fn json_schema(r#gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { + String::json_schema(r#gen) + } +} diff --git a/crates/pgls_splinter/Cargo.toml b/crates/pgls_splinter/Cargo.toml index 4455d4f6e..f6554aaf2 100644 --- a/crates/pgls_splinter/Cargo.toml +++ b/crates/pgls_splinter/Cargo.toml @@ -11,11 +11,12 @@ repository.workspace = true version = "0.0.0" [dependencies] -pgls_analyse.workspace = true -pgls_diagnostics.workspace = true -serde.workspace = true -serde_json.workspace = true -sqlx.workspace = true +pgls_analyse.workspace = true +pgls_diagnostics.workspace = true +pgls_schema_cache.workspace = true +serde.workspace = true +serde_json.workspace = true +sqlx.workspace = true [build-dependencies] ureq = "2.10" diff --git a/crates/pgls_splinter/src/lib.rs b/crates/pgls_splinter/src/lib.rs index a42f39439..6b0c32573 100644 --- a/crates/pgls_splinter/src/lib.rs +++ b/crates/pgls_splinter/src/lib.rs @@ -5,6 +5,8 @@ pub mod registry; pub mod rule; pub mod rules; +use pgls_analyse::{AnalysisFilter, RegistryVisitor, RuleMeta}; +use pgls_schema_cache::SchemaCache; use sqlx::PgPool; pub use diagnostics::{SplinterAdvices, SplinterDiagnostic}; @@ -14,41 +16,103 @@ pub use rule::SplinterRule; #[derive(Debug)] pub struct SplinterParams<'a> { pub conn: &'a PgPool, + pub schema_cache: Option<&'a SchemaCache>, } -async fn check_supabase_roles(conn: &PgPool) -> Result { - let required_roles = ["anon", "authenticated", "service_role"]; +/// Visitor that collects enabled splinter rules based on filter +struct SplinterRuleCollector<'a> { + filter: &'a AnalysisFilter<'a>, + enabled_rules: Vec, // rule names in camelCase +} - let existing_roles: Vec = - sqlx::query_scalar("SELECT rolname FROM pg_roles WHERE rolname = ANY($1)") - .bind(&required_roles[..]) - .fetch_all(conn) - .await?; +impl<'a> RegistryVisitor for SplinterRuleCollector<'a> { + fn record_category(&mut self) { + if self.filter.match_category::() { + C::record_groups(self); + } + } - // Check if all required roles exist - let all_exist = required_roles - .iter() - .all(|role| existing_roles.contains(&(*role).to_string())); + fn record_group(&mut self) { + if self.filter.match_group::() { + G::record_rules(self); + } + } - Ok(all_exist) + fn record_rule(&mut self) { + if self.filter.match_rule::() { + self.enabled_rules.push(R::METADATA.name.to_string()); + } + } } pub async fn run_splinter( params: SplinterParams<'_>, + filter: &AnalysisFilter<'_>, ) -> Result, sqlx::Error> { - let mut all_results = Vec::new(); + // Use visitor pattern to collect enabled rules + let mut collector = SplinterRuleCollector { + filter, + enabled_rules: Vec::new(), + }; + crate::registry::visit_registry(&mut collector); + + // If no rules are enabled, return early + if collector.enabled_rules.is_empty() { + return Ok(Vec::new()); + } + + // Check if Supabase roles exist (anon, authenticated, service_role) + let has_supabase_roles = params.schema_cache.map_or(false, |cache| { + let required_roles = ["anon", "authenticated", "service_role"]; + required_roles.iter().all(|role_name| { + cache + .roles + .iter() + .any(|role| role.name.as_str() == *role_name) + }) + }); + + // Build dynamic SQL query from enabled rules + // Filter out Supabase-specific rules if Supabase roles don't exist + // SQL content is embedded at compile time using include_str! for performance + let mut sql_queries = Vec::new(); - let generic_results = query::load_generic_splinter_results(params.conn).await?; - all_results.extend(generic_results); + for rule_name in &collector.enabled_rules { + // Skip Supabase-specific rules if Supabase roles don't exist + if !has_supabase_roles && crate::registry::rule_requires_supabase(rule_name) { + continue; + } - // Only run Supabase-specific rules if the required roles exist - let has_supabase_roles = check_supabase_roles(params.conn).await?; - if has_supabase_roles { - let supabase_results = query::load_supabase_splinter_results(params.conn).await?; - all_results.extend(supabase_results); + // Get embedded SQL content (compile-time included) + if let Some(sql) = crate::registry::get_sql_content(rule_name) { + sql_queries.push(sql); + } } - let diagnostics: Vec = all_results.into_iter().map(Into::into).collect(); + // If no SQL files could be read, return early + if sql_queries.is_empty() { + return Ok(Vec::new()); + } + + // Combine SQL queries with UNION ALL + let combined_sql = sql_queries.join("\n\nUNION ALL\n\n"); + + // Execute the combined query + let mut tx = params.conn.begin().await?; + + // Set search path as done in the original implementation + sqlx::query("set local search_path = ''") + .execute(&mut *tx) + .await?; + + let results = sqlx::query_as::<_, SplinterQueryResult>(&combined_sql) + .fetch_all(&mut *tx) + .await?; + + tx.commit().await?; + + // Convert results to diagnostics + let diagnostics: Vec = results.into_iter().map(Into::into).collect(); Ok(diagnostics) } diff --git a/crates/pgls_splinter/src/query.rs b/crates/pgls_splinter/src/query.rs index 4c7a7ffa4..b01ce2ee6 100644 --- a/crates/pgls_splinter/src/query.rs +++ b/crates/pgls_splinter/src/query.rs @@ -1,5 +1,5 @@ use serde_json::Value; -use sqlx::PgPool; +use sqlx::{PgPool, Row}; /// Raw query result from the Splinter SQL query. /// This struct represents a single linting issue found in the database. @@ -38,42 +38,20 @@ pub struct SplinterQueryResult { pub cache_key: String, } -pub async fn load_generic_splinter_results( - pool: &PgPool, -) -> Result, sqlx::Error> { - let mut tx = pool.begin().await?; - - // this is done by the splinter.sql file normally, but we remove it so that sqlx can work with - // the file properly. - sqlx::query("set local search_path = ''") - .execute(&mut *tx) - .await?; - - let results = sqlx::query_file_as!(SplinterQueryResult, "vendor/splinter_generic.sql") - .fetch_all(&mut *tx) - .await?; - - tx.commit().await?; - - Ok(results) -} - -pub async fn load_supabase_splinter_results( - pool: &PgPool, -) -> Result, sqlx::Error> { - let mut tx = pool.begin().await?; - - // this is done by the splinter.sql file normally, but we remove it so that sqlx can work with - // the file properly. - sqlx::query("set local search_path = ''") - .execute(&mut *tx) - .await?; - - let results = sqlx::query_file_as!(SplinterQueryResult, "vendor/splinter_supabase.sql") - .fetch_all(&mut *tx) - .await?; - - tx.commit().await?; - - Ok(results) +// Implement FromRow manually since we're using dynamic SQL +impl<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow> for SplinterQueryResult { + fn from_row(row: &'r sqlx::postgres::PgRow) -> Result { + Ok(SplinterQueryResult { + name: row.try_get("name")?, + title: row.try_get("title")?, + level: row.try_get("level")?, + facing: row.try_get("facing")?, + categories: row.try_get("categories")?, + description: row.try_get("description")?, + detail: row.try_get("detail")?, + remediation: row.try_get("remediation")?, + metadata: row.try_get("metadata")?, + cache_key: row.try_get("cache_key")?, + }) + } } diff --git a/crates/pgls_splinter/src/registry.rs b/crates/pgls_splinter/src/registry.rs index 911b22891..aa84f4e2a 100644 --- a/crates/pgls_splinter/src/registry.rs +++ b/crates/pgls_splinter/src/registry.rs @@ -9,6 +9,7 @@ pub fn visit_registry(registry: &mut V) { } #[doc = r" Map rule name (camelCase) to SQL file path"] #[doc = r" Returns None if rule not found"] +#[deprecated(note = "Use get_sql_content() instead - SQL is now embedded at compile time")] pub fn get_sql_file_path(rule_name: &str) -> Option<&'static str> { match rule_name { "authRlsInitplan" => Some("vendor/performance/auth_rls_initplan.sql"), @@ -35,3 +36,218 @@ pub fn get_sql_file_path(rule_name: &str) -> Option<&'static str> { _ => None, } } +#[doc = r" Get embedded SQL content for a rule (camelCase name)"] +#[doc = r" Returns None if rule not found"] +#[doc = r""] +#[doc = r" SQL files are embedded at compile time using include_str! for performance"] +#[doc = r" and to make the binary self-contained."] +pub fn get_sql_content(rule_name: &str) -> Option<&'static str> { + match rule_name { + "authRlsInitplan" => Some(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/", + "vendor/performance/auth_rls_initplan.sql" + ))), + "authUsersExposed" => Some(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/", + "vendor/security/auth_users_exposed.sql" + ))), + "duplicateIndex" => Some(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/", + "vendor/performance/duplicate_index.sql" + ))), + "extensionInPublic" => Some(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/", + "vendor/security/extension_in_public.sql" + ))), + "extensionVersionsOutdated" => Some(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/", + "vendor/security/extension_versions_outdated.sql" + ))), + "fkeyToAuthUnique" => Some(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/", + "vendor/security/fkey_to_auth_unique.sql" + ))), + "foreignTableInApi" => Some(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/", + "vendor/security/foreign_table_in_api.sql" + ))), + "functionSearchPathMutable" => Some(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/", + "vendor/security/function_search_path_mutable.sql" + ))), + "insecureQueueExposedInApi" => Some(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/", + "vendor/security/insecure_queue_exposed_in_api.sql" + ))), + "materializedViewInApi" => Some(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/", + "vendor/security/materialized_view_in_api.sql" + ))), + "multiplePermissivePolicies" => Some(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/", + "vendor/performance/multiple_permissive_policies.sql" + ))), + "noPrimaryKey" => Some(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/", + "vendor/performance/no_primary_key.sql" + ))), + "policyExistsRlsDisabled" => Some(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/", + "vendor/security/policy_exists_rls_disabled.sql" + ))), + "rlsDisabledInPublic" => Some(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/", + "vendor/security/rls_disabled_in_public.sql" + ))), + "rlsEnabledNoPolicy" => Some(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/", + "vendor/security/rls_enabled_no_policy.sql" + ))), + "rlsReferencesUserMetadata" => Some(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/", + "vendor/security/rls_references_user_metadata.sql" + ))), + "securityDefinerView" => Some(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/", + "vendor/security/security_definer_view.sql" + ))), + "tableBloat" => Some(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/", + "vendor/performance/table_bloat.sql" + ))), + "unindexedForeignKeys" => Some(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/", + "vendor/performance/unindexed_foreign_keys.sql" + ))), + "unsupportedRegTypes" => Some(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/", + "vendor/security/unsupported_reg_types.sql" + ))), + "unusedIndex" => Some(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/", + "vendor/performance/unused_index.sql" + ))), + _ => None, + } +} +#[doc = r" Map rule name from SQL result (snake_case) to diagnostic category"] +#[doc = r" Returns None if rule not found"] +#[doc = r""] +#[doc = r" This replaces the hardcoded match in convert.rs"] +pub fn get_rule_category(rule_name: &str) -> Option<&'static ::pgls_diagnostics::Category> { + match rule_name { + "auth_rls_initplan" => Some(::pgls_diagnostics::category!( + "splinter/performance/authRlsInitplan" + )), + "auth_users_exposed" => Some(::pgls_diagnostics::category!( + "splinter/security/authUsersExposed" + )), + "duplicate_index" => Some(::pgls_diagnostics::category!( + "splinter/performance/duplicateIndex" + )), + "extension_in_public" => Some(::pgls_diagnostics::category!( + "splinter/security/extensionInPublic" + )), + "extension_versions_outdated" => Some(::pgls_diagnostics::category!( + "splinter/security/extensionVersionsOutdated" + )), + "fkey_to_auth_unique" => Some(::pgls_diagnostics::category!( + "splinter/security/fkeyToAuthUnique" + )), + "foreign_table_in_api" => Some(::pgls_diagnostics::category!( + "splinter/security/foreignTableInApi" + )), + "function_search_path_mutable" => Some(::pgls_diagnostics::category!( + "splinter/security/functionSearchPathMutable" + )), + "insecure_queue_exposed_in_api" => Some(::pgls_diagnostics::category!( + "splinter/security/insecureQueueExposedInApi" + )), + "materialized_view_in_api" => Some(::pgls_diagnostics::category!( + "splinter/security/materializedViewInApi" + )), + "multiple_permissive_policies" => Some(::pgls_diagnostics::category!( + "splinter/performance/multiplePermissivePolicies" + )), + "no_primary_key" => Some(::pgls_diagnostics::category!( + "splinter/performance/noPrimaryKey" + )), + "policy_exists_rls_disabled" => Some(::pgls_diagnostics::category!( + "splinter/security/policyExistsRlsDisabled" + )), + "rls_disabled_in_public" => Some(::pgls_diagnostics::category!( + "splinter/security/rlsDisabledInPublic" + )), + "rls_enabled_no_policy" => Some(::pgls_diagnostics::category!( + "splinter/security/rlsEnabledNoPolicy" + )), + "rls_references_user_metadata" => Some(::pgls_diagnostics::category!( + "splinter/security/rlsReferencesUserMetadata" + )), + "security_definer_view" => Some(::pgls_diagnostics::category!( + "splinter/security/securityDefinerView" + )), + "table_bloat" => Some(::pgls_diagnostics::category!( + "splinter/performance/tableBloat" + )), + "unindexed_foreign_keys" => Some(::pgls_diagnostics::category!( + "splinter/performance/unindexedForeignKeys" + )), + "unsupported_reg_types" => Some(::pgls_diagnostics::category!( + "splinter/security/unsupportedRegTypes" + )), + "unused_index" => Some(::pgls_diagnostics::category!( + "splinter/performance/unusedIndex" + )), + _ => None, + } +} +#[doc = r" Check if a rule requires Supabase roles (anon, authenticated, service_role)"] +#[doc = r" Rules that require Supabase should be filtered out if these roles don't exist"] +pub fn rule_requires_supabase(rule_name: &str) -> bool { + match rule_name { + "authRlsInitplan" => true, + "authUsersExposed" => true, + "duplicateIndex" => false, + "extensionInPublic" => false, + "extensionVersionsOutdated" => false, + "fkeyToAuthUnique" => true, + "foreignTableInApi" => true, + "functionSearchPathMutable" => false, + "insecureQueueExposedInApi" => true, + "materializedViewInApi" => true, + "multiplePermissivePolicies" => false, + "noPrimaryKey" => false, + "policyExistsRlsDisabled" => false, + "rlsDisabledInPublic" => true, + "rlsEnabledNoPolicy" => false, + "rlsReferencesUserMetadata" => true, + "securityDefinerView" => true, + "tableBloat" => false, + "unindexedForeignKeys" => false, + "unsupportedRegTypes" => false, + "unusedIndex" => false, + _ => false, + } +} diff --git a/crates/pgls_splinter/src/rules/performance/auth_rls_initplan.rs b/crates/pgls_splinter/src/rules/performance/auth_rls_initplan.rs index 6b733217c..463304b69 100644 --- a/crates/pgls_splinter/src/rules/performance/auth_rls_initplan.rs +++ b/crates/pgls_splinter/src/rules/performance/auth_rls_initplan.rs @@ -2,7 +2,7 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub AuthRlsInitplan { version : "1.0.0" , name : "authRlsInitplan" , severity : pgls_diagnostics :: Severity :: Warning , } } +::pgls_analyse::declare_rule! { # [doc = "/// # Auth RLS Initialization Plan\n///\n/// Detects if calls to \\`current_setting()\\` and \\`auth.()\\` in RLS policies are being unnecessarily re-evaluated for each row\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// with policies as (\n/// select\n/// nsp.nspname as schema_name,\n/// pb.tablename as table_name,\n/// pc.relrowsecurity as is_rls_active,\n/// polname as policy_name,\n/// polpermissive as is_permissive, -- if not, then restrictive\n/// (select array_agg(r::regrole) from unnest(polroles) as x(r)) as roles,\n/// case polcmd\n/// when 'r' then 'SELECT'\n/// when 'a' then 'INSERT'\n/// when 'w' then 'UPDATE'\n/// when 'd' then 'DELETE'\n/// when '*' then 'ALL'\n/// end as command,\n/// qual,\n/// with_check\n/// from\n/// pg_catalog.pg_policy pa\n/// join pg_catalog.pg_class pc\n/// on pa.polrelid = pc.oid\n/// join pg_catalog.pg_namespace nsp\n/// on pc.relnamespace = nsp.oid\n/// join pg_catalog.pg_policies pb\n/// on pc.relname = pb.tablename\n/// and nsp.nspname = pb.schemaname\n/// and pa.polname = pb.policyname\n/// )\n/// select\n/// 'auth_rls_initplan' as \"name!\",\n/// 'Auth RLS Initialization Plan' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['PERFORMANCE'] as \"categories!\",\n/// 'Detects if calls to \\`current_setting()\\` and \\`auth.()\\` in RLS policies are being unnecessarily re-evaluated for each row' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` has a row level security policy \\`%s\\` that re-evaluates current_setting() or auth.() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing \\`auth.()\\` with \\`(select auth.())\\`. See [docs](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) for more info.',\n/// schema_name,\n/// table_name,\n/// policy_name\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', schema_name,\n/// 'name', table_name,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format('auth_rls_init_plan_%s_%s_%s', schema_name, table_name, policy_name) as \"cache_key!\"\n/// from\n/// policies\n/// where\n/// is_rls_active\n/// -- NOTE: does not include realtime in support of monitoring policies on realtime.messages\n/// and schema_name not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and (\n/// -- Example: auth.uid()\n/// (\n/// qual like '%auth.uid()%'\n/// and lower(qual) not like '%select auth.uid()%'\n/// )\n/// or (\n/// qual like '%auth.jwt()%'\n/// and lower(qual) not like '%select auth.jwt()%'\n/// )\n/// or (\n/// qual like '%auth.role()%'\n/// and lower(qual) not like '%select auth.role()%'\n/// )\n/// or (\n/// qual like '%auth.email()%'\n/// and lower(qual) not like '%select auth.email()%'\n/// )\n/// or (\n/// qual like '%current\\_setting(%)%'\n/// and lower(qual) not like '%select current\\_setting(%)%'\n/// )\n/// or (\n/// with_check like '%auth.uid()%'\n/// and lower(with_check) not like '%select auth.uid()%'\n/// )\n/// or (\n/// with_check like '%auth.jwt()%'\n/// and lower(with_check) not like '%select auth.jwt()%'\n/// )\n/// or (\n/// with_check like '%auth.role()%'\n/// and lower(with_check) not like '%select auth.role()%'\n/// )\n/// or (\n/// with_check like '%auth.email()%'\n/// and lower(with_check) not like '%select auth.email()%'\n/// )\n/// or (\n/// with_check like '%current\\_setting(%)%'\n/// and lower(with_check) not like '%select current\\_setting(%)%'\n/// )\n/// ))\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"performance\": {\n/// \"authRlsInitplan\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub AuthRlsInitplan { version : "1.0.0" , name : "authRlsInitplan" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for AuthRlsInitplan { fn sql_file_path() -> &'static str { "performance/auth_rls_initplan.sql" diff --git a/crates/pgls_splinter/src/rules/performance/duplicate_index.rs b/crates/pgls_splinter/src/rules/performance/duplicate_index.rs index 85077c948..b89661dc4 100644 --- a/crates/pgls_splinter/src/rules/performance/duplicate_index.rs +++ b/crates/pgls_splinter/src/rules/performance/duplicate_index.rs @@ -2,7 +2,7 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub DuplicateIndex { version : "1.0.0" , name : "duplicateIndex" , severity : pgls_diagnostics :: Severity :: Warning , } } +::pgls_analyse::declare_rule! { # [doc = "/// # Duplicate Index\n///\n/// Detects cases where two ore more identical indexes exist.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'duplicate_index' as \"name!\",\n/// 'Duplicate Index' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['PERFORMANCE'] as \"categories!\",\n/// 'Detects cases where two ore more identical indexes exist.' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` has identical indexes %s. Drop all except one of them',\n/// n.nspname,\n/// c.relname,\n/// array_agg(pi.indexname order by pi.indexname)\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0009_duplicate_index' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', case\n/// when c.relkind = 'r' then 'table'\n/// when c.relkind = 'm' then 'materialized view'\n/// else 'ERROR'\n/// end,\n/// 'indexes', array_agg(pi.indexname order by pi.indexname)\n/// ) as \"metadata!\",\n/// format(\n/// 'duplicate_index_%s_%s_%s',\n/// n.nspname,\n/// c.relname,\n/// array_agg(pi.indexname order by pi.indexname)\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_indexes pi\n/// join pg_catalog.pg_namespace n\n/// on n.nspname = pi.schemaname\n/// join pg_catalog.pg_class c\n/// on pi.tablename = c.relname\n/// and n.oid = c.relnamespace\n/// left join pg_catalog.pg_depend dep\n/// on c.oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// c.relkind in ('r', 'm') -- tables and materialized views\n/// and n.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and dep.objid is null -- exclude tables owned by extensions\n/// group by\n/// n.nspname,\n/// c.relkind,\n/// c.relname,\n/// replace(pi.indexdef, pi.indexname, '')\n/// having\n/// count(*) > 1)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"performance\": {\n/// \"duplicateIndex\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub DuplicateIndex { version : "1.0.0" , name : "duplicateIndex" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for DuplicateIndex { fn sql_file_path() -> &'static str { "performance/duplicate_index.sql" diff --git a/crates/pgls_splinter/src/rules/performance/multiple_permissive_policies.rs b/crates/pgls_splinter/src/rules/performance/multiple_permissive_policies.rs index e53653482..15344e31d 100644 --- a/crates/pgls_splinter/src/rules/performance/multiple_permissive_policies.rs +++ b/crates/pgls_splinter/src/rules/performance/multiple_permissive_policies.rs @@ -2,7 +2,7 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub MultiplePermissivePolicies { version : "1.0.0" , name : "multiplePermissivePolicies" , severity : pgls_diagnostics :: Severity :: Warning , } } +::pgls_analyse::declare_rule! { # [doc = "/// # Multiple Permissive Policies\n///\n/// Detects if multiple permissive row level security policies are present on a table for the same \\`role\\` and \\`action\\` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'multiple_permissive_policies' as \"name!\",\n/// 'Multiple Permissive Policies' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['PERFORMANCE'] as \"categories!\",\n/// 'Detects if multiple permissive row level security policies are present on a table for the same \\`role\\` and \\`action\\` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` has multiple permissive policies for role \\`%s\\` for action \\`%s\\`. Policies include \\`%s\\`',\n/// n.nspname,\n/// c.relname,\n/// r.rolname,\n/// act.cmd,\n/// array_agg(p.polname order by p.polname)\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format(\n/// 'multiple_permissive_policies_%s_%s_%s_%s',\n/// n.nspname,\n/// c.relname,\n/// r.rolname,\n/// act.cmd\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_policy p\n/// join pg_catalog.pg_class c\n/// on p.polrelid = c.oid\n/// join pg_catalog.pg_namespace n\n/// on c.relnamespace = n.oid\n/// join pg_catalog.pg_roles r\n/// on p.polroles @> array[r.oid]\n/// or p.polroles = array[0::oid]\n/// left join pg_catalog.pg_depend dep\n/// on c.oid = dep.objid\n/// and dep.deptype = 'e',\n/// lateral (\n/// select x.cmd\n/// from unnest((\n/// select\n/// case p.polcmd\n/// when 'r' then array['SELECT']\n/// when 'a' then array['INSERT']\n/// when 'w' then array['UPDATE']\n/// when 'd' then array['DELETE']\n/// when '*' then array['SELECT', 'INSERT', 'UPDATE', 'DELETE']\n/// else array['ERROR']\n/// end as actions\n/// )) x(cmd)\n/// ) act(cmd)\n/// where\n/// c.relkind = 'r' -- regular tables\n/// and p.polpermissive -- policy is permissive\n/// and n.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and r.rolname not like 'pg_%'\n/// and r.rolname not like 'supabase%admin'\n/// and not r.rolbypassrls\n/// and dep.objid is null -- exclude tables owned by extensions\n/// group by\n/// n.nspname,\n/// c.relname,\n/// r.rolname,\n/// act.cmd\n/// having\n/// count(1) > 1)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"performance\": {\n/// \"multiplePermissivePolicies\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub MultiplePermissivePolicies { version : "1.0.0" , name : "multiplePermissivePolicies" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for MultiplePermissivePolicies { fn sql_file_path() -> &'static str { "performance/multiple_permissive_policies.sql" diff --git a/crates/pgls_splinter/src/rules/performance/no_primary_key.rs b/crates/pgls_splinter/src/rules/performance/no_primary_key.rs index 584aa0474..97c6e30d8 100644 --- a/crates/pgls_splinter/src/rules/performance/no_primary_key.rs +++ b/crates/pgls_splinter/src/rules/performance/no_primary_key.rs @@ -2,7 +2,7 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub NoPrimaryKey { version : "1.0.0" , name : "noPrimaryKey" , severity : pgls_diagnostics :: Severity :: Information , } } +::pgls_analyse::declare_rule! { # [doc = "/// # No Primary Key\n///\n/// Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'no_primary_key' as \"name!\",\n/// 'No Primary Key' as \"title!\",\n/// 'INFO' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['PERFORMANCE'] as \"categories!\",\n/// 'Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale.' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` does not have a primary key',\n/// pgns.nspname,\n/// pgc.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0004_no_primary_key' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', pgns.nspname,\n/// 'name', pgc.relname,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format(\n/// 'no_primary_key_%s_%s',\n/// pgns.nspname,\n/// pgc.relname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_class pgc\n/// join pg_catalog.pg_namespace pgns\n/// on pgns.oid = pgc.relnamespace\n/// left join pg_catalog.pg_index pgi\n/// on pgi.indrelid = pgc.oid\n/// left join pg_catalog.pg_depend dep\n/// on pgc.oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// pgc.relkind = 'r' -- regular tables\n/// and pgns.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and dep.objid is null -- exclude tables owned by extensions\n/// group by\n/// pgc.oid,\n/// pgns.nspname,\n/// pgc.relname\n/// having\n/// max(coalesce(pgi.indisprimary, false)::int) = 0)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"performance\": {\n/// \"noPrimaryKey\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub NoPrimaryKey { version : "1.0.0" , name : "noPrimaryKey" , severity : pgls_diagnostics :: Severity :: Information , } } impl SplinterRule for NoPrimaryKey { fn sql_file_path() -> &'static str { "performance/no_primary_key.sql" diff --git a/crates/pgls_splinter/src/rules/performance/table_bloat.rs b/crates/pgls_splinter/src/rules/performance/table_bloat.rs index 734cb8acd..6c30e0483 100644 --- a/crates/pgls_splinter/src/rules/performance/table_bloat.rs +++ b/crates/pgls_splinter/src/rules/performance/table_bloat.rs @@ -2,7 +2,7 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub TableBloat { version : "1.0.0" , name : "tableBloat" , severity : pgls_diagnostics :: Severity :: Information , } } +::pgls_analyse::declare_rule! { # [doc = "/// # Table Bloat\n///\n/// Detects if a table has excess bloat and may benefit from maintenance operations like vacuum full or cluster.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// with constants as (\n/// select current_setting('block_size')::numeric as bs, 23 as hdr, 4 as ma\n/// ),\n/// \n/// bloat_info as (\n/// select\n/// ma,\n/// bs,\n/// schemaname,\n/// tablename,\n/// (datawidth + (hdr + ma - (case when hdr % ma = 0 then ma else hdr % ma end)))::numeric as datahdr,\n/// (maxfracsum * (nullhdr + ma - (case when nullhdr % ma = 0 then ma else nullhdr % ma end))) as nullhdr2\n/// from (\n/// select\n/// schemaname,\n/// tablename,\n/// hdr,\n/// ma,\n/// bs,\n/// sum((1 - null_frac) * avg_width) as datawidth,\n/// max(null_frac) as maxfracsum,\n/// hdr + (\n/// select 1 + count(*) / 8\n/// from pg_stats s2\n/// where\n/// null_frac <> 0\n/// and s2.schemaname = s.schemaname\n/// and s2.tablename = s.tablename\n/// ) as nullhdr\n/// from pg_stats s, constants\n/// group by 1, 2, 3, 4, 5\n/// ) as foo\n/// ),\n/// \n/// table_bloat as (\n/// select\n/// schemaname,\n/// tablename,\n/// cc.relpages,\n/// bs,\n/// ceil((cc.reltuples * ((datahdr + ma -\n/// (case when datahdr % ma = 0 then ma else datahdr % ma end)) + nullhdr2 + 4)) / (bs - 20::float)) as otta\n/// from\n/// bloat_info\n/// join pg_class cc\n/// on cc.relname = bloat_info.tablename\n/// join pg_namespace nn\n/// on cc.relnamespace = nn.oid\n/// and nn.nspname = bloat_info.schemaname\n/// and nn.nspname <> 'information_schema'\n/// where\n/// cc.relkind = 'r'\n/// and cc.relam = (select oid from pg_am where amname = 'heap')\n/// ),\n/// \n/// bloat_data as (\n/// select\n/// 'table' as type,\n/// schemaname,\n/// tablename as object_name,\n/// round(case when otta = 0 then 0.0 else table_bloat.relpages / otta::numeric end, 1) as bloat,\n/// case when relpages < otta then 0 else (bs * (table_bloat.relpages - otta)::bigint)::bigint end as raw_waste\n/// from\n/// table_bloat\n/// )\n/// \n/// select\n/// 'table_bloat' as \"name!\",\n/// 'Table Bloat' as \"title!\",\n/// 'INFO' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['PERFORMANCE'] as \"categories!\",\n/// 'Detects if a table has excess bloat and may benefit from maintenance operations like vacuum full or cluster.' as \"description!\",\n/// format(\n/// 'Table `%s`.`%s` has excessive bloat',\n/// bloat_data.schemaname,\n/// bloat_data.object_name\n/// ) as \"detail!\",\n/// 'Consider running vacuum full (WARNING: incurs downtime) and tweaking autovacuum settings to reduce bloat.' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', bloat_data.schemaname,\n/// 'name', bloat_data.object_name,\n/// 'type', bloat_data.type\n/// ) as \"metadata!\",\n/// format(\n/// 'table_bloat_%s_%s',\n/// bloat_data.schemaname,\n/// bloat_data.object_name\n/// ) as \"cache_key!\"\n/// from\n/// bloat_data\n/// where\n/// bloat > 70.0\n/// and raw_waste > (20 * 1024 * 1024) -- filter for waste > 200 MB\n/// order by\n/// schemaname,\n/// object_name)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"performance\": {\n/// \"tableBloat\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub TableBloat { version : "1.0.0" , name : "tableBloat" , severity : pgls_diagnostics :: Severity :: Information , } } impl SplinterRule for TableBloat { fn sql_file_path() -> &'static str { "performance/table_bloat.sql" diff --git a/crates/pgls_splinter/src/rules/performance/unindexed_foreign_keys.rs b/crates/pgls_splinter/src/rules/performance/unindexed_foreign_keys.rs index aa59e3e09..0afe3636e 100644 --- a/crates/pgls_splinter/src/rules/performance/unindexed_foreign_keys.rs +++ b/crates/pgls_splinter/src/rules/performance/unindexed_foreign_keys.rs @@ -2,7 +2,7 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub UnindexedForeignKeys { version : "1.0.0" , name : "unindexedForeignKeys" , severity : pgls_diagnostics :: Severity :: Information , } } +::pgls_analyse::declare_rule! { # [doc = "/// # Unindexed foreign keys\n///\n/// Identifies foreign key constraints without a covering index, which can impact database performance.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// with foreign_keys as (\n/// select\n/// cl.relnamespace::regnamespace::text as schema_name,\n/// cl.relname as table_name,\n/// cl.oid as table_oid,\n/// ct.conname as fkey_name,\n/// ct.conkey as col_attnums\n/// from\n/// pg_catalog.pg_constraint ct\n/// join pg_catalog.pg_class cl -- fkey owning table\n/// on ct.conrelid = cl.oid\n/// left join pg_catalog.pg_depend d\n/// on d.objid = cl.oid\n/// and d.deptype = 'e'\n/// where\n/// ct.contype = 'f' -- foreign key constraints\n/// and d.objid is null -- exclude tables that are dependencies of extensions\n/// and cl.relnamespace::regnamespace::text not in (\n/// 'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'extensions'\n/// )\n/// ),\n/// index_ as (\n/// select\n/// pi.indrelid as table_oid,\n/// indexrelid::regclass as index_,\n/// string_to_array(indkey::text, ' ')::smallint[] as col_attnums\n/// from\n/// pg_catalog.pg_index pi\n/// where\n/// indisvalid\n/// )\n/// select\n/// 'unindexed_foreign_keys' as \"name!\",\n/// 'Unindexed foreign keys' as \"title!\",\n/// 'INFO' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['PERFORMANCE'] as \"categories!\",\n/// 'Identifies foreign key constraints without a covering index, which can impact database performance.' as \"description!\",\n/// format(\n/// 'Table `%s.%s` has a foreign key `%s` without a covering index. This can lead to suboptimal query performance.',\n/// fk.schema_name,\n/// fk.table_name,\n/// fk.fkey_name\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', fk.schema_name,\n/// 'name', fk.table_name,\n/// 'type', 'table',\n/// 'fkey_name', fk.fkey_name,\n/// 'fkey_columns', fk.col_attnums\n/// ) as \"metadata!\",\n/// format('unindexed_foreign_keys_%s_%s_%s', fk.schema_name, fk.table_name, fk.fkey_name) as \"cache_key!\"\n/// from\n/// foreign_keys fk\n/// left join index_ idx\n/// on fk.table_oid = idx.table_oid\n/// and fk.col_attnums = idx.col_attnums[1:array_length(fk.col_attnums, 1)]\n/// left join pg_catalog.pg_depend dep\n/// on idx.table_oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// idx.index_ is null\n/// and fk.schema_name not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and dep.objid is null -- exclude tables owned by extensions\n/// order by\n/// fk.schema_name,\n/// fk.table_name,\n/// fk.fkey_name\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"performance\": {\n/// \"unindexedForeignKeys\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub UnindexedForeignKeys { version : "1.0.0" , name : "unindexedForeignKeys" , severity : pgls_diagnostics :: Severity :: Information , } } impl SplinterRule for UnindexedForeignKeys { fn sql_file_path() -> &'static str { "performance/unindexed_foreign_keys.sql" diff --git a/crates/pgls_splinter/src/rules/performance/unused_index.rs b/crates/pgls_splinter/src/rules/performance/unused_index.rs index 1563989d0..813bce6d4 100644 --- a/crates/pgls_splinter/src/rules/performance/unused_index.rs +++ b/crates/pgls_splinter/src/rules/performance/unused_index.rs @@ -2,7 +2,7 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub UnusedIndex { version : "1.0.0" , name : "unusedIndex" , severity : pgls_diagnostics :: Severity :: Information , } } +::pgls_analyse::declare_rule! { # [doc = "/// # Unused Index\n///\n/// Detects if an index has never been used and may be a candidate for removal.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'unused_index' as \"name!\",\n/// 'Unused Index' as \"title!\",\n/// 'INFO' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['PERFORMANCE'] as \"categories!\",\n/// 'Detects if an index has never been used and may be a candidate for removal.' as \"description!\",\n/// format(\n/// 'Index \\`%s\\` on table \\`%s.%s\\` has not been used',\n/// psui.indexrelname,\n/// psui.schemaname,\n/// psui.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0005_unused_index' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', psui.schemaname,\n/// 'name', psui.relname,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format(\n/// 'unused_index_%s_%s_%s',\n/// psui.schemaname,\n/// psui.relname,\n/// psui.indexrelname\n/// ) as \"cache_key!\"\n/// \n/// from\n/// pg_catalog.pg_stat_user_indexes psui\n/// join pg_catalog.pg_index pi\n/// on psui.indexrelid = pi.indexrelid\n/// left join pg_catalog.pg_depend dep\n/// on psui.relid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// psui.idx_scan = 0\n/// and not pi.indisunique\n/// and not pi.indisprimary\n/// and dep.objid is null -- exclude tables owned by extensions\n/// and psui.schemaname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// ))\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"performance\": {\n/// \"unusedIndex\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub UnusedIndex { version : "1.0.0" , name : "unusedIndex" , severity : pgls_diagnostics :: Severity :: Information , } } impl SplinterRule for UnusedIndex { fn sql_file_path() -> &'static str { "performance/unused_index.sql" diff --git a/crates/pgls_splinter/src/rules/security/auth_users_exposed.rs b/crates/pgls_splinter/src/rules/security/auth_users_exposed.rs index 25d76b2df..d8cee82d7 100644 --- a/crates/pgls_splinter/src/rules/security/auth_users_exposed.rs +++ b/crates/pgls_splinter/src/rules/security/auth_users_exposed.rs @@ -2,7 +2,7 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub AuthUsersExposed { version : "1.0.0" , name : "authUsersExposed" , severity : pgls_diagnostics :: Severity :: Error , } } +::pgls_analyse::declare_rule! { # [doc = "/// # Exposed Auth Users\n///\n/// Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security.\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'auth_users_exposed' as \"name!\",\n/// 'Exposed Auth Users' as \"title!\",\n/// 'ERROR' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security.' as \"description!\",\n/// format(\n/// 'View/Materialized View \"%s\" in the public schema may expose \\`auth.users\\` data to anon or authenticated roles.',\n/// c.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'view',\n/// 'exposed_to', array_remove(array_agg(DISTINCT case when pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') then 'anon' when pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') then 'authenticated' end), null)\n/// ) as \"metadata!\",\n/// format('auth_users_exposed_%s_%s', n.nspname, c.relname) as \"cache_key!\"\n/// from\n/// -- Identify the oid for auth.users\n/// pg_catalog.pg_class auth_users_pg_class\n/// join pg_catalog.pg_namespace auth_users_pg_namespace\n/// on auth_users_pg_class.relnamespace = auth_users_pg_namespace.oid\n/// and auth_users_pg_class.relname = 'users'\n/// and auth_users_pg_namespace.nspname = 'auth'\n/// -- Depends on auth.users\n/// join pg_catalog.pg_depend d\n/// on d.refobjid = auth_users_pg_class.oid\n/// join pg_catalog.pg_rewrite r\n/// on r.oid = d.objid\n/// join pg_catalog.pg_class c\n/// on c.oid = r.ev_class\n/// join pg_catalog.pg_namespace n\n/// on n.oid = c.relnamespace\n/// join pg_catalog.pg_class pg_class_auth_users\n/// on d.refobjid = pg_class_auth_users.oid\n/// where\n/// d.deptype = 'n'\n/// and (\n/// pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n/// or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n/// )\n/// and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n/// -- Exclude self\n/// and c.relname <> '0002_auth_users_exposed'\n/// -- There are 3 insecure configurations\n/// and\n/// (\n/// -- Materialized views don't support RLS so this is insecure by default\n/// (c.relkind in ('m')) -- m for materialized view\n/// or\n/// -- Standard View, accessible to anon or authenticated that is security_definer\n/// (\n/// c.relkind = 'v' -- v for view\n/// -- Exclude security invoker views\n/// and not (\n/// lower(coalesce(c.reloptions::text,'{}'))::text[]\n/// && array[\n/// 'security_invoker=1',\n/// 'security_invoker=true',\n/// 'security_invoker=yes',\n/// 'security_invoker=on'\n/// ]\n/// )\n/// )\n/// or\n/// -- Standard View, security invoker, but no RLS enabled on auth.users\n/// (\n/// c.relkind in ('v') -- v for view\n/// -- is security invoker\n/// and (\n/// lower(coalesce(c.reloptions::text,'{}'))::text[]\n/// && array[\n/// 'security_invoker=1',\n/// 'security_invoker=true',\n/// 'security_invoker=yes',\n/// 'security_invoker=on'\n/// ]\n/// )\n/// and not pg_class_auth_users.relrowsecurity\n/// )\n/// )\n/// group by\n/// n.nspname,\n/// c.relname,\n/// c.oid)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"authUsersExposed\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub AuthUsersExposed { version : "1.0.0" , name : "authUsersExposed" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for AuthUsersExposed { fn sql_file_path() -> &'static str { "security/auth_users_exposed.sql" diff --git a/crates/pgls_splinter/src/rules/security/extension_in_public.rs b/crates/pgls_splinter/src/rules/security/extension_in_public.rs index d4b85b743..e69a01a44 100644 --- a/crates/pgls_splinter/src/rules/security/extension_in_public.rs +++ b/crates/pgls_splinter/src/rules/security/extension_in_public.rs @@ -2,7 +2,7 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub ExtensionInPublic { version : "1.0.0" , name : "extensionInPublic" , severity : pgls_diagnostics :: Severity :: Warning , } } +::pgls_analyse::declare_rule! { # [doc = "/// # Extension in Public\n///\n/// Detects extensions installed in the \\`public\\` schema.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'extension_in_public' as \"name!\",\n/// 'Extension in Public' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects extensions installed in the \\`public\\` schema.' as \"description!\",\n/// format(\n/// 'Extension \\`%s\\` is installed in the public schema. Move it to another schema.',\n/// pe.extname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0014_extension_in_public' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', pe.extnamespace::regnamespace,\n/// 'name', pe.extname,\n/// 'type', 'extension'\n/// ) as \"metadata!\",\n/// format(\n/// 'extension_in_public_%s',\n/// pe.extname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_extension pe\n/// where\n/// -- plpgsql is installed by default in public and outside user control\n/// -- confirmed safe\n/// pe.extname not in ('plpgsql')\n/// -- Scoping this to public is not optimal. Ideally we would use the postgres\n/// -- search path. That currently isn't available via SQL. In other lints\n/// -- we have used has_schema_privilege('anon', 'extensions', 'USAGE') but that\n/// -- is not appropriate here as it would evaluate true for the extensions schema\n/// and pe.extnamespace::regnamespace::text = 'public')\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"extensionInPublic\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub ExtensionInPublic { version : "1.0.0" , name : "extensionInPublic" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for ExtensionInPublic { fn sql_file_path() -> &'static str { "security/extension_in_public.sql" diff --git a/crates/pgls_splinter/src/rules/security/extension_versions_outdated.rs b/crates/pgls_splinter/src/rules/security/extension_versions_outdated.rs index 22ff28507..3cf65de2e 100644 --- a/crates/pgls_splinter/src/rules/security/extension_versions_outdated.rs +++ b/crates/pgls_splinter/src/rules/security/extension_versions_outdated.rs @@ -2,7 +2,7 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub ExtensionVersionsOutdated { version : "1.0.0" , name : "extensionVersionsOutdated" , severity : pgls_diagnostics :: Severity :: Warning , } } +::pgls_analyse::declare_rule! { # [doc = "/// # Extension Versions Outdated\n///\n/// Detects extensions that are not using the default (recommended) version.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'extension_versions_outdated' as \"name!\",\n/// 'Extension Versions Outdated' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects extensions that are not using the default (recommended) version.' as \"description!\",\n/// format(\n/// 'Extension `%s` is using version `%s` but version `%s` is available. Using outdated extension versions may expose the database to security vulnerabilities.',\n/// ext.name,\n/// ext.installed_version,\n/// ext.default_version\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0022_extension_versions_outdated' as \"remediation!\",\n/// jsonb_build_object(\n/// 'extension_name', ext.name,\n/// 'installed_version', ext.installed_version,\n/// 'default_version', ext.default_version\n/// ) as \"metadata!\",\n/// format(\n/// 'extension_versions_outdated_%s_%s',\n/// ext.name,\n/// ext.installed_version\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_available_extensions ext\n/// join\n/// -- ignore versions not in pg_available_extension_versions\n/// -- e.g. residue of pg_upgrade\n/// pg_catalog.pg_available_extension_versions extv\n/// on extv.name = ext.name and extv.installed\n/// where\n/// ext.installed_version is not null\n/// and ext.default_version is not null\n/// and ext.installed_version != ext.default_version\n/// order by\n/// ext.name)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"extensionVersionsOutdated\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub ExtensionVersionsOutdated { version : "1.0.0" , name : "extensionVersionsOutdated" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for ExtensionVersionsOutdated { fn sql_file_path() -> &'static str { "security/extension_versions_outdated.sql" diff --git a/crates/pgls_splinter/src/rules/security/fkey_to_auth_unique.rs b/crates/pgls_splinter/src/rules/security/fkey_to_auth_unique.rs index e4cfced1d..e84917504 100644 --- a/crates/pgls_splinter/src/rules/security/fkey_to_auth_unique.rs +++ b/crates/pgls_splinter/src/rules/security/fkey_to_auth_unique.rs @@ -2,7 +2,7 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub FkeyToAuthUnique { version : "1.0.0" , name : "fkeyToAuthUnique" , severity : pgls_diagnostics :: Severity :: Error , } } +::pgls_analyse::declare_rule! { # [doc = "/// # Foreign Key to Auth Unique Constraint\n///\n/// Detects user defined foreign keys to unique constraints in the auth schema.\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'fkey_to_auth_unique' as \"name!\",\n/// 'Foreign Key to Auth Unique Constraint' as \"title!\",\n/// 'ERROR' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects user defined foreign keys to unique constraints in the auth schema.' as \"description!\",\n/// format(\n/// 'Table `%s`.`%s` has a foreign key `%s` referencing an auth unique constraint',\n/// n.nspname, -- referencing schema\n/// c_rel.relname, -- referencing table\n/// c.conname -- fkey name\n/// ) as \"detail!\",\n/// 'Drop the foreign key constraint that references the auth schema.' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c_rel.relname,\n/// 'foreign_key', c.conname\n/// ) as \"metadata!\",\n/// format(\n/// 'fkey_to_auth_unique_%s_%s_%s',\n/// n.nspname, -- referencing schema\n/// c_rel.relname, -- referencing table\n/// c.conname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_constraint c\n/// join pg_catalog.pg_class c_rel\n/// on c.conrelid = c_rel.oid\n/// join pg_catalog.pg_namespace n\n/// on c_rel.relnamespace = n.oid\n/// join pg_catalog.pg_class ref_rel\n/// on c.confrelid = ref_rel.oid\n/// join pg_catalog.pg_namespace cn\n/// on ref_rel.relnamespace = cn.oid\n/// join pg_catalog.pg_index i\n/// on c.conindid = i.indexrelid\n/// where c.contype = 'f'\n/// and cn.nspname = 'auth'\n/// and i.indisunique\n/// and not i.indisprimary)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"fkeyToAuthUnique\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub FkeyToAuthUnique { version : "1.0.0" , name : "fkeyToAuthUnique" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for FkeyToAuthUnique { fn sql_file_path() -> &'static str { "security/fkey_to_auth_unique.sql" diff --git a/crates/pgls_splinter/src/rules/security/foreign_table_in_api.rs b/crates/pgls_splinter/src/rules/security/foreign_table_in_api.rs index eda8a8576..962db4a02 100644 --- a/crates/pgls_splinter/src/rules/security/foreign_table_in_api.rs +++ b/crates/pgls_splinter/src/rules/security/foreign_table_in_api.rs @@ -2,7 +2,7 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub ForeignTableInApi { version : "1.0.0" , name : "foreignTableInApi" , severity : pgls_diagnostics :: Severity :: Warning , } } +::pgls_analyse::declare_rule! { # [doc = "/// # Foreign Table in API\n///\n/// Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies.\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'foreign_table_in_api' as \"name!\",\n/// 'Foreign Table in API' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies.' as \"description!\",\n/// format(\n/// 'Foreign table \\`%s.%s\\` is accessible over APIs',\n/// n.nspname,\n/// c.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'foreign table'\n/// ) as \"metadata!\",\n/// format(\n/// 'foreign_table_in_api_%s_%s',\n/// n.nspname,\n/// c.relname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_class c\n/// join pg_catalog.pg_namespace n\n/// on n.oid = c.relnamespace\n/// left join pg_catalog.pg_depend dep\n/// on c.oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// c.relkind = 'f'\n/// and (\n/// pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n/// or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n/// )\n/// and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n/// and n.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and dep.objid is null)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"foreignTableInApi\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub ForeignTableInApi { version : "1.0.0" , name : "foreignTableInApi" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for ForeignTableInApi { fn sql_file_path() -> &'static str { "security/foreign_table_in_api.sql" diff --git a/crates/pgls_splinter/src/rules/security/function_search_path_mutable.rs b/crates/pgls_splinter/src/rules/security/function_search_path_mutable.rs index 0e67b7d10..a33deaf61 100644 --- a/crates/pgls_splinter/src/rules/security/function_search_path_mutable.rs +++ b/crates/pgls_splinter/src/rules/security/function_search_path_mutable.rs @@ -2,7 +2,7 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub FunctionSearchPathMutable { version : "1.0.0" , name : "functionSearchPathMutable" , severity : pgls_diagnostics :: Severity :: Warning , } } +::pgls_analyse::declare_rule! { # [doc = "/// # Function Search Path Mutable\n///\n/// Detects functions where the search_path parameter is not set.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'function_search_path_mutable' as \"name!\",\n/// 'Function Search Path Mutable' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects functions where the search_path parameter is not set.' as \"description!\",\n/// format(\n/// 'Function \\`%s.%s\\` has a role mutable search_path',\n/// n.nspname,\n/// p.proname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0011_function_search_path_mutable' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', p.proname,\n/// 'type', 'function'\n/// ) as \"metadata!\",\n/// format(\n/// 'function_search_path_mutable_%s_%s_%s',\n/// n.nspname,\n/// p.proname,\n/// md5(p.prosrc) -- required when function is polymorphic\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_proc p\n/// join pg_catalog.pg_namespace n\n/// on p.pronamespace = n.oid\n/// left join pg_catalog.pg_depend dep\n/// on p.oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// n.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and dep.objid is null -- exclude functions owned by extensions\n/// -- Search path not set\n/// and not exists (\n/// select 1\n/// from unnest(coalesce(p.proconfig, '{}')) as config\n/// where config like 'search_path=%'\n/// ))\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"functionSearchPathMutable\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub FunctionSearchPathMutable { version : "1.0.0" , name : "functionSearchPathMutable" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for FunctionSearchPathMutable { fn sql_file_path() -> &'static str { "security/function_search_path_mutable.sql" diff --git a/crates/pgls_splinter/src/rules/security/insecure_queue_exposed_in_api.rs b/crates/pgls_splinter/src/rules/security/insecure_queue_exposed_in_api.rs index 077801ea2..c44223426 100644 --- a/crates/pgls_splinter/src/rules/security/insecure_queue_exposed_in_api.rs +++ b/crates/pgls_splinter/src/rules/security/insecure_queue_exposed_in_api.rs @@ -2,7 +2,7 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub InsecureQueueExposedInApi { version : "1.0.0" , name : "insecureQueueExposedInApi" , severity : pgls_diagnostics :: Severity :: Error , } } +::pgls_analyse::declare_rule! { # [doc = "/// # Insecure Queue Exposed in API\n///\n/// Detects cases where an insecure Queue is exposed over Data APIs\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'insecure_queue_exposed_in_api' as \"name!\",\n/// 'Insecure Queue Exposed in API' as \"title!\",\n/// 'ERROR' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects cases where an insecure Queue is exposed over Data APIs' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` is public, but RLS has not been enabled.',\n/// n.nspname,\n/// c.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0019_insecure_queue_exposed_in_api' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format(\n/// 'rls_disabled_in_public_%s_%s',\n/// n.nspname,\n/// c.relname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_class c\n/// join pg_catalog.pg_namespace n\n/// on c.relnamespace = n.oid\n/// where\n/// c.relkind in ('r', 'I') -- regular or partitioned tables\n/// and not c.relrowsecurity -- RLS is disabled\n/// and (\n/// pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n/// or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n/// )\n/// and n.nspname = 'pgmq' -- tables in the pgmq schema\n/// and c.relname like 'q_%' -- only queue tables\n/// -- Constant requirements\n/// and 'pgmq_public' = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))))\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"insecureQueueExposedInApi\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub InsecureQueueExposedInApi { version : "1.0.0" , name : "insecureQueueExposedInApi" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for InsecureQueueExposedInApi { fn sql_file_path() -> &'static str { "security/insecure_queue_exposed_in_api.sql" diff --git a/crates/pgls_splinter/src/rules/security/materialized_view_in_api.rs b/crates/pgls_splinter/src/rules/security/materialized_view_in_api.rs index 2f7f71e72..c3dd23e9e 100644 --- a/crates/pgls_splinter/src/rules/security/materialized_view_in_api.rs +++ b/crates/pgls_splinter/src/rules/security/materialized_view_in_api.rs @@ -2,7 +2,7 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub MaterializedViewInApi { version : "1.0.0" , name : "materializedViewInApi" , severity : pgls_diagnostics :: Severity :: Warning , } } +::pgls_analyse::declare_rule! { # [doc = "/// # Materialized View in API\n///\n/// Detects materialized views that are accessible over the Data APIs.\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'materialized_view_in_api' as \"name!\",\n/// 'Materialized View in API' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects materialized views that are accessible over the Data APIs.' as \"description!\",\n/// format(\n/// 'Materialized view \\`%s.%s\\` is selectable by anon or authenticated roles',\n/// n.nspname,\n/// c.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'materialized view'\n/// ) as \"metadata!\",\n/// format(\n/// 'materialized_view_in_api_%s_%s',\n/// n.nspname,\n/// c.relname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_class c\n/// join pg_catalog.pg_namespace n\n/// on n.oid = c.relnamespace\n/// left join pg_catalog.pg_depend dep\n/// on c.oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// c.relkind = 'm'\n/// and (\n/// pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n/// or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n/// )\n/// and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n/// and n.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and dep.objid is null)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"materializedViewInApi\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub MaterializedViewInApi { version : "1.0.0" , name : "materializedViewInApi" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for MaterializedViewInApi { fn sql_file_path() -> &'static str { "security/materialized_view_in_api.sql" diff --git a/crates/pgls_splinter/src/rules/security/policy_exists_rls_disabled.rs b/crates/pgls_splinter/src/rules/security/policy_exists_rls_disabled.rs index daabdb6ad..ca5047a71 100644 --- a/crates/pgls_splinter/src/rules/security/policy_exists_rls_disabled.rs +++ b/crates/pgls_splinter/src/rules/security/policy_exists_rls_disabled.rs @@ -2,7 +2,7 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub PolicyExistsRlsDisabled { version : "1.0.0" , name : "policyExistsRlsDisabled" , severity : pgls_diagnostics :: Severity :: Error , } } +::pgls_analyse::declare_rule! { # [doc = "/// # Policy Exists RLS Disabled\n///\n/// Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'policy_exists_rls_disabled' as \"name!\",\n/// 'Policy Exists RLS Disabled' as \"title!\",\n/// 'ERROR' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table.' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` has RLS policies but RLS is not enabled on the table. Policies include %s.',\n/// n.nspname,\n/// c.relname,\n/// array_agg(p.polname order by p.polname)\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0007_policy_exists_rls_disabled' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format(\n/// 'policy_exists_rls_disabled_%s_%s',\n/// n.nspname,\n/// c.relname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_policy p\n/// join pg_catalog.pg_class c\n/// on p.polrelid = c.oid\n/// join pg_catalog.pg_namespace n\n/// on c.relnamespace = n.oid\n/// left join pg_catalog.pg_depend dep\n/// on c.oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// c.relkind = 'r' -- regular tables\n/// and n.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// -- RLS is disabled\n/// and not c.relrowsecurity\n/// and dep.objid is null -- exclude tables owned by extensions\n/// group by\n/// n.nspname,\n/// c.relname)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"policyExistsRlsDisabled\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub PolicyExistsRlsDisabled { version : "1.0.0" , name : "policyExistsRlsDisabled" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for PolicyExistsRlsDisabled { fn sql_file_path() -> &'static str { "security/policy_exists_rls_disabled.sql" diff --git a/crates/pgls_splinter/src/rules/security/rls_disabled_in_public.rs b/crates/pgls_splinter/src/rules/security/rls_disabled_in_public.rs index 2ab01fde4..ba4fca06d 100644 --- a/crates/pgls_splinter/src/rules/security/rls_disabled_in_public.rs +++ b/crates/pgls_splinter/src/rules/security/rls_disabled_in_public.rs @@ -2,7 +2,7 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub RlsDisabledInPublic { version : "1.0.0" , name : "rlsDisabledInPublic" , severity : pgls_diagnostics :: Severity :: Error , } } +::pgls_analyse::declare_rule! { # [doc = "/// # RLS Disabled in Public\n///\n/// Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'rls_disabled_in_public' as \"name!\",\n/// 'RLS Disabled in Public' as \"title!\",\n/// 'ERROR' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` is public, but RLS has not been enabled.',\n/// n.nspname,\n/// c.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format(\n/// 'rls_disabled_in_public_%s_%s',\n/// n.nspname,\n/// c.relname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_class c\n/// join pg_catalog.pg_namespace n\n/// on c.relnamespace = n.oid\n/// where\n/// c.relkind = 'r' -- regular tables\n/// -- RLS is disabled\n/// and not c.relrowsecurity\n/// and (\n/// pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n/// or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n/// )\n/// and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n/// and n.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// ))\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"rlsDisabledInPublic\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub RlsDisabledInPublic { version : "1.0.0" , name : "rlsDisabledInPublic" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for RlsDisabledInPublic { fn sql_file_path() -> &'static str { "security/rls_disabled_in_public.sql" diff --git a/crates/pgls_splinter/src/rules/security/rls_enabled_no_policy.rs b/crates/pgls_splinter/src/rules/security/rls_enabled_no_policy.rs index 1b1576d48..06deface8 100644 --- a/crates/pgls_splinter/src/rules/security/rls_enabled_no_policy.rs +++ b/crates/pgls_splinter/src/rules/security/rls_enabled_no_policy.rs @@ -2,7 +2,7 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub RlsEnabledNoPolicy { version : "1.0.0" , name : "rlsEnabledNoPolicy" , severity : pgls_diagnostics :: Severity :: Information , } } +::pgls_analyse::declare_rule! { # [doc = "/// # RLS Enabled No Policy\n///\n/// Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'rls_enabled_no_policy' as \"name!\",\n/// 'RLS Enabled No Policy' as \"title!\",\n/// 'INFO' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created.' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` has RLS enabled, but no policies exist',\n/// n.nspname,\n/// c.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0008_rls_enabled_no_policy' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format(\n/// 'rls_enabled_no_policy_%s_%s',\n/// n.nspname,\n/// c.relname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_class c\n/// left join pg_catalog.pg_policy p\n/// on p.polrelid = c.oid\n/// join pg_catalog.pg_namespace n\n/// on c.relnamespace = n.oid\n/// left join pg_catalog.pg_depend dep\n/// on c.oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// c.relkind = 'r' -- regular tables\n/// and n.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// -- RLS is enabled\n/// and c.relrowsecurity\n/// and p.polname is null\n/// and dep.objid is null -- exclude tables owned by extensions\n/// group by\n/// n.nspname,\n/// c.relname)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"rlsEnabledNoPolicy\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub RlsEnabledNoPolicy { version : "1.0.0" , name : "rlsEnabledNoPolicy" , severity : pgls_diagnostics :: Severity :: Information , } } impl SplinterRule for RlsEnabledNoPolicy { fn sql_file_path() -> &'static str { "security/rls_enabled_no_policy.sql" diff --git a/crates/pgls_splinter/src/rules/security/rls_references_user_metadata.rs b/crates/pgls_splinter/src/rules/security/rls_references_user_metadata.rs index 9b2f24b3c..1c9b5d33d 100644 --- a/crates/pgls_splinter/src/rules/security/rls_references_user_metadata.rs +++ b/crates/pgls_splinter/src/rules/security/rls_references_user_metadata.rs @@ -2,7 +2,7 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub RlsReferencesUserMetadata { version : "1.0.0" , name : "rlsReferencesUserMetadata" , severity : pgls_diagnostics :: Severity :: Error , } } +::pgls_analyse::declare_rule! { # [doc = "/// # RLS references user metadata\n///\n/// Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy.\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// with policies as (\n/// select\n/// nsp.nspname as schema_name,\n/// pb.tablename as table_name,\n/// polname as policy_name,\n/// qual,\n/// with_check\n/// from\n/// pg_catalog.pg_policy pa\n/// join pg_catalog.pg_class pc\n/// on pa.polrelid = pc.oid\n/// join pg_catalog.pg_namespace nsp\n/// on pc.relnamespace = nsp.oid\n/// join pg_catalog.pg_policies pb\n/// on pc.relname = pb.tablename\n/// and nsp.nspname = pb.schemaname\n/// and pa.polname = pb.policyname\n/// )\n/// select\n/// 'rls_references_user_metadata' as \"name!\",\n/// 'RLS references user metadata' as \"title!\",\n/// 'ERROR' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy.' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` has a row level security policy \\`%s\\` that references Supabase Auth \\`user_metadata\\`. \\`user_metadata\\` is editable by end users and should never be used in a security context.',\n/// schema_name,\n/// table_name,\n/// policy_name\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', schema_name,\n/// 'name', table_name,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format('rls_references_user_metadata_%s_%s_%s', schema_name, table_name, policy_name) as \"cache_key!\"\n/// from\n/// policies\n/// where\n/// schema_name not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and (\n/// -- Example: auth.jwt() -> 'user_metadata'\n/// -- False positives are possible, but it isn't practical to string match\n/// -- If false positive rate is too high, this expression can iterate\n/// qual like '%auth.jwt()%user_metadata%'\n/// or qual like '%current_setting(%request.jwt.claims%)%user_metadata%'\n/// or with_check like '%auth.jwt()%user_metadata%'\n/// or with_check like '%current_setting(%request.jwt.claims%)%user_metadata%'\n/// ))\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"rlsReferencesUserMetadata\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub RlsReferencesUserMetadata { version : "1.0.0" , name : "rlsReferencesUserMetadata" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for RlsReferencesUserMetadata { fn sql_file_path() -> &'static str { "security/rls_references_user_metadata.sql" diff --git a/crates/pgls_splinter/src/rules/security/security_definer_view.rs b/crates/pgls_splinter/src/rules/security/security_definer_view.rs index 53a11c04f..a6752587b 100644 --- a/crates/pgls_splinter/src/rules/security/security_definer_view.rs +++ b/crates/pgls_splinter/src/rules/security/security_definer_view.rs @@ -2,7 +2,7 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub SecurityDefinerView { version : "1.0.0" , name : "securityDefinerView" , severity : pgls_diagnostics :: Severity :: Error , } } +::pgls_analyse::declare_rule! { # [doc = "/// # Security Definer View\n///\n/// Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'security_definer_view' as \"name!\",\n/// 'Security Definer View' as \"title!\",\n/// 'ERROR' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user' as \"description!\",\n/// format(\n/// 'View \\`%s.%s\\` is defined with the SECURITY DEFINER property',\n/// n.nspname,\n/// c.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'view'\n/// ) as \"metadata!\",\n/// format(\n/// 'security_definer_view_%s_%s',\n/// n.nspname,\n/// c.relname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_class c\n/// join pg_catalog.pg_namespace n\n/// on n.oid = c.relnamespace\n/// left join pg_catalog.pg_depend dep\n/// on c.oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// c.relkind = 'v'\n/// and (\n/// pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n/// or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n/// )\n/// and substring(pg_catalog.version() from 'PostgreSQL ([0-9]+)') >= '15' -- security invoker was added in pg15\n/// and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n/// and n.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and dep.objid is null -- exclude views owned by extensions\n/// and not (\n/// lower(coalesce(c.reloptions::text,'{}'))::text[]\n/// && array[\n/// 'security_invoker=1',\n/// 'security_invoker=true',\n/// 'security_invoker=yes',\n/// 'security_invoker=on'\n/// ]\n/// ))\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"securityDefinerView\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub SecurityDefinerView { version : "1.0.0" , name : "securityDefinerView" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for SecurityDefinerView { fn sql_file_path() -> &'static str { "security/security_definer_view.sql" diff --git a/crates/pgls_splinter/src/rules/security/unsupported_reg_types.rs b/crates/pgls_splinter/src/rules/security/unsupported_reg_types.rs index a5672c772..9e6c2ea36 100644 --- a/crates/pgls_splinter/src/rules/security/unsupported_reg_types.rs +++ b/crates/pgls_splinter/src/rules/security/unsupported_reg_types.rs @@ -2,7 +2,7 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use crate::rule::SplinterRule; -::pgls_analyse::declare_rule! { # [doc = r" #title"] # [doc = r""] # [doc = r" #description"] pub UnsupportedRegTypes { version : "1.0.0" , name : "unsupportedRegTypes" , severity : pgls_diagnostics :: Severity :: Warning , } } +::pgls_analyse::declare_rule! { # [doc = "/// # Unsupported reg types\n///\n/// Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'unsupported_reg_types' as \"name!\",\n/// 'Unsupported reg types' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade.' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` has a column \\`%s\\` with unsupported reg* type \\`%s\\`.',\n/// n.nspname,\n/// c.relname,\n/// a.attname,\n/// t.typname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=unsupported_reg_types' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'column', a.attname,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format(\n/// 'unsupported_reg_types_%s_%s_%s',\n/// n.nspname,\n/// c.relname,\n/// a.attname\n/// ) AS cache_key\n/// from\n/// pg_catalog.pg_attribute a\n/// join pg_catalog.pg_class c\n/// on a.attrelid = c.oid\n/// join pg_catalog.pg_namespace n\n/// on c.relnamespace = n.oid\n/// join pg_catalog.pg_type t\n/// on a.atttypid = t.oid\n/// join pg_catalog.pg_namespace tn\n/// on t.typnamespace = tn.oid\n/// where\n/// tn.nspname = 'pg_catalog'\n/// and t.typname in ('regcollation', 'regconfig', 'regdictionary', 'regnamespace', 'regoper', 'regoperator', 'regproc', 'regprocedure')\n/// and n.nspname not in ('pg_catalog', 'information_schema', 'pgsodium'))\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"unsupportedRegTypes\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub UnsupportedRegTypes { version : "1.0.0" , name : "unsupportedRegTypes" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for UnsupportedRegTypes { fn sql_file_path() -> &'static str { "security/unsupported_reg_types.sql" diff --git a/crates/pgls_splinter/tests/diagnostics.rs b/crates/pgls_splinter/tests/diagnostics.rs index 6fee1f20d..2764200db 100644 --- a/crates/pgls_splinter/tests/diagnostics.rs +++ b/crates/pgls_splinter/tests/diagnostics.rs @@ -1,3 +1,4 @@ +use pgls_analyse::AnalysisFilter; use pgls_console::fmt::{Formatter, HTML}; use pgls_diagnostics::{Diagnostic, LogCategory, Visit}; use pgls_splinter::{SplinterParams, run_splinter}; @@ -57,10 +58,17 @@ impl TestSetup<'_> { .await .expect("Failed to setup test database"); - // Run splinter checks - let diagnostics = run_splinter(SplinterParams { conn: self.test_db }) - .await - .expect("Failed to run splinter checks"); + // Run splinter checks with all rules enabled + let filter = AnalysisFilter::default(); + let diagnostics = run_splinter( + SplinterParams { + conn: self.test_db, + schema_cache: None, + }, + &filter, + ) + .await + .expect("Failed to run splinter checks"); let content = if diagnostics.is_empty() { String::from("No Diagnostics") @@ -235,9 +243,16 @@ async fn multiple_issues(test_db: PgPool) { async fn missing_roles_runs_generic_checks_only(test_db: PgPool) { // Without Supabase roles, generic rules should still run // but Supabase-specific rules should be skipped - let diagnostics = run_splinter(SplinterParams { conn: &test_db }) - .await - .expect("Should not error when Supabase roles are missing"); + let filter = AnalysisFilter::default(); + let diagnostics = run_splinter( + SplinterParams { + conn: &test_db, + schema_cache: None, + }, + &filter, + ) + .await + .expect("Should not error when Supabase roles are missing"); assert!( diagnostics.is_empty(), @@ -251,9 +266,16 @@ async fn missing_roles_runs_generic_checks_only(test_db: PgPool) { .await .expect("Failed to create test table"); - let diagnostics_with_issue = run_splinter(SplinterParams { conn: &test_db }) - .await - .expect("Should not error when checking for issues"); + let filter = AnalysisFilter::default(); + let diagnostics_with_issue = run_splinter( + SplinterParams { + conn: &test_db, + schema_cache: None, + }, + &filter, + ) + .await + .expect("Should not error when checking for issues"); assert!( !diagnostics_with_issue.is_empty(), diff --git a/crates/pgls_splinter/vendor/performance/auth_rls_initplan.sql b/crates/pgls_splinter/vendor/performance/auth_rls_initplan.sql index dd2b7edd5..bc38f76f6 100644 --- a/crates/pgls_splinter/vendor/performance/auth_rls_initplan.sql +++ b/crates/pgls_splinter/vendor/performance/auth_rls_initplan.sql @@ -4,6 +4,7 @@ -- meta: category = PERFORMANCE -- meta: description = Detects if calls to \`current_setting()\` and \`auth.()\` in RLS policies are being unnecessarily re-evaluated for each row -- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan +-- meta: requires_supabase = true ( with policies as ( diff --git a/crates/pgls_splinter/vendor/security/auth_users_exposed.sql b/crates/pgls_splinter/vendor/security/auth_users_exposed.sql index e73592b8c..b33e5d3df 100644 --- a/crates/pgls_splinter/vendor/security/auth_users_exposed.sql +++ b/crates/pgls_splinter/vendor/security/auth_users_exposed.sql @@ -4,6 +4,7 @@ -- meta: category = SECURITY -- meta: description = Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security. -- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed +-- meta: requires_supabase = true ( select diff --git a/crates/pgls_splinter/vendor/security/fkey_to_auth_unique.sql b/crates/pgls_splinter/vendor/security/fkey_to_auth_unique.sql index 183f9ca9a..24cfbaf1f 100644 --- a/crates/pgls_splinter/vendor/security/fkey_to_auth_unique.sql +++ b/crates/pgls_splinter/vendor/security/fkey_to_auth_unique.sql @@ -4,6 +4,7 @@ -- meta: category = SECURITY -- meta: description = Detects user defined foreign keys to unique constraints in the auth schema. -- meta: remediation = Drop the foreign key constraint that references the auth schema. +-- meta: requires_supabase = true ( select diff --git a/crates/pgls_splinter/vendor/security/foreign_table_in_api.sql b/crates/pgls_splinter/vendor/security/foreign_table_in_api.sql index 9e205606e..58f4a176d 100644 --- a/crates/pgls_splinter/vendor/security/foreign_table_in_api.sql +++ b/crates/pgls_splinter/vendor/security/foreign_table_in_api.sql @@ -4,6 +4,7 @@ -- meta: category = SECURITY -- meta: description = Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies. -- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api +-- meta: requires_supabase = true ( select diff --git a/crates/pgls_splinter/vendor/security/insecure_queue_exposed_in_api.sql b/crates/pgls_splinter/vendor/security/insecure_queue_exposed_in_api.sql index 2a35f5e42..a919d0b4c 100644 --- a/crates/pgls_splinter/vendor/security/insecure_queue_exposed_in_api.sql +++ b/crates/pgls_splinter/vendor/security/insecure_queue_exposed_in_api.sql @@ -4,6 +4,7 @@ -- meta: category = SECURITY -- meta: description = Detects cases where an insecure Queue is exposed over Data APIs -- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0019_insecure_queue_exposed_in_api +-- meta: requires_supabase = true ( select diff --git a/crates/pgls_splinter/vendor/security/materialized_view_in_api.sql b/crates/pgls_splinter/vendor/security/materialized_view_in_api.sql index 9c1546614..634188e5e 100644 --- a/crates/pgls_splinter/vendor/security/materialized_view_in_api.sql +++ b/crates/pgls_splinter/vendor/security/materialized_view_in_api.sql @@ -4,6 +4,7 @@ -- meta: category = SECURITY -- meta: description = Detects materialized views that are accessible over the Data APIs. -- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api +-- meta: requires_supabase = true ( select diff --git a/crates/pgls_splinter/vendor/security/rls_disabled_in_public.sql b/crates/pgls_splinter/vendor/security/rls_disabled_in_public.sql index ae9fc3162..db812fe78 100644 --- a/crates/pgls_splinter/vendor/security/rls_disabled_in_public.sql +++ b/crates/pgls_splinter/vendor/security/rls_disabled_in_public.sql @@ -4,6 +4,7 @@ -- meta: category = SECURITY -- meta: description = Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST -- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public +-- meta: requires_supabase = true ( select diff --git a/crates/pgls_splinter/vendor/security/rls_references_user_metadata.sql b/crates/pgls_splinter/vendor/security/rls_references_user_metadata.sql index 5dc91ca6f..cb50c607f 100644 --- a/crates/pgls_splinter/vendor/security/rls_references_user_metadata.sql +++ b/crates/pgls_splinter/vendor/security/rls_references_user_metadata.sql @@ -4,6 +4,7 @@ -- meta: category = SECURITY -- meta: description = Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy. -- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata +-- meta: requires_supabase = true ( with policies as ( diff --git a/crates/pgls_splinter/vendor/security/security_definer_view.sql b/crates/pgls_splinter/vendor/security/security_definer_view.sql index 102141fc2..ac22248af 100644 --- a/crates/pgls_splinter/vendor/security/security_definer_view.sql +++ b/crates/pgls_splinter/vendor/security/security_definer_view.sql @@ -4,6 +4,7 @@ -- meta: category = SECURITY -- meta: description = Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user -- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view +-- meta: requires_supabase = true ( select diff --git a/xtask/codegen/src/generate_splinter.rs b/xtask/codegen/src/generate_splinter.rs index 833ce9240..7db9e54d8 100644 --- a/xtask/codegen/src/generate_splinter.rs +++ b/xtask/codegen/src/generate_splinter.rs @@ -25,6 +25,10 @@ struct SqlRuleMetadata { description: String, /// Remediation URL or text remediation: String, + /// Whether this rule requires Supabase roles (anon, authenticated, service_role) + requires_supabase: bool, + /// The actual SQL query content (everything after metadata comments) + sql_query: String, /// Path to SQL file relative to vendor/ sql_file_path: PathBuf, } @@ -75,11 +79,14 @@ fn extract_metadata_from_sql(sql_path: &Path, category: &str) -> Result Result>() + .join("\n") + .trim() + .to_string(); + // Get snake_case name from filename let snake_name = sql_path .file_stem() @@ -118,6 +136,7 @@ fn extract_metadata_from_sql(sql_path: &Path, category: &str) -> Result Result Result let title = &metadata.title; let description = &metadata.description; let name = &metadata.name; // camelCase name - let category_upper = metadata.category.to_uppercase(); - let category_ident = format_ident!("{}", category_upper); + let remediation = &metadata.remediation; + let sql_query = &metadata.sql_query; + let category_lower = metadata.category.to_lowercase(); let sql_path = metadata.sql_file_path.display().to_string(); + let requires_supabase = metadata.requires_supabase; // Parse severity - this will be a Rust expression let severity = match metadata.severity.as_str() { @@ -233,15 +256,63 @@ fn generate_rule_file(category_dir: &Path, metadata: &SqlRuleMetadata) -> Result _ => quote! { pgls_diagnostics::Severity::Information }, }; + // Build comprehensive documentation + let requires_supabase_note = if requires_supabase { + format!("\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.") + } else { + String::new() + }; + + let doc_string = format!( + r#"/// # {title} +/// +/// {description}{requires_supabase_note} +/// +/// ## SQL Query +/// +/// ```sql +{sql_query_commented} +/// ``` +/// +/// ## Configuration +/// +/// Enable or disable this rule in your configuration: +/// +/// ```json +/// {{ +/// "splinter": {{ +/// "rules": {{ +/// "{category_lower}": {{ +/// "{name}": "warn" +/// }} +/// }} +/// }} +/// }} +/// ``` +/// +/// ## Remediation +/// +/// See: <{remediation}>"#, + title = title, + description = description, + requires_supabase_note = requires_supabase_note, + sql_query_commented = sql_query + .lines() + .map(|line| format!("/// {}", line)) + .collect::>() + .join("\n"), + category_lower = category_lower, + name = name, + remediation = remediation, + ); + let content = quote! { //! Generated file, do not edit by hand, see `xtask/codegen` use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { - /// #title - /// - /// #description + #[doc = #doc_string] pub #struct_name { version: "1.0.0", name: #name, @@ -374,7 +445,7 @@ fn generate_registry(rules: &BTreeMap) -> Result<()> { registry.record_category::(); }]; - // Generate match arms for SQL file path mapping + // Generate match arms for SQL file path mapping (camelCase → path) let sql_path_arms: Vec<_> = rules .values() .map(|rule| { @@ -388,6 +459,49 @@ fn generate_registry(rules: &BTreeMap) -> Result<()> { }) .collect(); + // Generate match arms for category lookup (snake_case → &'static Category) + let category_arms: Vec<_> = rules + .values() + .map(|rule| { + let snake_name = &rule.snake_name; + let group = rule.category.to_lowercase(); + let camel_name = &rule.name; + let category_path = format!("splinter/{}/{}", group, camel_name); + + quote! { + #snake_name => Some(::pgls_diagnostics::category!(#category_path)) + } + }) + .collect(); + + // Generate match arms for Supabase requirement check (camelCase → bool) + let supabase_arms: Vec<_> = rules + .values() + .map(|rule| { + let camel_name = &rule.name; + let requires = rule.requires_supabase; + + quote! { + #camel_name => #requires + } + }) + .collect(); + + // Generate match arms for SQL content lookup (camelCase → embedded SQL) + // Use include_str! to embed SQL files at compile time + let sql_content_arms: Vec<_> = rules + .values() + .map(|rule| { + let camel_name = &rule.name; + // Path relative to crate root (where Cargo.toml is) + let relative_path = format!("vendor/{}", rule.sql_file_path.display()); + + quote! { + #camel_name => Some(include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/", #relative_path))) + } + }) + .collect(); + let content = quote! { //! Generated file, do not edit by hand, see `xtask/codegen` @@ -401,12 +515,45 @@ fn generate_registry(rules: &BTreeMap) -> Result<()> { /// Map rule name (camelCase) to SQL file path /// Returns None if rule not found + #[deprecated(note = "Use get_sql_content() instead - SQL is now embedded at compile time")] pub fn get_sql_file_path(rule_name: &str) -> Option<&'static str> { match rule_name { #( #sql_path_arms, )* _ => None, } } + + /// Get embedded SQL content for a rule (camelCase name) + /// Returns None if rule not found + /// + /// SQL files are embedded at compile time using include_str! for performance + /// and to make the binary self-contained. + pub fn get_sql_content(rule_name: &str) -> Option<&'static str> { + match rule_name { + #( #sql_content_arms, )* + _ => None, + } + } + + /// Map rule name from SQL result (snake_case) to diagnostic category + /// Returns None if rule not found + /// + /// This replaces the hardcoded match in convert.rs + pub fn get_rule_category(rule_name: &str) -> Option<&'static ::pgls_diagnostics::Category> { + match rule_name { + #( #category_arms, )* + _ => None, + } + } + + /// Check if a rule requires Supabase roles (anon, authenticated, service_role) + /// Rules that require Supabase should be filtered out if these roles don't exist + pub fn rule_requires_supabase(rule_name: &str) -> bool { + match rule_name { + #( #supabase_arms, )* + _ => false, + } + } }; let formatted = xtask::reformat(content)?; From 13afe90fa7fc48673d3906e5bc4303b20fd26dcc Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 15 Dec 2025 13:36:59 +0100 Subject: [PATCH 15/32] fix: resolve configuration.rs from parent branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed stale module reference after rebase. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/rules/configuration.rs | 129 ------------------ 1 file changed, 129 deletions(-) diff --git a/crates/pgls_configuration/src/rules/configuration.rs b/crates/pgls_configuration/src/rules/configuration.rs index 625d6e3fb..6b86fef69 100644 --- a/crates/pgls_configuration/src/rules/configuration.rs +++ b/crates/pgls_configuration/src/rules/configuration.rs @@ -1,7 +1,3 @@ -pub mod linter; -pub mod splinter; - -pub use crate::analyser::linter::*; use biome_deserialize::Merge; use biome_deserialize_macros::Deserializable; use pgls_analyser::RuleOptions; @@ -9,7 +5,6 @@ use pgls_diagnostics::Severity; #[cfg(feature = "schema")] use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use std::str::FromStr; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[cfg_attr(feature = "schema", derive(JsonSchema))] @@ -300,127 +295,3 @@ impl Merge for RuleWithFixOptions { self.options = other.options; } } - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] -pub enum RuleSelector { - LinterGroup(linter::RuleGroup), - LinterRule(linter::RuleGroup, &'static str), - SplinterGroup(splinter::RuleGroup), - SplinterRule(splinter::RuleGroup, &'static str), -} - -impl From for RuleFilter<'static> { - fn from(value: RuleSelector) -> Self { - match value { - RuleSelector::LinterGroup(group) => RuleFilter::Group(group.as_str()), - RuleSelector::LinterRule(group, name) => RuleFilter::Rule(group.as_str(), name), - RuleSelector::SplinterGroup(group) => RuleFilter::Group(group.as_str()), - RuleSelector::SplinterRule(group, name) => RuleFilter::Rule(group.as_str(), name), - } - } -} - -impl<'a> From<&'a RuleSelector> for RuleFilter<'static> { - fn from(value: &'a RuleSelector) -> Self { - match value { - RuleSelector::LinterGroup(group) => RuleFilter::Group(group.as_str()), - RuleSelector::LinterRule(group, name) => RuleFilter::Rule(group.as_str(), name), - RuleSelector::SplinterGroup(group) => RuleFilter::Group(group.as_str()), - RuleSelector::SplinterRule(group, name) => RuleFilter::Rule(group.as_str(), name), - } - } -} - -impl FromStr for RuleSelector { - type Err = &'static str; - fn from_str(selector: &str) -> Result { - // Check for explicit prefixes - if let Some(linter_selector) = selector.strip_prefix("lint/") { - return parse_linter_selector(linter_selector); - } - if let Some(splinter_selector) = selector.strip_prefix("splinter/") { - return parse_splinter_selector(splinter_selector); - } - - // No prefix: try linter first (for backward compatibility), then splinter - parse_linter_selector(selector) - .or_else(|_| parse_splinter_selector(selector)) - .map_err(|_| "This rule or group doesn't exist in linter or splinter.") - } -} - -fn parse_linter_selector(selector: &str) -> Result { - if let Some((group_name, rule_name)) = selector.split_once('/') { - let group = linter::RuleGroup::from_str(group_name)?; - if let Some(rule_name) = linter::Rules::has_rule(group, rule_name) { - Ok(RuleSelector::LinterRule(group, rule_name)) - } else { - Err("This linter rule doesn't exist.") - } - } else { - let group = linter::RuleGroup::from_str(selector)?; - Ok(RuleSelector::LinterGroup(group)) - } -} - -fn parse_splinter_selector(selector: &str) -> Result { - if let Some((group_name, rule_name)) = selector.split_once('/') { - let group = splinter::RuleGroup::from_str(group_name)?; - if let Some(rule_name) = splinter::Rules::has_rule(group, rule_name) { - Ok(RuleSelector::SplinterRule(group, rule_name)) - } else { - Err("This splinter rule doesn't exist.") - } - } else { - let group = splinter::RuleGroup::from_str(selector)?; - Ok(RuleSelector::SplinterGroup(group)) - } -} - -impl serde::Serialize for RuleSelector { - fn serialize(&self, serializer: S) -> Result { - match self { - RuleSelector::LinterGroup(group) => serializer.serialize_str(group.as_str()), - RuleSelector::LinterRule(group, rule_name) => { - let group_name = group.as_str(); - serializer.serialize_str(&format!("{group_name}/{rule_name}")) - } - RuleSelector::SplinterGroup(group) => { - serializer.serialize_str(&format!("splinter/{}", group.as_str())) - } - RuleSelector::SplinterRule(group, rule_name) => { - let group_name = group.as_str(); - serializer.serialize_str(&format!("splinter/{group_name}/{rule_name}")) - } - } - } -} - -impl<'de> serde::Deserialize<'de> for RuleSelector { - fn deserialize>(deserializer: D) -> Result { - struct Visitor; - impl serde::de::Visitor<'_> for Visitor { - type Value = RuleSelector; - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("/") - } - fn visit_str(self, v: &str) -> Result { - match RuleSelector::from_str(v) { - Ok(result) => Ok(result), - Err(error) => Err(serde::de::Error::custom(error)), - } - } - } - deserializer.deserialize_str(Visitor) - } -} - -#[cfg(feature = "schema")] -impl schemars::JsonSchema for RuleSelector { - fn schema_name() -> String { - "RuleCode".to_string() - } - fn json_schema(r#gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { - String::json_schema(r#gen) - } -} From e2950b5b45af0ea57497676b459c7c5f177b315d Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 15 Dec 2025 15:12:20 +0100 Subject: [PATCH 16/32] fix: strip outer parentheses from SQL queries before UNION ALL --- crates/pgls_splinter/src/lib.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/pgls_splinter/src/lib.rs b/crates/pgls_splinter/src/lib.rs index 6b0c32573..3d0b8b8c5 100644 --- a/crates/pgls_splinter/src/lib.rs +++ b/crates/pgls_splinter/src/lib.rs @@ -95,7 +95,20 @@ pub async fn run_splinter( } // Combine SQL queries with UNION ALL - let combined_sql = sql_queries.join("\n\nUNION ALL\n\n"); + // Strip outer parentheses from each query since they may contain CTEs which + // cannot be parenthesized when joined with UNION ALL + let processed_queries: Vec = sql_queries + .iter() + .map(|sql| { + let trimmed = sql.trim(); + if trimmed.starts_with('(') && trimmed.ends_with(')') { + trimmed[1..trimmed.len() - 1].to_string() + } else { + trimmed.to_string() + } + }) + .collect(); + let combined_sql = processed_queries.join("\n\nUNION ALL\n\n"); // Execute the combined query let mut tx = params.conn.begin().await?; From 7a2d56e9801e4f5d259021259ba65880ff6f1191 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 15 Dec 2025 15:23:44 +0100 Subject: [PATCH 17/32] fix: wrap queries in SELECT * FROM to handle CTEs in UNION ALL --- crates/pgls_splinter/src/lib.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/crates/pgls_splinter/src/lib.rs b/crates/pgls_splinter/src/lib.rs index 3d0b8b8c5..91bb78dbd 100644 --- a/crates/pgls_splinter/src/lib.rs +++ b/crates/pgls_splinter/src/lib.rs @@ -95,17 +95,14 @@ pub async fn run_splinter( } // Combine SQL queries with UNION ALL - // Strip outer parentheses from each query since they may contain CTEs which - // cannot be parenthesized when joined with UNION ALL + // Wrap each query in SELECT * FROM (...) to handle CTEs correctly + // This allows queries with WITH clauses to be properly combined with UNION ALL let processed_queries: Vec = sql_queries .iter() .map(|sql| { let trimmed = sql.trim(); - if trimmed.starts_with('(') && trimmed.ends_with(')') { - trimmed[1..trimmed.len() - 1].to_string() - } else { - trimmed.to_string() - } + // Wrap in SELECT * FROM to create a valid subquery + format!("SELECT * FROM {trimmed}") }) .collect(); let combined_sql = processed_queries.join("\n\nUNION ALL\n\n"); From 947426b33e4ff6f8373978c3ba26996aa102b4b1 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 15 Dec 2025 15:35:39 +0100 Subject: [PATCH 18/32] fix: add alias to subqueries in FROM clause --- crates/pgls_splinter/src/lib.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/pgls_splinter/src/lib.rs b/crates/pgls_splinter/src/lib.rs index 91bb78dbd..cfd762053 100644 --- a/crates/pgls_splinter/src/lib.rs +++ b/crates/pgls_splinter/src/lib.rs @@ -95,14 +95,15 @@ pub async fn run_splinter( } // Combine SQL queries with UNION ALL - // Wrap each query in SELECT * FROM (...) to handle CTEs correctly + // Wrap each query in SELECT * FROM (...) AS alias to handle CTEs correctly // This allows queries with WITH clauses to be properly combined with UNION ALL let processed_queries: Vec = sql_queries .iter() - .map(|sql| { + .enumerate() + .map(|(idx, sql)| { let trimmed = sql.trim(); - // Wrap in SELECT * FROM to create a valid subquery - format!("SELECT * FROM {trimmed}") + // Wrap in SELECT * FROM with alias (required by PostgreSQL) + format!("SELECT * FROM {trimmed} AS rule_{idx}") }) .collect(); let combined_sql = processed_queries.join("\n\nUNION ALL\n\n"); From 404bab97873ca27674b04a014e76633422978eb8 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 15 Dec 2025 15:49:36 +0100 Subject: [PATCH 19/32] fix: simplify SQL query combination in run_splinter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove unnecessary SELECT * FROM wrapping that was causing syntax errors with CTEs. SQL files are already complete parenthesized queries. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- crates/pgls_splinter/src/lib.rs | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/crates/pgls_splinter/src/lib.rs b/crates/pgls_splinter/src/lib.rs index cfd762053..f02629762 100644 --- a/crates/pgls_splinter/src/lib.rs +++ b/crates/pgls_splinter/src/lib.rs @@ -95,18 +95,12 @@ pub async fn run_splinter( } // Combine SQL queries with UNION ALL - // Wrap each query in SELECT * FROM (...) AS alias to handle CTEs correctly - // This allows queries with WITH clauses to be properly combined with UNION ALL - let processed_queries: Vec = sql_queries + // SQL files are already complete queries wrapped in parentheses + let combined_sql = sql_queries .iter() - .enumerate() - .map(|(idx, sql)| { - let trimmed = sql.trim(); - // Wrap in SELECT * FROM with alias (required by PostgreSQL) - format!("SELECT * FROM {trimmed} AS rule_{idx}") - }) - .collect(); - let combined_sql = processed_queries.join("\n\nUNION ALL\n\n"); + .map(|sql| sql.trim()) + .collect::>() + .join("\n\nUNION ALL\n\n"); // Execute the combined query let mut tx = params.conn.begin().await?; From a8c551c0ddc727c09af8ede544599f23d7cdb2f0 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 15 Dec 2025 16:02:41 +0100 Subject: [PATCH 20/32] fix: ensure all SQL queries are wrapped in parentheses for UNION ALL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some SQL files are already wrapped in parentheses while others are not. Ensure consistent wrapping to create valid UNION ALL syntax. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- crates/pgls_splinter/src/lib.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/pgls_splinter/src/lib.rs b/crates/pgls_splinter/src/lib.rs index f02629762..e5136e1fa 100644 --- a/crates/pgls_splinter/src/lib.rs +++ b/crates/pgls_splinter/src/lib.rs @@ -95,12 +95,21 @@ pub async fn run_splinter( } // Combine SQL queries with UNION ALL - // SQL files are already complete queries wrapped in parentheses - let combined_sql = sql_queries + // Some SQL files are wrapped in parentheses, some are not + // Ensure all queries are wrapped for valid UNION ALL syntax + let processed_queries: Vec = sql_queries .iter() - .map(|sql| sql.trim()) - .collect::>() - .join("\n\nUNION ALL\n\n"); + .map(|sql| { + let trimmed = sql.trim(); + // Wrap in parentheses if not already wrapped + if trimmed.starts_with('(') && trimmed.ends_with(')') { + trimmed.to_string() + } else { + format!("({trimmed})") + } + }) + .collect(); + let combined_sql = processed_queries.join("\n\nUNION ALL\n\n"); // Execute the combined query let mut tx = params.conn.begin().await?; From 7dd92570d5921acb7c72bb846ea5726be097b678 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 15 Dec 2025 16:16:30 +0100 Subject: [PATCH 21/32] fix: use column names with ! suffix in FromRow implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQL files use "name!" notation for column names (literal identifiers), so FromRow must look for these exact names. Also fix clippy warnings. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- crates/pgls_splinter/src/lib.rs | 2 +- crates/pgls_splinter/src/query.rs | 23 ++++++++++++----------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/crates/pgls_splinter/src/lib.rs b/crates/pgls_splinter/src/lib.rs index e5136e1fa..0d4297a5a 100644 --- a/crates/pgls_splinter/src/lib.rs +++ b/crates/pgls_splinter/src/lib.rs @@ -62,7 +62,7 @@ pub async fn run_splinter( } // Check if Supabase roles exist (anon, authenticated, service_role) - let has_supabase_roles = params.schema_cache.map_or(false, |cache| { + let has_supabase_roles = params.schema_cache.is_some_and(|cache| { let required_roles = ["anon", "authenticated", "service_role"]; required_roles.iter().all(|role_name| { cache diff --git a/crates/pgls_splinter/src/query.rs b/crates/pgls_splinter/src/query.rs index b01ce2ee6..99c079cdb 100644 --- a/crates/pgls_splinter/src/query.rs +++ b/crates/pgls_splinter/src/query.rs @@ -1,5 +1,5 @@ use serde_json::Value; -use sqlx::{PgPool, Row}; +use sqlx::Row; /// Raw query result from the Splinter SQL query. /// This struct represents a single linting issue found in the database. @@ -39,19 +39,20 @@ pub struct SplinterQueryResult { } // Implement FromRow manually since we're using dynamic SQL +// Column names include "!" suffix (e.g., "name!") which indicates NOT NULL in SQL files impl<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow> for SplinterQueryResult { fn from_row(row: &'r sqlx::postgres::PgRow) -> Result { Ok(SplinterQueryResult { - name: row.try_get("name")?, - title: row.try_get("title")?, - level: row.try_get("level")?, - facing: row.try_get("facing")?, - categories: row.try_get("categories")?, - description: row.try_get("description")?, - detail: row.try_get("detail")?, - remediation: row.try_get("remediation")?, - metadata: row.try_get("metadata")?, - cache_key: row.try_get("cache_key")?, + name: row.try_get("name!")?, + title: row.try_get("title!")?, + level: row.try_get("level!")?, + facing: row.try_get("facing!")?, + categories: row.try_get("categories!")?, + description: row.try_get("description!")?, + detail: row.try_get("detail!")?, + remediation: row.try_get("remediation!")?, + metadata: row.try_get("metadata!")?, + cache_key: row.try_get("cache_key!")?, }) } } From 3f06f5953b7478ebe4304105ee908587a3060c7e Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 15 Dec 2025 16:28:30 +0100 Subject: [PATCH 22/32] fix: add ORDER BY to combined SQL for deterministic result ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Individual SQL queries' ORDER BY clauses are not preserved when combined with UNION ALL. Wrap the entire result in SELECT and ORDER BY cache_key! to ensure consistent, deterministic ordering for tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- crates/pgls_splinter/src/lib.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/pgls_splinter/src/lib.rs b/crates/pgls_splinter/src/lib.rs index 0d4297a5a..21b272855 100644 --- a/crates/pgls_splinter/src/lib.rs +++ b/crates/pgls_splinter/src/lib.rs @@ -109,7 +109,11 @@ pub async fn run_splinter( } }) .collect(); - let combined_sql = processed_queries.join("\n\nUNION ALL\n\n"); + // Add ORDER BY to ensure deterministic ordering across all results + let combined_sql = format!( + "SELECT * FROM (\n{}\n) AS all_results ORDER BY \"cache_key!\"", + processed_queries.join("\n\nUNION ALL\n\n") + ); // Execute the combined query let mut tx = params.conn.begin().await?; From d00534e177a66aede884828aadd4ab89851b0b3b Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 15 Dec 2025 16:44:23 +0100 Subject: [PATCH 23/32] test: update snapshots with corrected formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove backslash escaping from backticks in message text - Update diagnostic ordering to match cache_key sort order 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../tests/snapshots/multiple_issues.snap | 22 +++++++++---------- .../snapshots/unindexed_foreign_key.snap | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/pgls_splinter/tests/snapshots/multiple_issues.snap b/crates/pgls_splinter/tests/snapshots/multiple_issues.snap index 1a3a05b33..811cc8daf 100644 --- a/crates/pgls_splinter/tests/snapshots/multiple_issues.snap +++ b/crates/pgls_splinter/tests/snapshots/multiple_issues.snap @@ -3,17 +3,6 @@ source: crates/pgls_splinter/tests/diagnostics.rs expression: content snapshot_kind: text --- -Category: splinter/performance/unindexedForeignKeys -Severity: Information -Message: Table \`public.child_table\` has a foreign key \`child_table_parent_id_fkey\` without a covering index. This can lead to suboptimal query performance. -Advices: -Identifies foreign key constraints without a covering index, which can impact database performance. -[Info] table: public.child_table -{"fkey_name":"child_table_parent_id_fkey","fkey_columns":[2]} -[Info] Remediation: https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys - ---- - Category: splinter/performance/noPrimaryKey Severity: Information Message: Table \`public.no_pk_table\` does not have a primary key @@ -21,3 +10,14 @@ Advices: Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale. [Info] table: public.no_pk_table [Info] Remediation: https://supabase.com/docs/guides/database/database-linter?lint=0004_no_primary_key + +--- + +Category: splinter/performance/unindexedForeignKeys +Severity: Information +Message: Table `public.child_table` has a foreign key `child_table_parent_id_fkey` without a covering index. This can lead to suboptimal query performance. +Advices: +Identifies foreign key constraints without a covering index, which can impact database performance. +[Info] table: public.child_table +{"fkey_name":"child_table_parent_id_fkey","fkey_columns":[2]} +[Info] Remediation: https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys diff --git a/crates/pgls_splinter/tests/snapshots/unindexed_foreign_key.snap b/crates/pgls_splinter/tests/snapshots/unindexed_foreign_key.snap index d6c4b7239..121fe3108 100644 --- a/crates/pgls_splinter/tests/snapshots/unindexed_foreign_key.snap +++ b/crates/pgls_splinter/tests/snapshots/unindexed_foreign_key.snap @@ -5,7 +5,7 @@ snapshot_kind: text --- Category: splinter/performance/unindexedForeignKeys Severity: Information -Message: Table \`public.posts\` has a foreign key \`posts_user_id_fkey\` without a covering index. This can lead to suboptimal query performance. +Message: Table `public.posts` has a foreign key `posts_user_id_fkey` without a covering index. This can lead to suboptimal query performance. Advices: Identifies foreign key constraints without a covering index, which can impact database performance. [Info] table: public.posts From 0b27360177b21e12b8b67166cacb56647a80d2bd Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 15 Dec 2025 17:01:55 +0100 Subject: [PATCH 24/32] chore: apply clippy formatting suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply automatic clippy fixes for format! macro usage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- xtask/codegen/src/generate_splinter.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/xtask/codegen/src/generate_splinter.rs b/xtask/codegen/src/generate_splinter.rs index 7db9e54d8..a00277be4 100644 --- a/xtask/codegen/src/generate_splinter.rs +++ b/xtask/codegen/src/generate_splinter.rs @@ -258,7 +258,7 @@ fn generate_rule_file(category_dir: &Path, metadata: &SqlRuleMetadata) -> Result // Build comprehensive documentation let requires_supabase_note = if requires_supabase { - format!("\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.") + "\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.".to_string() } else { String::new() }; @@ -298,7 +298,7 @@ fn generate_rule_file(category_dir: &Path, metadata: &SqlRuleMetadata) -> Result requires_supabase_note = requires_supabase_note, sql_query_commented = sql_query .lines() - .map(|line| format!("/// {}", line)) + .map(|line| format!("/// {line}")) .collect::>() .join("\n"), category_lower = category_lower, @@ -466,7 +466,7 @@ fn generate_registry(rules: &BTreeMap) -> Result<()> { let snake_name = &rule.snake_name; let group = rule.category.to_lowercase(); let camel_name = &rule.name; - let category_path = format!("splinter/{}/{}", group, camel_name); + let category_path = format!("splinter/{group}/{camel_name}"); quote! { #snake_name => Some(::pgls_diagnostics::category!(#category_path)) From e86fa4737876d13842d6193f4b01d553ff6a29b3 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 15 Dec 2025 10:22:38 +0100 Subject: [PATCH 25/32] chore: integrate splinter into docs codegen --- Cargo.lock | 1 + docs/codegen/Cargo.toml | 1 + docs/codegen/src/lib.rs | 3 +- docs/codegen/src/main.rs | 2 + docs/codegen/src/splinter_docs.rs | 145 ++++++++++++++++++ docs/codegen/src/utils.rs | 65 ++++++++ docs/reference/rules/auth-rls-initplan.md | 133 ++++++++++++++++ docs/reference/rules/auth-users-exposed.md | 121 +++++++++++++++ docs/reference/rules/duplicate-index.md | 84 ++++++++++ docs/reference/rules/extension-in-public.md | 63 ++++++++ .../rules/extension-versions-outdated.md | 68 ++++++++ docs/reference/rules/fkey-to-auth-unique.md | 75 +++++++++ docs/reference/rules/foreign-table-in-api.md | 75 +++++++++ .../rules/function-search-path-mutable.md | 73 +++++++++ .../rules/insecure-queue-exposed-in-api.md | 72 +++++++++ .../rules/materialized-view-in-api.md | 75 +++++++++ .../rules/multiple-permissive-policies.md | 102 ++++++++++++ docs/reference/rules/no-primary-key.md | 75 +++++++++ .../rules/policy-exists-rls-disabled.md | 75 +++++++++ .../reference/rules/rls-disabled-in-public.md | 73 +++++++++ docs/reference/rules/rls-enabled-no-policy.md | 75 +++++++++ .../rules/rls-references-user-metadata.md | 87 +++++++++++ docs/reference/rules/security-definer-view.md | 85 ++++++++++ docs/reference/rules/table-bloat.md | 128 ++++++++++++++++ .../reference/rules/unindexed-foreign-keys.md | 101 ++++++++++++ docs/reference/rules/unsupported-reg-types.md | 72 +++++++++ docs/reference/rules/unused-index.md | 72 +++++++++ 27 files changed, 2000 insertions(+), 1 deletion(-) create mode 100644 docs/codegen/src/splinter_docs.rs create mode 100644 docs/reference/rules/auth-rls-initplan.md create mode 100644 docs/reference/rules/auth-users-exposed.md create mode 100644 docs/reference/rules/duplicate-index.md create mode 100644 docs/reference/rules/extension-in-public.md create mode 100644 docs/reference/rules/extension-versions-outdated.md create mode 100644 docs/reference/rules/fkey-to-auth-unique.md create mode 100644 docs/reference/rules/foreign-table-in-api.md create mode 100644 docs/reference/rules/function-search-path-mutable.md create mode 100644 docs/reference/rules/insecure-queue-exposed-in-api.md create mode 100644 docs/reference/rules/materialized-view-in-api.md create mode 100644 docs/reference/rules/multiple-permissive-policies.md create mode 100644 docs/reference/rules/no-primary-key.md create mode 100644 docs/reference/rules/policy-exists-rls-disabled.md create mode 100644 docs/reference/rules/rls-disabled-in-public.md create mode 100644 docs/reference/rules/rls-enabled-no-policy.md create mode 100644 docs/reference/rules/rls-references-user-metadata.md create mode 100644 docs/reference/rules/security-definer-view.md create mode 100644 docs/reference/rules/table-bloat.md create mode 100644 docs/reference/rules/unindexed-foreign-keys.md create mode 100644 docs/reference/rules/unsupported-reg-types.md create mode 100644 docs/reference/rules/unused-index.md diff --git a/Cargo.lock b/Cargo.lock index a575a902a..690e4e0d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1222,6 +1222,7 @@ dependencies = [ "pgls_env", "pgls_query", "pgls_query_ext", + "pgls_splinter", "pgls_statement_splitter", "pgls_workspace", "pulldown-cmark", diff --git a/docs/codegen/Cargo.toml b/docs/codegen/Cargo.toml index 0b4def22b..97619dda0 100644 --- a/docs/codegen/Cargo.toml +++ b/docs/codegen/Cargo.toml @@ -25,6 +25,7 @@ pgls_env = { workspace = true } pgls_cli = { workspace = true } pgls_analyse = { workspace = true } pgls_analyser = { workspace = true } +pgls_splinter = { workspace = true } pgls_diagnostics = { workspace = true } pgls_query = { workspace = true } pgls_query_ext = { workspace = true } diff --git a/docs/codegen/src/lib.rs b/docs/codegen/src/lib.rs index 1be6d0df0..8e8773521 100644 --- a/docs/codegen/src/lib.rs +++ b/docs/codegen/src/lib.rs @@ -5,6 +5,7 @@ pub mod rules_docs; pub mod rules_index; pub mod rules_sources; pub mod schema; +pub mod splinter_docs; pub mod version; -mod utils; +pub(crate) mod utils; diff --git a/docs/codegen/src/main.rs b/docs/codegen/src/main.rs index 98a63b310..45dfc8397 100644 --- a/docs/codegen/src/main.rs +++ b/docs/codegen/src/main.rs @@ -8,6 +8,7 @@ use docs_codegen::rules_docs::generate_rules_docs; use docs_codegen::rules_index::generate_rules_index; use docs_codegen::rules_sources::generate_rule_sources; use docs_codegen::schema::generate_schema; +use docs_codegen::splinter_docs::generate_splinter_docs; use docs_codegen::version::replace_version; fn docs_root() -> PathBuf { @@ -23,6 +24,7 @@ fn main() -> anyhow::Result<()> { generate_env_variables(&docs_root)?; generate_cli_doc(&docs_root)?; generate_rules_docs(&docs_root)?; + generate_splinter_docs(&docs_root)?; generate_rules_index(&docs_root)?; generate_rule_sources(&docs_root)?; generate_schema(&docs_root)?; diff --git a/docs/codegen/src/splinter_docs.rs b/docs/codegen/src/splinter_docs.rs new file mode 100644 index 000000000..a9512f4e3 --- /dev/null +++ b/docs/codegen/src/splinter_docs.rs @@ -0,0 +1,145 @@ +use anyhow::Result; +use biome_string_case::Case; +use std::{fs, io::Write as _, path::Path}; + +use crate::utils::SplinterRuleMetadata; + +/// Extract remediation URL from SQL metadata comments +fn extract_remediation_from_sql(sql: &str) -> Option { + for line in sql.lines() { + if let Some(url) = line.strip_prefix("-- meta: remediation = ") { + return Some(url.trim().to_string()); + } + } + None +} + +/// Strip metadata comments from SQL content +/// Removes all lines starting with "-- meta:" +fn strip_metadata_from_sql(sql: &str) -> String { + sql.lines() + .filter(|line| !line.trim().starts_with("-- meta:")) + .collect::>() + .join("\n") + .trim() + .to_string() +} + +/// Generates the documentation page for each splinter rule. +/// +/// * `docs_dir`: Path to the docs directory. +pub fn generate_splinter_docs(docs_dir: &Path) -> anyhow::Result<()> { + let rules_dir = docs_dir.join("reference/rules"); + + // Ensure rules directory exists (created by linter docs generation) + if !rules_dir.exists() { + fs::create_dir_all(&rules_dir)?; + } + + let mut visitor = crate::utils::SplinterRulesVisitor::default(); + pgls_splinter::registry::visit_registry(&mut visitor); + + let crate::utils::SplinterRulesVisitor { groups } = visitor; + + for (group, rules) in groups { + for (rule, metadata) in rules { + let content = generate_splinter_rule_doc(group, rule, metadata)?; + let dashed_rule = Case::Kebab.convert(rule); + fs::write(rules_dir.join(format!("{dashed_rule}.md")), content)?; + } + } + + Ok(()) +} + +fn generate_splinter_rule_doc( + group: &'static str, + rule: &'static str, + splinter_meta: SplinterRuleMetadata, +) -> Result { + let meta = splinter_meta.metadata; + let mut content = Vec::new(); + + writeln!(content, "# {rule}")?; + writeln!(content)?; + + writeln!( + content, + "**Diagnostic Category: `splinter/{group}/{rule}`**" + )?; + writeln!(content)?; + + // Add severity + let severity_str = match meta.severity { + pgls_diagnostics::Severity::Information => "Info", + pgls_diagnostics::Severity::Warning => "Warning", + pgls_diagnostics::Severity::Error => "Error", + _ => "Info", + }; + writeln!(content, "**Severity**: {}", severity_str)?; + writeln!(content)?; + + // Add Supabase requirement notice + if splinter_meta.requires_supabase { + writeln!(content, "> [!NOTE]")?; + writeln!( + content, + "> This rule requires a Supabase database/project and will be automatically skipped if not detected." + )?; + writeln!(content)?; + } + + writeln!(content, "## Description")?; + writeln!(content)?; + + // Use description from SQL metadata + writeln!(content, "{}", splinter_meta.description)?; + writeln!(content)?; + + // Add "Learn More" link with remediation URL + if let Some(remediation) = extract_remediation_from_sql(splinter_meta.sql_content) { + writeln!(content, "[Learn More]({})", remediation)?; + writeln!(content)?; + } + + // Add SQL query section (with metadata stripped) + writeln!(content, "## SQL Query")?; + writeln!(content)?; + writeln!(content, "```sql")?; + let sql_without_metadata = strip_metadata_from_sql(splinter_meta.sql_content); + writeln!(content, "{}", sql_without_metadata)?; + writeln!(content, "```")?; + writeln!(content)?; + + // Add configuration section + write_how_to_configure(group, rule, &mut content)?; + + Ok(String::from_utf8(content)?) +} + +fn write_how_to_configure( + group: &'static str, + rule: &'static str, + content: &mut Vec, +) -> std::io::Result<()> { + writeln!(content, "## How to configure")?; + writeln!(content)?; + + let json = format!( + r#"{{ + "splinter": {{ + "rules": {{ + "{group}": {{ + "{rule}": "error" + }} + }} + }} +}}"# + ); + + writeln!(content, "```json")?; + writeln!(content, "{json}")?; + writeln!(content, "```")?; + + Ok(()) +} diff --git a/docs/codegen/src/utils.rs b/docs/codegen/src/utils.rs index 8d53cf909..306fb5313 100644 --- a/docs/codegen/src/utils.rs +++ b/docs/codegen/src/utils.rs @@ -4,6 +4,15 @@ use pgls_analyse::{ use regex::Regex; use std::collections::BTreeMap; +/// Metadata for a splinter rule with SQL content +#[derive(Clone)] +pub(crate) struct SplinterRuleMetadata { + pub(crate) metadata: RuleMetadata, + pub(crate) sql_content: &'static str, + pub(crate) requires_supabase: bool, + pub(crate) description: String, +} + pub(crate) fn replace_section( content: &str, section_identifier: &str, @@ -59,3 +68,59 @@ impl RegistryVisitor for LintRulesVisitor { self.push_rule::() } } + +#[derive(Default)] +pub(crate) struct SplinterRulesVisitor { + /// This is mapped to: + /// - group (performance, security) -> list of rules + /// - list or rules is mapped to + /// - rule name -> splinter metadata + pub(crate) groups: BTreeMap<&'static str, BTreeMap<&'static str, SplinterRuleMetadata>>, +} + +impl RegistryVisitor for SplinterRulesVisitor { + fn record_category(&mut self) { + // Splinter uses Lint category (like linter), so we need to accept it + if matches!(C::CATEGORY, RuleCategory::Lint) { + C::record_groups(self); + } + } + + fn record_rule(&mut self) + where + R: RuleMeta + 'static, + { + let group = self + .groups + .entry(::NAME) + .or_default(); + + // Get SQL content and Supabase requirement from registry + let sql_content = pgls_splinter::registry::get_sql_content(R::METADATA.name) + .unwrap_or("-- SQL content not found"); + let requires_supabase = pgls_splinter::registry::rule_requires_supabase(R::METADATA.name); + + // Extract description from SQL content metadata + let description = extract_description_from_sql(sql_content); + + group.insert( + R::METADATA.name, + SplinterRuleMetadata { + metadata: R::METADATA, + sql_content, + requires_supabase, + description, + }, + ); + } +} + +fn extract_description_from_sql(sql: &str) -> String { + // Look for "-- meta: description = ..." in SQL content + for line in sql.lines() { + if let Some(desc_line) = line.strip_prefix("-- meta: description = ") { + return desc_line.trim().to_string(); + } + } + "Detects potential issues in your database schema.".to_string() +} diff --git a/docs/reference/rules/auth-rls-initplan.md b/docs/reference/rules/auth-rls-initplan.md new file mode 100644 index 000000000..17163a8f4 --- /dev/null +++ b/docs/reference/rules/auth-rls-initplan.md @@ -0,0 +1,133 @@ +# authRlsInitplan + +**Diagnostic Category: `splinter/performance/authRlsInitplan`** + +**Severity**: Warning + +> [!NOTE] +> This rule requires a Supabase database/project and will be automatically skipped if not detected. + +## Description + +Detects if calls to \`current_setting()\` and \`auth.()\` in RLS policies are being unnecessarily re-evaluated for each row + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan) + +## SQL Query + +```sql +( +with policies as ( + select + nsp.nspname as schema_name, + pb.tablename as table_name, + pc.relrowsecurity as is_rls_active, + polname as policy_name, + polpermissive as is_permissive, -- if not, then restrictive + (select array_agg(r::regrole) from unnest(polroles) as x(r)) as roles, + case polcmd + when 'r' then 'SELECT' + when 'a' then 'INSERT' + when 'w' then 'UPDATE' + when 'd' then 'DELETE' + when '*' then 'ALL' + end as command, + qual, + with_check + from + pg_catalog.pg_policy pa + join pg_catalog.pg_class pc + on pa.polrelid = pc.oid + join pg_catalog.pg_namespace nsp + on pc.relnamespace = nsp.oid + join pg_catalog.pg_policies pb + on pc.relname = pb.tablename + and nsp.nspname = pb.schemaname + and pa.polname = pb.policyname +) +select + 'auth_rls_initplan' as "name!", + 'Auth RLS Initialization Plan' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['PERFORMANCE'] as "categories!", + 'Detects if calls to \`current_setting()\` and \`auth.()\` in RLS policies are being unnecessarily re-evaluated for each row' as "description!", + format( + 'Table \`%s.%s\` has a row level security policy \`%s\` that re-evaluates current_setting() or auth.() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing \`auth.()\` with \`(select auth.())\`. See [docs](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) for more info.', + schema_name, + table_name, + policy_name + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan' as "remediation!", + jsonb_build_object( + 'schema', schema_name, + 'name', table_name, + 'type', 'table' + ) as "metadata!", + format('auth_rls_init_plan_%s_%s_%s', schema_name, table_name, policy_name) as "cache_key!" +from + policies +where + is_rls_active + -- NOTE: does not include realtime in support of monitoring policies on realtime.messages + and schema_name not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + and ( + -- Example: auth.uid() + ( + qual like '%auth.uid()%' + and lower(qual) not like '%select auth.uid()%' + ) + or ( + qual like '%auth.jwt()%' + and lower(qual) not like '%select auth.jwt()%' + ) + or ( + qual like '%auth.role()%' + and lower(qual) not like '%select auth.role()%' + ) + or ( + qual like '%auth.email()%' + and lower(qual) not like '%select auth.email()%' + ) + or ( + qual like '%current\_setting(%)%' + and lower(qual) not like '%select current\_setting(%)%' + ) + or ( + with_check like '%auth.uid()%' + and lower(with_check) not like '%select auth.uid()%' + ) + or ( + with_check like '%auth.jwt()%' + and lower(with_check) not like '%select auth.jwt()%' + ) + or ( + with_check like '%auth.role()%' + and lower(with_check) not like '%select auth.role()%' + ) + or ( + with_check like '%auth.email()%' + and lower(with_check) not like '%select auth.email()%' + ) + or ( + with_check like '%current\_setting(%)%' + and lower(with_check) not like '%select current\_setting(%)%' + ) + )) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "performance": { + "authRlsInitplan": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/auth-users-exposed.md b/docs/reference/rules/auth-users-exposed.md new file mode 100644 index 000000000..70d8c7160 --- /dev/null +++ b/docs/reference/rules/auth-users-exposed.md @@ -0,0 +1,121 @@ +# authUsersExposed + +**Diagnostic Category: `splinter/security/authUsersExposed`** + +**Severity**: Error + +> [!NOTE] +> This rule requires a Supabase database/project and will be automatically skipped if not detected. + +## Description + +Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed) + +## SQL Query + +```sql +( +select + 'auth_users_exposed' as "name!", + 'Exposed Auth Users' as "title!", + 'ERROR' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security.' as "description!", + format( + 'View/Materialized View "%s" in the public schema may expose \`auth.users\` data to anon or authenticated roles.', + c.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'view', + 'exposed_to', array_remove(array_agg(DISTINCT case when pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') then 'anon' when pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') then 'authenticated' end), null) + ) as "metadata!", + format('auth_users_exposed_%s_%s', n.nspname, c.relname) as "cache_key!" +from + -- Identify the oid for auth.users + pg_catalog.pg_class auth_users_pg_class + join pg_catalog.pg_namespace auth_users_pg_namespace + on auth_users_pg_class.relnamespace = auth_users_pg_namespace.oid + and auth_users_pg_class.relname = 'users' + and auth_users_pg_namespace.nspname = 'auth' + -- Depends on auth.users + join pg_catalog.pg_depend d + on d.refobjid = auth_users_pg_class.oid + join pg_catalog.pg_rewrite r + on r.oid = d.objid + join pg_catalog.pg_class c + on c.oid = r.ev_class + join pg_catalog.pg_namespace n + on n.oid = c.relnamespace + join pg_catalog.pg_class pg_class_auth_users + on d.refobjid = pg_class_auth_users.oid +where + d.deptype = 'n' + and ( + pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') + or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') + ) + and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + -- Exclude self + and c.relname <> '0002_auth_users_exposed' + -- There are 3 insecure configurations + and + ( + -- Materialized views don't support RLS so this is insecure by default + (c.relkind in ('m')) -- m for materialized view + or + -- Standard View, accessible to anon or authenticated that is security_definer + ( + c.relkind = 'v' -- v for view + -- Exclude security invoker views + and not ( + lower(coalesce(c.reloptions::text,'{}'))::text[] + && array[ + 'security_invoker=1', + 'security_invoker=true', + 'security_invoker=yes', + 'security_invoker=on' + ] + ) + ) + or + -- Standard View, security invoker, but no RLS enabled on auth.users + ( + c.relkind in ('v') -- v for view + -- is security invoker + and ( + lower(coalesce(c.reloptions::text,'{}'))::text[] + && array[ + 'security_invoker=1', + 'security_invoker=true', + 'security_invoker=yes', + 'security_invoker=on' + ] + ) + and not pg_class_auth_users.relrowsecurity + ) + ) +group by + n.nspname, + c.relname, + c.oid) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "authUsersExposed": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/duplicate-index.md b/docs/reference/rules/duplicate-index.md new file mode 100644 index 000000000..7a89fcdb7 --- /dev/null +++ b/docs/reference/rules/duplicate-index.md @@ -0,0 +1,84 @@ +# duplicateIndex + +**Diagnostic Category: `splinter/performance/duplicateIndex`** + +**Severity**: Warning + +## Description + +Detects cases where two ore more identical indexes exist. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0009_duplicate_index) + +## SQL Query + +```sql +( +select + 'duplicate_index' as "name!", + 'Duplicate Index' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['PERFORMANCE'] as "categories!", + 'Detects cases where two ore more identical indexes exist.' as "description!", + format( + 'Table \`%s.%s\` has identical indexes %s. Drop all except one of them', + n.nspname, + c.relname, + array_agg(pi.indexname order by pi.indexname) + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0009_duplicate_index' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', case + when c.relkind = 'r' then 'table' + when c.relkind = 'm' then 'materialized view' + else 'ERROR' + end, + 'indexes', array_agg(pi.indexname order by pi.indexname) + ) as "metadata!", + format( + 'duplicate_index_%s_%s_%s', + n.nspname, + c.relname, + array_agg(pi.indexname order by pi.indexname) + ) as "cache_key!" +from + pg_catalog.pg_indexes pi + join pg_catalog.pg_namespace n + on n.nspname = pi.schemaname + join pg_catalog.pg_class c + on pi.tablename = c.relname + and n.oid = c.relnamespace + left join pg_catalog.pg_depend dep + on c.oid = dep.objid + and dep.deptype = 'e' +where + c.relkind in ('r', 'm') -- tables and materialized views + and n.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + and dep.objid is null -- exclude tables owned by extensions +group by + n.nspname, + c.relkind, + c.relname, + replace(pi.indexdef, pi.indexname, '') +having + count(*) > 1) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "performance": { + "duplicateIndex": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/extension-in-public.md b/docs/reference/rules/extension-in-public.md new file mode 100644 index 000000000..9bd981959 --- /dev/null +++ b/docs/reference/rules/extension-in-public.md @@ -0,0 +1,63 @@ +# extensionInPublic + +**Diagnostic Category: `splinter/security/extensionInPublic`** + +**Severity**: Warning + +## Description + +Detects extensions installed in the \`public\` schema. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0014_extension_in_public) + +## SQL Query + +```sql +( +select + 'extension_in_public' as "name!", + 'Extension in Public' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects extensions installed in the \`public\` schema.' as "description!", + format( + 'Extension \`%s\` is installed in the public schema. Move it to another schema.', + pe.extname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0014_extension_in_public' as "remediation!", + jsonb_build_object( + 'schema', pe.extnamespace::regnamespace, + 'name', pe.extname, + 'type', 'extension' + ) as "metadata!", + format( + 'extension_in_public_%s', + pe.extname + ) as "cache_key!" +from + pg_catalog.pg_extension pe +where + -- plpgsql is installed by default in public and outside user control + -- confirmed safe + pe.extname not in ('plpgsql') + -- Scoping this to public is not optimal. Ideally we would use the postgres + -- search path. That currently isn't available via SQL. In other lints + -- we have used has_schema_privilege('anon', 'extensions', 'USAGE') but that + -- is not appropriate here as it would evaluate true for the extensions schema + and pe.extnamespace::regnamespace::text = 'public') +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "extensionInPublic": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/extension-versions-outdated.md b/docs/reference/rules/extension-versions-outdated.md new file mode 100644 index 000000000..e6cf12a9b --- /dev/null +++ b/docs/reference/rules/extension-versions-outdated.md @@ -0,0 +1,68 @@ +# extensionVersionsOutdated + +**Diagnostic Category: `splinter/security/extensionVersionsOutdated`** + +**Severity**: Warning + +## Description + +Detects extensions that are not using the default (recommended) version. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0022_extension_versions_outdated) + +## SQL Query + +```sql +( +select + 'extension_versions_outdated' as "name!", + 'Extension Versions Outdated' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects extensions that are not using the default (recommended) version.' as "description!", + format( + 'Extension `%s` is using version `%s` but version `%s` is available. Using outdated extension versions may expose the database to security vulnerabilities.', + ext.name, + ext.installed_version, + ext.default_version + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0022_extension_versions_outdated' as "remediation!", + jsonb_build_object( + 'extension_name', ext.name, + 'installed_version', ext.installed_version, + 'default_version', ext.default_version + ) as "metadata!", + format( + 'extension_versions_outdated_%s_%s', + ext.name, + ext.installed_version + ) as "cache_key!" +from + pg_catalog.pg_available_extensions ext +join + -- ignore versions not in pg_available_extension_versions + -- e.g. residue of pg_upgrade + pg_catalog.pg_available_extension_versions extv + on extv.name = ext.name and extv.installed +where + ext.installed_version is not null + and ext.default_version is not null + and ext.installed_version != ext.default_version +order by + ext.name) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "extensionVersionsOutdated": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/fkey-to-auth-unique.md b/docs/reference/rules/fkey-to-auth-unique.md new file mode 100644 index 000000000..5151d07c2 --- /dev/null +++ b/docs/reference/rules/fkey-to-auth-unique.md @@ -0,0 +1,75 @@ +# fkeyToAuthUnique + +**Diagnostic Category: `splinter/security/fkeyToAuthUnique`** + +**Severity**: Error + +> [!NOTE] +> This rule requires a Supabase database/project and will be automatically skipped if not detected. + +## Description + +Detects user defined foreign keys to unique constraints in the auth schema. + +[Learn More](Drop the foreign key constraint that references the auth schema.) + +## SQL Query + +```sql +( +select + 'fkey_to_auth_unique' as "name!", + 'Foreign Key to Auth Unique Constraint' as "title!", + 'ERROR' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects user defined foreign keys to unique constraints in the auth schema.' as "description!", + format( + 'Table `%s`.`%s` has a foreign key `%s` referencing an auth unique constraint', + n.nspname, -- referencing schema + c_rel.relname, -- referencing table + c.conname -- fkey name + ) as "detail!", + 'Drop the foreign key constraint that references the auth schema.' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c_rel.relname, + 'foreign_key', c.conname + ) as "metadata!", + format( + 'fkey_to_auth_unique_%s_%s_%s', + n.nspname, -- referencing schema + c_rel.relname, -- referencing table + c.conname + ) as "cache_key!" +from + pg_catalog.pg_constraint c + join pg_catalog.pg_class c_rel + on c.conrelid = c_rel.oid + join pg_catalog.pg_namespace n + on c_rel.relnamespace = n.oid + join pg_catalog.pg_class ref_rel + on c.confrelid = ref_rel.oid + join pg_catalog.pg_namespace cn + on ref_rel.relnamespace = cn.oid + join pg_catalog.pg_index i + on c.conindid = i.indexrelid +where c.contype = 'f' + and cn.nspname = 'auth' + and i.indisunique + and not i.indisprimary) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "fkeyToAuthUnique": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/foreign-table-in-api.md b/docs/reference/rules/foreign-table-in-api.md new file mode 100644 index 000000000..35a77cb03 --- /dev/null +++ b/docs/reference/rules/foreign-table-in-api.md @@ -0,0 +1,75 @@ +# foreignTableInApi + +**Diagnostic Category: `splinter/security/foreignTableInApi`** + +**Severity**: Warning + +> [!NOTE] +> This rule requires a Supabase database/project and will be automatically skipped if not detected. + +## Description + +Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api) + +## SQL Query + +```sql +( +select + 'foreign_table_in_api' as "name!", + 'Foreign Table in API' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies.' as "description!", + format( + 'Foreign table \`%s.%s\` is accessible over APIs', + n.nspname, + c.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'foreign table' + ) as "metadata!", + format( + 'foreign_table_in_api_%s_%s', + n.nspname, + c.relname + ) as "cache_key!" +from + pg_catalog.pg_class c + join pg_catalog.pg_namespace n + on n.oid = c.relnamespace + left join pg_catalog.pg_depend dep + on c.oid = dep.objid + and dep.deptype = 'e' +where + c.relkind = 'f' + and ( + pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') + or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') + ) + and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + and dep.objid is null) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "foreignTableInApi": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/function-search-path-mutable.md b/docs/reference/rules/function-search-path-mutable.md new file mode 100644 index 000000000..c9e1f72a1 --- /dev/null +++ b/docs/reference/rules/function-search-path-mutable.md @@ -0,0 +1,73 @@ +# functionSearchPathMutable + +**Diagnostic Category: `splinter/security/functionSearchPathMutable`** + +**Severity**: Warning + +## Description + +Detects functions where the search_path parameter is not set. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0011_function_search_path_mutable) + +## SQL Query + +```sql +( +select + 'function_search_path_mutable' as "name!", + 'Function Search Path Mutable' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects functions where the search_path parameter is not set.' as "description!", + format( + 'Function \`%s.%s\` has a role mutable search_path', + n.nspname, + p.proname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0011_function_search_path_mutable' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', p.proname, + 'type', 'function' + ) as "metadata!", + format( + 'function_search_path_mutable_%s_%s_%s', + n.nspname, + p.proname, + md5(p.prosrc) -- required when function is polymorphic + ) as "cache_key!" +from + pg_catalog.pg_proc p + join pg_catalog.pg_namespace n + on p.pronamespace = n.oid + left join pg_catalog.pg_depend dep + on p.oid = dep.objid + and dep.deptype = 'e' +where + n.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + and dep.objid is null -- exclude functions owned by extensions + -- Search path not set + and not exists ( + select 1 + from unnest(coalesce(p.proconfig, '{}')) as config + where config like 'search_path=%' + )) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "functionSearchPathMutable": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/insecure-queue-exposed-in-api.md b/docs/reference/rules/insecure-queue-exposed-in-api.md new file mode 100644 index 000000000..a8fe64534 --- /dev/null +++ b/docs/reference/rules/insecure-queue-exposed-in-api.md @@ -0,0 +1,72 @@ +# insecureQueueExposedInApi + +**Diagnostic Category: `splinter/security/insecureQueueExposedInApi`** + +**Severity**: Error + +> [!NOTE] +> This rule requires a Supabase database/project and will be automatically skipped if not detected. + +## Description + +Detects cases where an insecure Queue is exposed over Data APIs + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0019_insecure_queue_exposed_in_api) + +## SQL Query + +```sql +( +select + 'insecure_queue_exposed_in_api' as "name!", + 'Insecure Queue Exposed in API' as "title!", + 'ERROR' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects cases where an insecure Queue is exposed over Data APIs' as "description!", + format( + 'Table \`%s.%s\` is public, but RLS has not been enabled.', + n.nspname, + c.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0019_insecure_queue_exposed_in_api' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'table' + ) as "metadata!", + format( + 'rls_disabled_in_public_%s_%s', + n.nspname, + c.relname + ) as "cache_key!" +from + pg_catalog.pg_class c + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid +where + c.relkind in ('r', 'I') -- regular or partitioned tables + and not c.relrowsecurity -- RLS is disabled + and ( + pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') + or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') + ) + and n.nspname = 'pgmq' -- tables in the pgmq schema + and c.relname like 'q_%' -- only queue tables + -- Constant requirements + and 'pgmq_public' = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "insecureQueueExposedInApi": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/materialized-view-in-api.md b/docs/reference/rules/materialized-view-in-api.md new file mode 100644 index 000000000..e118218aa --- /dev/null +++ b/docs/reference/rules/materialized-view-in-api.md @@ -0,0 +1,75 @@ +# materializedViewInApi + +**Diagnostic Category: `splinter/security/materializedViewInApi`** + +**Severity**: Warning + +> [!NOTE] +> This rule requires a Supabase database/project and will be automatically skipped if not detected. + +## Description + +Detects materialized views that are accessible over the Data APIs. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api) + +## SQL Query + +```sql +( +select + 'materialized_view_in_api' as "name!", + 'Materialized View in API' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects materialized views that are accessible over the Data APIs.' as "description!", + format( + 'Materialized view \`%s.%s\` is selectable by anon or authenticated roles', + n.nspname, + c.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'materialized view' + ) as "metadata!", + format( + 'materialized_view_in_api_%s_%s', + n.nspname, + c.relname + ) as "cache_key!" +from + pg_catalog.pg_class c + join pg_catalog.pg_namespace n + on n.oid = c.relnamespace + left join pg_catalog.pg_depend dep + on c.oid = dep.objid + and dep.deptype = 'e' +where + c.relkind = 'm' + and ( + pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') + or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') + ) + and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + and dep.objid is null) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "materializedViewInApi": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/multiple-permissive-policies.md b/docs/reference/rules/multiple-permissive-policies.md new file mode 100644 index 000000000..cbcf37650 --- /dev/null +++ b/docs/reference/rules/multiple-permissive-policies.md @@ -0,0 +1,102 @@ +# multiplePermissivePolicies + +**Diagnostic Category: `splinter/performance/multiplePermissivePolicies`** + +**Severity**: Warning + +## Description + +Detects if multiple permissive row level security policies are present on a table for the same \`role\` and \`action\` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies) + +## SQL Query + +```sql +( +select + 'multiple_permissive_policies' as "name!", + 'Multiple Permissive Policies' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['PERFORMANCE'] as "categories!", + 'Detects if multiple permissive row level security policies are present on a table for the same \`role\` and \`action\` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.' as "description!", + format( + 'Table \`%s.%s\` has multiple permissive policies for role \`%s\` for action \`%s\`. Policies include \`%s\`', + n.nspname, + c.relname, + r.rolname, + act.cmd, + array_agg(p.polname order by p.polname) + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'table' + ) as "metadata!", + format( + 'multiple_permissive_policies_%s_%s_%s_%s', + n.nspname, + c.relname, + r.rolname, + act.cmd + ) as "cache_key!" +from + pg_catalog.pg_policy p + join pg_catalog.pg_class c + on p.polrelid = c.oid + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid + join pg_catalog.pg_roles r + on p.polroles @> array[r.oid] + or p.polroles = array[0::oid] + left join pg_catalog.pg_depend dep + on c.oid = dep.objid + and dep.deptype = 'e', + lateral ( + select x.cmd + from unnest(( + select + case p.polcmd + when 'r' then array['SELECT'] + when 'a' then array['INSERT'] + when 'w' then array['UPDATE'] + when 'd' then array['DELETE'] + when '*' then array['SELECT', 'INSERT', 'UPDATE', 'DELETE'] + else array['ERROR'] + end as actions + )) x(cmd) + ) act(cmd) +where + c.relkind = 'r' -- regular tables + and p.polpermissive -- policy is permissive + and n.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + and r.rolname not like 'pg_%' + and r.rolname not like 'supabase%admin' + and not r.rolbypassrls + and dep.objid is null -- exclude tables owned by extensions +group by + n.nspname, + c.relname, + r.rolname, + act.cmd +having + count(1) > 1) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "performance": { + "multiplePermissivePolicies": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/no-primary-key.md b/docs/reference/rules/no-primary-key.md new file mode 100644 index 000000000..70042250b --- /dev/null +++ b/docs/reference/rules/no-primary-key.md @@ -0,0 +1,75 @@ +# noPrimaryKey + +**Diagnostic Category: `splinter/performance/noPrimaryKey`** + +**Severity**: Info + +## Description + +Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0004_no_primary_key) + +## SQL Query + +```sql +( +select + 'no_primary_key' as "name!", + 'No Primary Key' as "title!", + 'INFO' as "level!", + 'EXTERNAL' as "facing!", + array['PERFORMANCE'] as "categories!", + 'Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale.' as "description!", + format( + 'Table \`%s.%s\` does not have a primary key', + pgns.nspname, + pgc.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0004_no_primary_key' as "remediation!", + jsonb_build_object( + 'schema', pgns.nspname, + 'name', pgc.relname, + 'type', 'table' + ) as "metadata!", + format( + 'no_primary_key_%s_%s', + pgns.nspname, + pgc.relname + ) as "cache_key!" +from + pg_catalog.pg_class pgc + join pg_catalog.pg_namespace pgns + on pgns.oid = pgc.relnamespace + left join pg_catalog.pg_index pgi + on pgi.indrelid = pgc.oid + left join pg_catalog.pg_depend dep + on pgc.oid = dep.objid + and dep.deptype = 'e' +where + pgc.relkind = 'r' -- regular tables + and pgns.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + and dep.objid is null -- exclude tables owned by extensions +group by + pgc.oid, + pgns.nspname, + pgc.relname +having + max(coalesce(pgi.indisprimary, false)::int) = 0) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "performance": { + "noPrimaryKey": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/policy-exists-rls-disabled.md b/docs/reference/rules/policy-exists-rls-disabled.md new file mode 100644 index 000000000..e4f46c702 --- /dev/null +++ b/docs/reference/rules/policy-exists-rls-disabled.md @@ -0,0 +1,75 @@ +# policyExistsRlsDisabled + +**Diagnostic Category: `splinter/security/policyExistsRlsDisabled`** + +**Severity**: Error + +## Description + +Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0007_policy_exists_rls_disabled) + +## SQL Query + +```sql +( +select + 'policy_exists_rls_disabled' as "name!", + 'Policy Exists RLS Disabled' as "title!", + 'ERROR' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table.' as "description!", + format( + 'Table \`%s.%s\` has RLS policies but RLS is not enabled on the table. Policies include %s.', + n.nspname, + c.relname, + array_agg(p.polname order by p.polname) + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0007_policy_exists_rls_disabled' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'table' + ) as "metadata!", + format( + 'policy_exists_rls_disabled_%s_%s', + n.nspname, + c.relname + ) as "cache_key!" +from + pg_catalog.pg_policy p + join pg_catalog.pg_class c + on p.polrelid = c.oid + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid + left join pg_catalog.pg_depend dep + on c.oid = dep.objid + and dep.deptype = 'e' +where + c.relkind = 'r' -- regular tables + and n.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + -- RLS is disabled + and not c.relrowsecurity + and dep.objid is null -- exclude tables owned by extensions +group by + n.nspname, + c.relname) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "policyExistsRlsDisabled": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/rls-disabled-in-public.md b/docs/reference/rules/rls-disabled-in-public.md new file mode 100644 index 000000000..339bace74 --- /dev/null +++ b/docs/reference/rules/rls-disabled-in-public.md @@ -0,0 +1,73 @@ +# rlsDisabledInPublic + +**Diagnostic Category: `splinter/security/rlsDisabledInPublic`** + +**Severity**: Error + +> [!NOTE] +> This rule requires a Supabase database/project and will be automatically skipped if not detected. + +## Description + +Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public) + +## SQL Query + +```sql +( +select + 'rls_disabled_in_public' as "name!", + 'RLS Disabled in Public' as "title!", + 'ERROR' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST' as "description!", + format( + 'Table \`%s.%s\` is public, but RLS has not been enabled.', + n.nspname, + c.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'table' + ) as "metadata!", + format( + 'rls_disabled_in_public_%s_%s', + n.nspname, + c.relname + ) as "cache_key!" +from + pg_catalog.pg_class c + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid +where + c.relkind = 'r' -- regular tables + -- RLS is disabled + and not c.relrowsecurity + and ( + pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') + or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') + ) + and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + )) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "rlsDisabledInPublic": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/rls-enabled-no-policy.md b/docs/reference/rules/rls-enabled-no-policy.md new file mode 100644 index 000000000..644f874b3 --- /dev/null +++ b/docs/reference/rules/rls-enabled-no-policy.md @@ -0,0 +1,75 @@ +# rlsEnabledNoPolicy + +**Diagnostic Category: `splinter/security/rlsEnabledNoPolicy`** + +**Severity**: Info + +## Description + +Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0008_rls_enabled_no_policy) + +## SQL Query + +```sql +( +select + 'rls_enabled_no_policy' as "name!", + 'RLS Enabled No Policy' as "title!", + 'INFO' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created.' as "description!", + format( + 'Table \`%s.%s\` has RLS enabled, but no policies exist', + n.nspname, + c.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0008_rls_enabled_no_policy' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'table' + ) as "metadata!", + format( + 'rls_enabled_no_policy_%s_%s', + n.nspname, + c.relname + ) as "cache_key!" +from + pg_catalog.pg_class c + left join pg_catalog.pg_policy p + on p.polrelid = c.oid + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid + left join pg_catalog.pg_depend dep + on c.oid = dep.objid + and dep.deptype = 'e' +where + c.relkind = 'r' -- regular tables + and n.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + -- RLS is enabled + and c.relrowsecurity + and p.polname is null + and dep.objid is null -- exclude tables owned by extensions +group by + n.nspname, + c.relname) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "rlsEnabledNoPolicy": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/rls-references-user-metadata.md b/docs/reference/rules/rls-references-user-metadata.md new file mode 100644 index 000000000..955c68512 --- /dev/null +++ b/docs/reference/rules/rls-references-user-metadata.md @@ -0,0 +1,87 @@ +# rlsReferencesUserMetadata + +**Diagnostic Category: `splinter/security/rlsReferencesUserMetadata`** + +**Severity**: Error + +> [!NOTE] +> This rule requires a Supabase database/project and will be automatically skipped if not detected. + +## Description + +Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata) + +## SQL Query + +```sql +( +with policies as ( + select + nsp.nspname as schema_name, + pb.tablename as table_name, + polname as policy_name, + qual, + with_check + from + pg_catalog.pg_policy pa + join pg_catalog.pg_class pc + on pa.polrelid = pc.oid + join pg_catalog.pg_namespace nsp + on pc.relnamespace = nsp.oid + join pg_catalog.pg_policies pb + on pc.relname = pb.tablename + and nsp.nspname = pb.schemaname + and pa.polname = pb.policyname +) +select + 'rls_references_user_metadata' as "name!", + 'RLS references user metadata' as "title!", + 'ERROR' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy.' as "description!", + format( + 'Table \`%s.%s\` has a row level security policy \`%s\` that references Supabase Auth \`user_metadata\`. \`user_metadata\` is editable by end users and should never be used in a security context.', + schema_name, + table_name, + policy_name + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata' as "remediation!", + jsonb_build_object( + 'schema', schema_name, + 'name', table_name, + 'type', 'table' + ) as "metadata!", + format('rls_references_user_metadata_%s_%s_%s', schema_name, table_name, policy_name) as "cache_key!" +from + policies +where + schema_name not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + and ( + -- Example: auth.jwt() -> 'user_metadata' + -- False positives are possible, but it isn't practical to string match + -- If false positive rate is too high, this expression can iterate + qual like '%auth.jwt()%user_metadata%' + or qual like '%current_setting(%request.jwt.claims%)%user_metadata%' + or with_check like '%auth.jwt()%user_metadata%' + or with_check like '%current_setting(%request.jwt.claims%)%user_metadata%' + )) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "rlsReferencesUserMetadata": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/security-definer-view.md b/docs/reference/rules/security-definer-view.md new file mode 100644 index 000000000..8dde33a3e --- /dev/null +++ b/docs/reference/rules/security-definer-view.md @@ -0,0 +1,85 @@ +# securityDefinerView + +**Diagnostic Category: `splinter/security/securityDefinerView`** + +**Severity**: Error + +> [!NOTE] +> This rule requires a Supabase database/project and will be automatically skipped if not detected. + +## Description + +Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view) + +## SQL Query + +```sql +( +select + 'security_definer_view' as "name!", + 'Security Definer View' as "title!", + 'ERROR' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user' as "description!", + format( + 'View \`%s.%s\` is defined with the SECURITY DEFINER property', + n.nspname, + c.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'view' + ) as "metadata!", + format( + 'security_definer_view_%s_%s', + n.nspname, + c.relname + ) as "cache_key!" +from + pg_catalog.pg_class c + join pg_catalog.pg_namespace n + on n.oid = c.relnamespace + left join pg_catalog.pg_depend dep + on c.oid = dep.objid + and dep.deptype = 'e' +where + c.relkind = 'v' + and ( + pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') + or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') + ) + and substring(pg_catalog.version() from 'PostgreSQL ([0-9]+)') >= '15' -- security invoker was added in pg15 + and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + and dep.objid is null -- exclude views owned by extensions + and not ( + lower(coalesce(c.reloptions::text,'{}'))::text[] + && array[ + 'security_invoker=1', + 'security_invoker=true', + 'security_invoker=yes', + 'security_invoker=on' + ] + )) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "securityDefinerView": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/table-bloat.md b/docs/reference/rules/table-bloat.md new file mode 100644 index 000000000..808410b7a --- /dev/null +++ b/docs/reference/rules/table-bloat.md @@ -0,0 +1,128 @@ +# tableBloat + +**Diagnostic Category: `splinter/performance/tableBloat`** + +**Severity**: Info + +## Description + +Detects if a table has excess bloat and may benefit from maintenance operations like vacuum full or cluster. + +[Learn More](Consider running vacuum full (WARNING: incurs downtime) and tweaking autovacuum settings to reduce bloat.) + +## SQL Query + +```sql +( +with constants as ( + select current_setting('block_size')::numeric as bs, 23 as hdr, 4 as ma +), + +bloat_info as ( + select + ma, + bs, + schemaname, + tablename, + (datawidth + (hdr + ma - (case when hdr % ma = 0 then ma else hdr % ma end)))::numeric as datahdr, + (maxfracsum * (nullhdr + ma - (case when nullhdr % ma = 0 then ma else nullhdr % ma end))) as nullhdr2 + from ( + select + schemaname, + tablename, + hdr, + ma, + bs, + sum((1 - null_frac) * avg_width) as datawidth, + max(null_frac) as maxfracsum, + hdr + ( + select 1 + count(*) / 8 + from pg_stats s2 + where + null_frac <> 0 + and s2.schemaname = s.schemaname + and s2.tablename = s.tablename + ) as nullhdr + from pg_stats s, constants + group by 1, 2, 3, 4, 5 + ) as foo +), + +table_bloat as ( + select + schemaname, + tablename, + cc.relpages, + bs, + ceil((cc.reltuples * ((datahdr + ma - + (case when datahdr % ma = 0 then ma else datahdr % ma end)) + nullhdr2 + 4)) / (bs - 20::float)) as otta + from + bloat_info + join pg_class cc + on cc.relname = bloat_info.tablename + join pg_namespace nn + on cc.relnamespace = nn.oid + and nn.nspname = bloat_info.schemaname + and nn.nspname <> 'information_schema' + where + cc.relkind = 'r' + and cc.relam = (select oid from pg_am where amname = 'heap') +), + +bloat_data as ( + select + 'table' as type, + schemaname, + tablename as object_name, + round(case when otta = 0 then 0.0 else table_bloat.relpages / otta::numeric end, 1) as bloat, + case when relpages < otta then 0 else (bs * (table_bloat.relpages - otta)::bigint)::bigint end as raw_waste + from + table_bloat +) + +select + 'table_bloat' as "name!", + 'Table Bloat' as "title!", + 'INFO' as "level!", + 'EXTERNAL' as "facing!", + array['PERFORMANCE'] as "categories!", + 'Detects if a table has excess bloat and may benefit from maintenance operations like vacuum full or cluster.' as "description!", + format( + 'Table `%s`.`%s` has excessive bloat', + bloat_data.schemaname, + bloat_data.object_name + ) as "detail!", + 'Consider running vacuum full (WARNING: incurs downtime) and tweaking autovacuum settings to reduce bloat.' as "remediation!", + jsonb_build_object( + 'schema', bloat_data.schemaname, + 'name', bloat_data.object_name, + 'type', bloat_data.type + ) as "metadata!", + format( + 'table_bloat_%s_%s', + bloat_data.schemaname, + bloat_data.object_name + ) as "cache_key!" +from + bloat_data +where + bloat > 70.0 + and raw_waste > (20 * 1024 * 1024) -- filter for waste > 200 MB +order by + schemaname, + object_name) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "performance": { + "tableBloat": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/unindexed-foreign-keys.md b/docs/reference/rules/unindexed-foreign-keys.md new file mode 100644 index 000000000..2a978ec65 --- /dev/null +++ b/docs/reference/rules/unindexed-foreign-keys.md @@ -0,0 +1,101 @@ +# unindexedForeignKeys + +**Diagnostic Category: `splinter/performance/unindexedForeignKeys`** + +**Severity**: Info + +## Description + +Identifies foreign key constraints without a covering index, which can impact database performance. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys) + +## SQL Query + +```sql +with foreign_keys as ( + select + cl.relnamespace::regnamespace::text as schema_name, + cl.relname as table_name, + cl.oid as table_oid, + ct.conname as fkey_name, + ct.conkey as col_attnums + from + pg_catalog.pg_constraint ct + join pg_catalog.pg_class cl -- fkey owning table + on ct.conrelid = cl.oid + left join pg_catalog.pg_depend d + on d.objid = cl.oid + and d.deptype = 'e' + where + ct.contype = 'f' -- foreign key constraints + and d.objid is null -- exclude tables that are dependencies of extensions + and cl.relnamespace::regnamespace::text not in ( + 'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'extensions' + ) +), +index_ as ( + select + pi.indrelid as table_oid, + indexrelid::regclass as index_, + string_to_array(indkey::text, ' ')::smallint[] as col_attnums + from + pg_catalog.pg_index pi + where + indisvalid +) +select + 'unindexed_foreign_keys' as "name!", + 'Unindexed foreign keys' as "title!", + 'INFO' as "level!", + 'EXTERNAL' as "facing!", + array['PERFORMANCE'] as "categories!", + 'Identifies foreign key constraints without a covering index, which can impact database performance.' as "description!", + format( + 'Table `%s.%s` has a foreign key `%s` without a covering index. This can lead to suboptimal query performance.', + fk.schema_name, + fk.table_name, + fk.fkey_name + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys' as "remediation!", + jsonb_build_object( + 'schema', fk.schema_name, + 'name', fk.table_name, + 'type', 'table', + 'fkey_name', fk.fkey_name, + 'fkey_columns', fk.col_attnums + ) as "metadata!", + format('unindexed_foreign_keys_%s_%s_%s', fk.schema_name, fk.table_name, fk.fkey_name) as "cache_key!" +from + foreign_keys fk + left join index_ idx + on fk.table_oid = idx.table_oid + and fk.col_attnums = idx.col_attnums[1:array_length(fk.col_attnums, 1)] + left join pg_catalog.pg_depend dep + on idx.table_oid = dep.objid + and dep.deptype = 'e' +where + idx.index_ is null + and fk.schema_name not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + and dep.objid is null -- exclude tables owned by extensions +order by + fk.schema_name, + fk.table_name, + fk.fkey_name +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "performance": { + "unindexedForeignKeys": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/unsupported-reg-types.md b/docs/reference/rules/unsupported-reg-types.md new file mode 100644 index 000000000..6922bd008 --- /dev/null +++ b/docs/reference/rules/unsupported-reg-types.md @@ -0,0 +1,72 @@ +# unsupportedRegTypes + +**Diagnostic Category: `splinter/security/unsupportedRegTypes`** + +**Severity**: Warning + +## Description + +Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=unsupported_reg_types) + +## SQL Query + +```sql +( +select + 'unsupported_reg_types' as "name!", + 'Unsupported reg types' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade.' as "description!", + format( + 'Table \`%s.%s\` has a column \`%s\` with unsupported reg* type \`%s\`.', + n.nspname, + c.relname, + a.attname, + t.typname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=unsupported_reg_types' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'column', a.attname, + 'type', 'table' + ) as "metadata!", + format( + 'unsupported_reg_types_%s_%s_%s', + n.nspname, + c.relname, + a.attname + ) AS cache_key +from + pg_catalog.pg_attribute a + join pg_catalog.pg_class c + on a.attrelid = c.oid + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid + join pg_catalog.pg_type t + on a.atttypid = t.oid + join pg_catalog.pg_namespace tn + on t.typnamespace = tn.oid +where + tn.nspname = 'pg_catalog' + and t.typname in ('regcollation', 'regconfig', 'regdictionary', 'regnamespace', 'regoper', 'regoperator', 'regproc', 'regprocedure') + and n.nspname not in ('pg_catalog', 'information_schema', 'pgsodium')) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "unsupportedRegTypes": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/unused-index.md b/docs/reference/rules/unused-index.md new file mode 100644 index 000000000..e0f5d1d83 --- /dev/null +++ b/docs/reference/rules/unused-index.md @@ -0,0 +1,72 @@ +# unusedIndex + +**Diagnostic Category: `splinter/performance/unusedIndex`** + +**Severity**: Info + +## Description + +Detects if an index has never been used and may be a candidate for removal. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0005_unused_index) + +## SQL Query + +```sql +( +select + 'unused_index' as "name!", + 'Unused Index' as "title!", + 'INFO' as "level!", + 'EXTERNAL' as "facing!", + array['PERFORMANCE'] as "categories!", + 'Detects if an index has never been used and may be a candidate for removal.' as "description!", + format( + 'Index \`%s\` on table \`%s.%s\` has not been used', + psui.indexrelname, + psui.schemaname, + psui.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0005_unused_index' as "remediation!", + jsonb_build_object( + 'schema', psui.schemaname, + 'name', psui.relname, + 'type', 'table' + ) as "metadata!", + format( + 'unused_index_%s_%s_%s', + psui.schemaname, + psui.relname, + psui.indexrelname + ) as "cache_key!" + +from + pg_catalog.pg_stat_user_indexes psui + join pg_catalog.pg_index pi + on psui.indexrelid = pi.indexrelid + left join pg_catalog.pg_depend dep + on psui.relid = dep.objid + and dep.deptype = 'e' +where + psui.idx_scan = 0 + and not pi.indisunique + and not pi.indisprimary + and dep.objid is null -- exclude tables owned by extensions + and psui.schemaname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + )) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "performance": { + "unusedIndex": "error" + } + } + } +} +``` From 22376a6956d1547bced1f2dad4047fb541a5b180 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 15 Dec 2025 17:36:39 +0100 Subject: [PATCH 26/32] fix: apply clippy formatting to splinter_docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/codegen/src/splinter_docs.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/codegen/src/splinter_docs.rs b/docs/codegen/src/splinter_docs.rs index a9512f4e3..357007e1c 100644 --- a/docs/codegen/src/splinter_docs.rs +++ b/docs/codegen/src/splinter_docs.rs @@ -76,7 +76,7 @@ fn generate_splinter_rule_doc( pgls_diagnostics::Severity::Error => "Error", _ => "Info", }; - writeln!(content, "**Severity**: {}", severity_str)?; + writeln!(content, "**Severity**: {severity_str}")?; writeln!(content)?; // Add Supabase requirement notice @@ -98,7 +98,7 @@ fn generate_splinter_rule_doc( // Add "Learn More" link with remediation URL if let Some(remediation) = extract_remediation_from_sql(splinter_meta.sql_content) { - writeln!(content, "[Learn More]({})", remediation)?; + writeln!(content, "[Learn More]({remediation})")?; writeln!(content)?; } @@ -107,7 +107,7 @@ fn generate_splinter_rule_doc( writeln!(content)?; writeln!(content, "```sql")?; let sql_without_metadata = strip_metadata_from_sql(splinter_meta.sql_content); - writeln!(content, "{}", sql_without_metadata)?; + writeln!(content, "{sql_without_metadata}")?; writeln!(content, "```")?; writeln!(content)?; From dae4622c5f7583e5960617c1e074495fc75bc01f Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 15 Dec 2025 17:53:20 +0100 Subject: [PATCH 27/32] refactor: expose structured metadata through splinter registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of re-parsing SQL comment metadata in docs codegen, expose structured metadata directly through the registry API. Changes: - Add SplinterRuleMetadata struct (description, remediation, requires_supabase) - Add get_rule_metadata() function to registry - Update docs codegen to use structured metadata - Remove duplicate SQL parsing logic - Update runtime to use new metadata API - Deprecate rule_requires_supabase() in favor of get_rule_metadata() This eliminates redundant parsing and provides a cleaner API. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- crates/pgls_splinter/src/lib.rs | 8 +- crates/pgls_splinter/src/registry.rs | 125 +++++++++++++++++++++++++ docs/codegen/src/splinter_docs.rs | 25 ++--- docs/codegen/src/utils.rs | 30 ++---- xtask/codegen/src/generate_splinter.rs | 42 +++++++++ 5 files changed, 190 insertions(+), 40 deletions(-) diff --git a/crates/pgls_splinter/src/lib.rs b/crates/pgls_splinter/src/lib.rs index 21b272855..58c56709b 100644 --- a/crates/pgls_splinter/src/lib.rs +++ b/crates/pgls_splinter/src/lib.rs @@ -79,8 +79,12 @@ pub async fn run_splinter( for rule_name in &collector.enabled_rules { // Skip Supabase-specific rules if Supabase roles don't exist - if !has_supabase_roles && crate::registry::rule_requires_supabase(rule_name) { - continue; + if !has_supabase_roles { + if let Some(metadata) = crate::registry::get_rule_metadata(rule_name) { + if metadata.requires_supabase { + continue; + } + } } // Get embedded SQL content (compile-time included) diff --git a/crates/pgls_splinter/src/registry.rs b/crates/pgls_splinter/src/registry.rs index aa84f4e2a..2defb6161 100644 --- a/crates/pgls_splinter/src/registry.rs +++ b/crates/pgls_splinter/src/registry.rs @@ -2,6 +2,16 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use pgls_analyse::RegistryVisitor; +#[doc = r" Metadata for a splinter rule"] +#[derive(Debug, Clone, Copy)] +pub struct SplinterRuleMetadata { + #[doc = r" Description of what the rule detects"] + pub description: &'static str, + #[doc = r" URL to documentation/remediation guide"] + pub remediation: &'static str, + #[doc = r" Whether this rule requires Supabase roles (anon, authenticated, service_role)"] + pub requires_supabase: bool, +} #[doc = r" Visit all splinter rules using the visitor pattern"] #[doc = r" This is called during registry building to collect enabled rules"] pub fn visit_registry(registry: &mut V) { @@ -151,6 +161,120 @@ pub fn get_sql_content(rule_name: &str) -> Option<&'static str> { _ => None, } } +#[doc = r" Get metadata for a rule (camelCase name)"] +#[doc = r" Returns None if rule not found"] +#[doc = r""] +#[doc = r" This provides structured access to rule metadata without requiring SQL parsing"] +pub fn get_rule_metadata(rule_name: &str) -> Option { + match rule_name { + "authRlsInitplan" => Some(SplinterRuleMetadata { + description: "Detects if calls to \\`current_setting()\\` and \\`auth.()\\` in RLS policies are being unnecessarily re-evaluated for each row", + remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan", + requires_supabase: true, + }), + "authUsersExposed" => Some(SplinterRuleMetadata { + description: "Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security.", + remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed", + requires_supabase: true, + }), + "duplicateIndex" => Some(SplinterRuleMetadata { + description: "Detects cases where two ore more identical indexes exist.", + remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0009_duplicate_index", + requires_supabase: false, + }), + "extensionInPublic" => Some(SplinterRuleMetadata { + description: "Detects extensions installed in the \\`public\\` schema.", + remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0014_extension_in_public", + requires_supabase: false, + }), + "extensionVersionsOutdated" => Some(SplinterRuleMetadata { + description: "Detects extensions that are not using the default (recommended) version.", + remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0022_extension_versions_outdated", + requires_supabase: false, + }), + "fkeyToAuthUnique" => Some(SplinterRuleMetadata { + description: "Detects user defined foreign keys to unique constraints in the auth schema.", + remediation: "Drop the foreign key constraint that references the auth schema.", + requires_supabase: true, + }), + "foreignTableInApi" => Some(SplinterRuleMetadata { + description: "Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies.", + remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api", + requires_supabase: true, + }), + "functionSearchPathMutable" => Some(SplinterRuleMetadata { + description: "Detects functions where the search_path parameter is not set.", + remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0011_function_search_path_mutable", + requires_supabase: false, + }), + "insecureQueueExposedInApi" => Some(SplinterRuleMetadata { + description: "Detects cases where an insecure Queue is exposed over Data APIs", + remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0019_insecure_queue_exposed_in_api", + requires_supabase: true, + }), + "materializedViewInApi" => Some(SplinterRuleMetadata { + description: "Detects materialized views that are accessible over the Data APIs.", + remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api", + requires_supabase: true, + }), + "multiplePermissivePolicies" => Some(SplinterRuleMetadata { + description: "Detects if multiple permissive row level security policies are present on a table for the same \\`role\\` and \\`action\\` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.", + remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies", + requires_supabase: false, + }), + "noPrimaryKey" => Some(SplinterRuleMetadata { + description: "Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale.", + remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0004_no_primary_key", + requires_supabase: false, + }), + "policyExistsRlsDisabled" => Some(SplinterRuleMetadata { + description: "Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table.", + remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0007_policy_exists_rls_disabled", + requires_supabase: false, + }), + "rlsDisabledInPublic" => Some(SplinterRuleMetadata { + description: "Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST", + remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public", + requires_supabase: true, + }), + "rlsEnabledNoPolicy" => Some(SplinterRuleMetadata { + description: "Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created.", + remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0008_rls_enabled_no_policy", + requires_supabase: false, + }), + "rlsReferencesUserMetadata" => Some(SplinterRuleMetadata { + description: "Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy.", + remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata", + requires_supabase: true, + }), + "securityDefinerView" => Some(SplinterRuleMetadata { + description: "Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user", + remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view", + requires_supabase: true, + }), + "tableBloat" => Some(SplinterRuleMetadata { + description: "Detects if a table has excess bloat and may benefit from maintenance operations like vacuum full or cluster.", + remediation: "Consider running vacuum full (WARNING: incurs downtime) and tweaking autovacuum settings to reduce bloat.", + requires_supabase: false, + }), + "unindexedForeignKeys" => Some(SplinterRuleMetadata { + description: "Identifies foreign key constraints without a covering index, which can impact database performance.", + remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys", + requires_supabase: false, + }), + "unsupportedRegTypes" => Some(SplinterRuleMetadata { + description: "Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade.", + remediation: "https://supabase.com/docs/guides/database/database-linter?lint=unsupported_reg_types", + requires_supabase: false, + }), + "unusedIndex" => Some(SplinterRuleMetadata { + description: "Detects if an index has never been used and may be a candidate for removal.", + remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0005_unused_index", + requires_supabase: false, + }), + _ => None, + } +} #[doc = r" Map rule name from SQL result (snake_case) to diagnostic category"] #[doc = r" Returns None if rule not found"] #[doc = r""] @@ -225,6 +349,7 @@ pub fn get_rule_category(rule_name: &str) -> Option<&'static ::pgls_diagnostics: } #[doc = r" Check if a rule requires Supabase roles (anon, authenticated, service_role)"] #[doc = r" Rules that require Supabase should be filtered out if these roles don't exist"] +#[deprecated(note = "Use get_rule_metadata() instead")] pub fn rule_requires_supabase(rule_name: &str) -> bool { match rule_name { "authRlsInitplan" => true, diff --git a/docs/codegen/src/splinter_docs.rs b/docs/codegen/src/splinter_docs.rs index 357007e1c..2897049a2 100644 --- a/docs/codegen/src/splinter_docs.rs +++ b/docs/codegen/src/splinter_docs.rs @@ -4,16 +4,6 @@ use std::{fs, io::Write as _, path::Path}; use crate::utils::SplinterRuleMetadata; -/// Extract remediation URL from SQL metadata comments -fn extract_remediation_from_sql(sql: &str) -> Option { - for line in sql.lines() { - if let Some(url) = line.strip_prefix("-- meta: remediation = ") { - return Some(url.trim().to_string()); - } - } - None -} - /// Strip metadata comments from SQL content /// Removes all lines starting with "-- meta:" fn strip_metadata_from_sql(sql: &str) -> String { @@ -80,7 +70,7 @@ fn generate_splinter_rule_doc( writeln!(content)?; // Add Supabase requirement notice - if splinter_meta.requires_supabase { + if splinter_meta.registry_metadata.requires_supabase { writeln!(content, "> [!NOTE]")?; writeln!( content, @@ -92,15 +82,14 @@ fn generate_splinter_rule_doc( writeln!(content, "## Description")?; writeln!(content)?; - // Use description from SQL metadata - writeln!(content, "{}", splinter_meta.description)?; + // Use description from registry metadata + writeln!(content, "{}", splinter_meta.registry_metadata.description)?; writeln!(content)?; - // Add "Learn More" link with remediation URL - if let Some(remediation) = extract_remediation_from_sql(splinter_meta.sql_content) { - writeln!(content, "[Learn More]({remediation})")?; - writeln!(content)?; - } + // Add "Learn More" link with remediation URL from registry metadata + let remediation = splinter_meta.registry_metadata.remediation; + writeln!(content, "[Learn More]({remediation})")?; + writeln!(content)?; // Add SQL query section (with metadata stripped) writeln!(content, "## SQL Query")?; diff --git a/docs/codegen/src/utils.rs b/docs/codegen/src/utils.rs index 306fb5313..e31a71400 100644 --- a/docs/codegen/src/utils.rs +++ b/docs/codegen/src/utils.rs @@ -4,13 +4,12 @@ use pgls_analyse::{ use regex::Regex; use std::collections::BTreeMap; -/// Metadata for a splinter rule with SQL content +/// Metadata for a splinter rule with SQL content and registry metadata #[derive(Clone)] pub(crate) struct SplinterRuleMetadata { pub(crate) metadata: RuleMetadata, pub(crate) sql_content: &'static str, - pub(crate) requires_supabase: bool, - pub(crate) description: String, + pub(crate) registry_metadata: pgls_splinter::registry::SplinterRuleMetadata, } pub(crate) fn replace_section( @@ -95,32 +94,23 @@ impl RegistryVisitor for SplinterRulesVisitor { .entry(::NAME) .or_default(); - // Get SQL content and Supabase requirement from registry + // Get SQL content and metadata from registry let sql_content = pgls_splinter::registry::get_sql_content(R::METADATA.name) .unwrap_or("-- SQL content not found"); - let requires_supabase = pgls_splinter::registry::rule_requires_supabase(R::METADATA.name); - - // Extract description from SQL content metadata - let description = extract_description_from_sql(sql_content); + let registry_metadata = pgls_splinter::registry::get_rule_metadata(R::METADATA.name) + .unwrap_or(pgls_splinter::registry::SplinterRuleMetadata { + description: "Detects potential issues in your database schema.", + remediation: "https://supabase.com/docs/guides/database/database-advisors", + requires_supabase: false, + }); group.insert( R::METADATA.name, SplinterRuleMetadata { metadata: R::METADATA, sql_content, - requires_supabase, - description, + registry_metadata, }, ); } } - -fn extract_description_from_sql(sql: &str) -> String { - // Look for "-- meta: description = ..." in SQL content - for line in sql.lines() { - if let Some(desc_line) = line.strip_prefix("-- meta: description = ") { - return desc_line.trim().to_string(); - } - } - "Detects potential issues in your database schema.".to_string() -} diff --git a/xtask/codegen/src/generate_splinter.rs b/xtask/codegen/src/generate_splinter.rs index a00277be4..fca5b59d7 100644 --- a/xtask/codegen/src/generate_splinter.rs +++ b/xtask/codegen/src/generate_splinter.rs @@ -502,11 +502,41 @@ fn generate_registry(rules: &BTreeMap) -> Result<()> { }) .collect(); + // Generate match arms for metadata lookup (camelCase → SplinterRuleMetadata) + let metadata_arms: Vec<_> = rules + .values() + .map(|rule| { + let camel_name = &rule.name; + let description = &rule.description; + let remediation = &rule.remediation; + let requires_supabase = rule.requires_supabase; + + quote! { + #camel_name => Some(SplinterRuleMetadata { + description: #description, + remediation: #remediation, + requires_supabase: #requires_supabase, + }) + } + }) + .collect(); + let content = quote! { //! Generated file, do not edit by hand, see `xtask/codegen` use pgls_analyse::RegistryVisitor; + /// Metadata for a splinter rule + #[derive(Debug, Clone, Copy)] + pub struct SplinterRuleMetadata { + /// Description of what the rule detects + pub description: &'static str, + /// URL to documentation/remediation guide + pub remediation: &'static str, + /// Whether this rule requires Supabase roles (anon, authenticated, service_role) + pub requires_supabase: bool, + } + /// Visit all splinter rules using the visitor pattern /// This is called during registry building to collect enabled rules pub fn visit_registry(registry: &mut V) { @@ -535,6 +565,17 @@ fn generate_registry(rules: &BTreeMap) -> Result<()> { } } + /// Get metadata for a rule (camelCase name) + /// Returns None if rule not found + /// + /// This provides structured access to rule metadata without requiring SQL parsing + pub fn get_rule_metadata(rule_name: &str) -> Option { + match rule_name { + #( #metadata_arms, )* + _ => None, + } + } + /// Map rule name from SQL result (snake_case) to diagnostic category /// Returns None if rule not found /// @@ -548,6 +589,7 @@ fn generate_registry(rules: &BTreeMap) -> Result<()> { /// Check if a rule requires Supabase roles (anon, authenticated, service_role) /// Rules that require Supabase should be filtered out if these roles don't exist + #[deprecated(note = "Use get_rule_metadata() instead")] pub fn rule_requires_supabase(rule_name: &str) -> bool { match rule_name { #( #supabase_arms, )* From 229dc8f7c7d4f1de6369e0f605e125aa162bd663 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Tue, 16 Dec 2025 08:34:00 +0100 Subject: [PATCH 28/32] refactor: embed splinter metadata directly in SplinterRule trait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed the architecture to make metadata accessible as trait constants instead of requiring registry lookups or SQL parsing: - Updated `SplinterRule` trait to include four associated constants: - `SQL_FILE_PATH`: Path to SQL file (changed from function to const) - `DESCRIPTION`: What the rule detects - `REMEDIATION`: URL to documentation - `REQUIRES_SUPABASE`: Whether rule needs Supabase roles - Updated codegen to generate these constants in each rule's impl block - Updated registry to provide metadata via qualified trait syntax: - `get_rule_metadata_fields()`: Returns tuple by calling trait constants - `get_rule_metadata()`: Returns struct by delegating to above - Updated docs codegen to use `get_rule_metadata_fields()` from registry Benefits: - Type-safe: Metadata defined with the rule type - No runtime string parsing - Single source of truth (SQL metadata comments) - Clean API: Registry provides runtime lookups via trait constants 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- crates/pgls_splinter/src/lib.rs | 2 +- crates/pgls_splinter/src/registry.rs | 123 ++---------------- crates/pgls_splinter/src/rule.rs | 8 +- .../rules/performance/auth_rls_initplan.rs | 8 +- .../src/rules/performance/duplicate_index.rs | 8 +- .../multiple_permissive_policies.rs | 7 +- .../src/rules/performance/no_primary_key.rs | 8 +- .../src/rules/performance/table_bloat.rs | 7 +- .../performance/unindexed_foreign_keys.rs | 7 +- .../src/rules/performance/unused_index.rs | 9 +- .../src/rules/security/auth_users_exposed.rs | 8 +- .../src/rules/security/extension_in_public.rs | 8 +- .../security/extension_versions_outdated.rs | 8 +- .../src/rules/security/fkey_to_auth_unique.rs | 9 +- .../rules/security/foreign_table_in_api.rs | 8 +- .../security/function_search_path_mutable.rs | 8 +- .../security/insecure_queue_exposed_in_api.rs | 8 +- .../security/materialized_view_in_api.rs | 8 +- .../security/policy_exists_rls_disabled.rs | 7 +- .../rules/security/rls_disabled_in_public.rs | 7 +- .../rules/security/rls_enabled_no_policy.rs | 8 +- .../security/rls_references_user_metadata.rs | 7 +- .../rules/security/security_definer_view.rs | 8 +- .../rules/security/unsupported_reg_types.rs | 8 +- docs/codegen/src/splinter_docs.rs | 10 +- docs/codegen/src/utils.rs | 22 ++-- xtask/codegen/src/generate_splinter.rs | 64 ++++++--- 27 files changed, 186 insertions(+), 207 deletions(-) diff --git a/crates/pgls_splinter/src/lib.rs b/crates/pgls_splinter/src/lib.rs index 58c56709b..7552c744e 100644 --- a/crates/pgls_splinter/src/lib.rs +++ b/crates/pgls_splinter/src/lib.rs @@ -103,7 +103,7 @@ pub async fn run_splinter( // Ensure all queries are wrapped for valid UNION ALL syntax let processed_queries: Vec = sql_queries .iter() - .map(|sql| { + .map(|sql: &&str| { let trimmed = sql.trim(); // Wrap in parentheses if not already wrapped if trimmed.starts_with('(') && trimmed.ends_with(')') { diff --git a/crates/pgls_splinter/src/registry.rs b/crates/pgls_splinter/src/registry.rs index 2defb6161..3b2a81350 100644 --- a/crates/pgls_splinter/src/registry.rs +++ b/crates/pgls_splinter/src/registry.rs @@ -161,119 +161,24 @@ pub fn get_sql_content(rule_name: &str) -> Option<&'static str> { _ => None, } } +#[doc = r" Get metadata fields for a rule (camelCase name)"] +#[doc = r" Returns (description, remediation, requires_supabase) tuple"] +#[doc = r""] +#[doc = r" This calls the trait constants from the generated rule types"] +pub fn get_rule_metadata_fields(rule_name: &str) -> Option<(&'static str, &'static str, bool)> { + match rule_name { "authRlsInitplan" => Some ((< crate :: rules :: performance :: auth_rls_initplan :: AuthRlsInitplan as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: performance :: auth_rls_initplan :: AuthRlsInitplan as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: performance :: auth_rls_initplan :: AuthRlsInitplan as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "authUsersExposed" => Some ((< crate :: rules :: security :: auth_users_exposed :: AuthUsersExposed as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: auth_users_exposed :: AuthUsersExposed as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: auth_users_exposed :: AuthUsersExposed as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "duplicateIndex" => Some ((< crate :: rules :: performance :: duplicate_index :: DuplicateIndex as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: performance :: duplicate_index :: DuplicateIndex as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: performance :: duplicate_index :: DuplicateIndex as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "extensionInPublic" => Some ((< crate :: rules :: security :: extension_in_public :: ExtensionInPublic as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: extension_in_public :: ExtensionInPublic as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: extension_in_public :: ExtensionInPublic as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "extensionVersionsOutdated" => Some ((< crate :: rules :: security :: extension_versions_outdated :: ExtensionVersionsOutdated as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: extension_versions_outdated :: ExtensionVersionsOutdated as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: extension_versions_outdated :: ExtensionVersionsOutdated as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "fkeyToAuthUnique" => Some ((< crate :: rules :: security :: fkey_to_auth_unique :: FkeyToAuthUnique as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: fkey_to_auth_unique :: FkeyToAuthUnique as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: fkey_to_auth_unique :: FkeyToAuthUnique as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "foreignTableInApi" => Some ((< crate :: rules :: security :: foreign_table_in_api :: ForeignTableInApi as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: foreign_table_in_api :: ForeignTableInApi as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: foreign_table_in_api :: ForeignTableInApi as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "functionSearchPathMutable" => Some ((< crate :: rules :: security :: function_search_path_mutable :: FunctionSearchPathMutable as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: function_search_path_mutable :: FunctionSearchPathMutable as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: function_search_path_mutable :: FunctionSearchPathMutable as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "insecureQueueExposedInApi" => Some ((< crate :: rules :: security :: insecure_queue_exposed_in_api :: InsecureQueueExposedInApi as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: insecure_queue_exposed_in_api :: InsecureQueueExposedInApi as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: insecure_queue_exposed_in_api :: InsecureQueueExposedInApi as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "materializedViewInApi" => Some ((< crate :: rules :: security :: materialized_view_in_api :: MaterializedViewInApi as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: materialized_view_in_api :: MaterializedViewInApi as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: materialized_view_in_api :: MaterializedViewInApi as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "multiplePermissivePolicies" => Some ((< crate :: rules :: performance :: multiple_permissive_policies :: MultiplePermissivePolicies as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: performance :: multiple_permissive_policies :: MultiplePermissivePolicies as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: performance :: multiple_permissive_policies :: MultiplePermissivePolicies as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "noPrimaryKey" => Some ((< crate :: rules :: performance :: no_primary_key :: NoPrimaryKey as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: performance :: no_primary_key :: NoPrimaryKey as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: performance :: no_primary_key :: NoPrimaryKey as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "policyExistsRlsDisabled" => Some ((< crate :: rules :: security :: policy_exists_rls_disabled :: PolicyExistsRlsDisabled as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: policy_exists_rls_disabled :: PolicyExistsRlsDisabled as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: policy_exists_rls_disabled :: PolicyExistsRlsDisabled as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "rlsDisabledInPublic" => Some ((< crate :: rules :: security :: rls_disabled_in_public :: RlsDisabledInPublic as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: rls_disabled_in_public :: RlsDisabledInPublic as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: rls_disabled_in_public :: RlsDisabledInPublic as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "rlsEnabledNoPolicy" => Some ((< crate :: rules :: security :: rls_enabled_no_policy :: RlsEnabledNoPolicy as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: rls_enabled_no_policy :: RlsEnabledNoPolicy as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: rls_enabled_no_policy :: RlsEnabledNoPolicy as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "rlsReferencesUserMetadata" => Some ((< crate :: rules :: security :: rls_references_user_metadata :: RlsReferencesUserMetadata as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: rls_references_user_metadata :: RlsReferencesUserMetadata as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: rls_references_user_metadata :: RlsReferencesUserMetadata as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "securityDefinerView" => Some ((< crate :: rules :: security :: security_definer_view :: SecurityDefinerView as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: security_definer_view :: SecurityDefinerView as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: security_definer_view :: SecurityDefinerView as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "tableBloat" => Some ((< crate :: rules :: performance :: table_bloat :: TableBloat as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: performance :: table_bloat :: TableBloat as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: performance :: table_bloat :: TableBloat as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "unindexedForeignKeys" => Some ((< crate :: rules :: performance :: unindexed_foreign_keys :: UnindexedForeignKeys as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: performance :: unindexed_foreign_keys :: UnindexedForeignKeys as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: performance :: unindexed_foreign_keys :: UnindexedForeignKeys as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "unsupportedRegTypes" => Some ((< crate :: rules :: security :: unsupported_reg_types :: UnsupportedRegTypes as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: unsupported_reg_types :: UnsupportedRegTypes as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: unsupported_reg_types :: UnsupportedRegTypes as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "unusedIndex" => Some ((< crate :: rules :: performance :: unused_index :: UnusedIndex as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: performance :: unused_index :: UnusedIndex as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: performance :: unused_index :: UnusedIndex as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , _ => None , } +} #[doc = r" Get metadata for a rule (camelCase name)"] #[doc = r" Returns None if rule not found"] #[doc = r""] -#[doc = r" This provides structured access to rule metadata without requiring SQL parsing"] +#[doc = r" This provides structured access to rule metadata by calling trait constants"] pub fn get_rule_metadata(rule_name: &str) -> Option { - match rule_name { - "authRlsInitplan" => Some(SplinterRuleMetadata { - description: "Detects if calls to \\`current_setting()\\` and \\`auth.()\\` in RLS policies are being unnecessarily re-evaluated for each row", - remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan", - requires_supabase: true, - }), - "authUsersExposed" => Some(SplinterRuleMetadata { - description: "Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security.", - remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed", - requires_supabase: true, - }), - "duplicateIndex" => Some(SplinterRuleMetadata { - description: "Detects cases where two ore more identical indexes exist.", - remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0009_duplicate_index", - requires_supabase: false, - }), - "extensionInPublic" => Some(SplinterRuleMetadata { - description: "Detects extensions installed in the \\`public\\` schema.", - remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0014_extension_in_public", - requires_supabase: false, - }), - "extensionVersionsOutdated" => Some(SplinterRuleMetadata { - description: "Detects extensions that are not using the default (recommended) version.", - remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0022_extension_versions_outdated", - requires_supabase: false, - }), - "fkeyToAuthUnique" => Some(SplinterRuleMetadata { - description: "Detects user defined foreign keys to unique constraints in the auth schema.", - remediation: "Drop the foreign key constraint that references the auth schema.", - requires_supabase: true, - }), - "foreignTableInApi" => Some(SplinterRuleMetadata { - description: "Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies.", - remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api", - requires_supabase: true, - }), - "functionSearchPathMutable" => Some(SplinterRuleMetadata { - description: "Detects functions where the search_path parameter is not set.", - remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0011_function_search_path_mutable", - requires_supabase: false, - }), - "insecureQueueExposedInApi" => Some(SplinterRuleMetadata { - description: "Detects cases where an insecure Queue is exposed over Data APIs", - remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0019_insecure_queue_exposed_in_api", - requires_supabase: true, - }), - "materializedViewInApi" => Some(SplinterRuleMetadata { - description: "Detects materialized views that are accessible over the Data APIs.", - remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api", - requires_supabase: true, - }), - "multiplePermissivePolicies" => Some(SplinterRuleMetadata { - description: "Detects if multiple permissive row level security policies are present on a table for the same \\`role\\` and \\`action\\` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.", - remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies", - requires_supabase: false, - }), - "noPrimaryKey" => Some(SplinterRuleMetadata { - description: "Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale.", - remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0004_no_primary_key", - requires_supabase: false, - }), - "policyExistsRlsDisabled" => Some(SplinterRuleMetadata { - description: "Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table.", - remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0007_policy_exists_rls_disabled", - requires_supabase: false, - }), - "rlsDisabledInPublic" => Some(SplinterRuleMetadata { - description: "Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST", - remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public", - requires_supabase: true, - }), - "rlsEnabledNoPolicy" => Some(SplinterRuleMetadata { - description: "Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created.", - remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0008_rls_enabled_no_policy", - requires_supabase: false, - }), - "rlsReferencesUserMetadata" => Some(SplinterRuleMetadata { - description: "Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy.", - remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata", - requires_supabase: true, - }), - "securityDefinerView" => Some(SplinterRuleMetadata { - description: "Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user", - remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view", - requires_supabase: true, - }), - "tableBloat" => Some(SplinterRuleMetadata { - description: "Detects if a table has excess bloat and may benefit from maintenance operations like vacuum full or cluster.", - remediation: "Consider running vacuum full (WARNING: incurs downtime) and tweaking autovacuum settings to reduce bloat.", - requires_supabase: false, - }), - "unindexedForeignKeys" => Some(SplinterRuleMetadata { - description: "Identifies foreign key constraints without a covering index, which can impact database performance.", - remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys", - requires_supabase: false, - }), - "unsupportedRegTypes" => Some(SplinterRuleMetadata { - description: "Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade.", - remediation: "https://supabase.com/docs/guides/database/database-linter?lint=unsupported_reg_types", - requires_supabase: false, - }), - "unusedIndex" => Some(SplinterRuleMetadata { - description: "Detects if an index has never been used and may be a candidate for removal.", - remediation: "https://supabase.com/docs/guides/database/database-linter?lint=0005_unused_index", - requires_supabase: false, - }), - _ => None, - } + let (description, remediation, requires_supabase) = get_rule_metadata_fields(rule_name)?; + Some(SplinterRuleMetadata { + description, + remediation, + requires_supabase, + }) } #[doc = r" Map rule name from SQL result (snake_case) to diagnostic category"] #[doc = r" Returns None if rule not found"] diff --git a/crates/pgls_splinter/src/rule.rs b/crates/pgls_splinter/src/rule.rs index 0b1ef1046..f00a528f2 100644 --- a/crates/pgls_splinter/src/rule.rs +++ b/crates/pgls_splinter/src/rule.rs @@ -10,5 +10,11 @@ use pgls_analyse::RuleMeta; #[doc = r" - Rule logic is in SQL files, not Rust"] pub trait SplinterRule: RuleMeta { #[doc = r" Path to the SQL file containing the rule query"] - fn sql_file_path() -> &'static str; + const SQL_FILE_PATH: &'static str; + #[doc = r" Description of what the rule detects"] + const DESCRIPTION: &'static str; + #[doc = r" URL to documentation/remediation guide"] + const REMEDIATION: &'static str; + #[doc = r" Whether this rule requires Supabase roles (anon, authenticated, service_role)"] + const REQUIRES_SUPABASE: bool; } diff --git a/crates/pgls_splinter/src/rules/performance/auth_rls_initplan.rs b/crates/pgls_splinter/src/rules/performance/auth_rls_initplan.rs index 463304b69..bd5c4f34c 100644 --- a/crates/pgls_splinter/src/rules/performance/auth_rls_initplan.rs +++ b/crates/pgls_splinter/src/rules/performance/auth_rls_initplan.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Auth RLS Initialization Plan\n///\n/// Detects if calls to \\`current_setting()\\` and \\`auth.()\\` in RLS policies are being unnecessarily re-evaluated for each row\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// with policies as (\n/// select\n/// nsp.nspname as schema_name,\n/// pb.tablename as table_name,\n/// pc.relrowsecurity as is_rls_active,\n/// polname as policy_name,\n/// polpermissive as is_permissive, -- if not, then restrictive\n/// (select array_agg(r::regrole) from unnest(polroles) as x(r)) as roles,\n/// case polcmd\n/// when 'r' then 'SELECT'\n/// when 'a' then 'INSERT'\n/// when 'w' then 'UPDATE'\n/// when 'd' then 'DELETE'\n/// when '*' then 'ALL'\n/// end as command,\n/// qual,\n/// with_check\n/// from\n/// pg_catalog.pg_policy pa\n/// join pg_catalog.pg_class pc\n/// on pa.polrelid = pc.oid\n/// join pg_catalog.pg_namespace nsp\n/// on pc.relnamespace = nsp.oid\n/// join pg_catalog.pg_policies pb\n/// on pc.relname = pb.tablename\n/// and nsp.nspname = pb.schemaname\n/// and pa.polname = pb.policyname\n/// )\n/// select\n/// 'auth_rls_initplan' as \"name!\",\n/// 'Auth RLS Initialization Plan' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['PERFORMANCE'] as \"categories!\",\n/// 'Detects if calls to \\`current_setting()\\` and \\`auth.()\\` in RLS policies are being unnecessarily re-evaluated for each row' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` has a row level security policy \\`%s\\` that re-evaluates current_setting() or auth.() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing \\`auth.()\\` with \\`(select auth.())\\`. See [docs](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) for more info.',\n/// schema_name,\n/// table_name,\n/// policy_name\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', schema_name,\n/// 'name', table_name,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format('auth_rls_init_plan_%s_%s_%s', schema_name, table_name, policy_name) as \"cache_key!\"\n/// from\n/// policies\n/// where\n/// is_rls_active\n/// -- NOTE: does not include realtime in support of monitoring policies on realtime.messages\n/// and schema_name not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and (\n/// -- Example: auth.uid()\n/// (\n/// qual like '%auth.uid()%'\n/// and lower(qual) not like '%select auth.uid()%'\n/// )\n/// or (\n/// qual like '%auth.jwt()%'\n/// and lower(qual) not like '%select auth.jwt()%'\n/// )\n/// or (\n/// qual like '%auth.role()%'\n/// and lower(qual) not like '%select auth.role()%'\n/// )\n/// or (\n/// qual like '%auth.email()%'\n/// and lower(qual) not like '%select auth.email()%'\n/// )\n/// or (\n/// qual like '%current\\_setting(%)%'\n/// and lower(qual) not like '%select current\\_setting(%)%'\n/// )\n/// or (\n/// with_check like '%auth.uid()%'\n/// and lower(with_check) not like '%select auth.uid()%'\n/// )\n/// or (\n/// with_check like '%auth.jwt()%'\n/// and lower(with_check) not like '%select auth.jwt()%'\n/// )\n/// or (\n/// with_check like '%auth.role()%'\n/// and lower(with_check) not like '%select auth.role()%'\n/// )\n/// or (\n/// with_check like '%auth.email()%'\n/// and lower(with_check) not like '%select auth.email()%'\n/// )\n/// or (\n/// with_check like '%current\\_setting(%)%'\n/// and lower(with_check) not like '%select current\\_setting(%)%'\n/// )\n/// ))\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"performance\": {\n/// \"authRlsInitplan\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub AuthRlsInitplan { version : "1.0.0" , name : "authRlsInitplan" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for AuthRlsInitplan { - fn sql_file_path() -> &'static str { - "performance/auth_rls_initplan.sql" - } + const SQL_FILE_PATH: &'static str = "performance/auth_rls_initplan.sql"; + const DESCRIPTION: &'static str = "Detects if calls to \\`current_setting()\\` and \\`auth.()\\` in RLS policies are being unnecessarily re-evaluated for each row"; + const REMEDIATION: &'static str = + "https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan"; + const REQUIRES_SUPABASE: bool = true; } diff --git a/crates/pgls_splinter/src/rules/performance/duplicate_index.rs b/crates/pgls_splinter/src/rules/performance/duplicate_index.rs index b89661dc4..841b6a5cb 100644 --- a/crates/pgls_splinter/src/rules/performance/duplicate_index.rs +++ b/crates/pgls_splinter/src/rules/performance/duplicate_index.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Duplicate Index\n///\n/// Detects cases where two ore more identical indexes exist.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'duplicate_index' as \"name!\",\n/// 'Duplicate Index' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['PERFORMANCE'] as \"categories!\",\n/// 'Detects cases where two ore more identical indexes exist.' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` has identical indexes %s. Drop all except one of them',\n/// n.nspname,\n/// c.relname,\n/// array_agg(pi.indexname order by pi.indexname)\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0009_duplicate_index' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', case\n/// when c.relkind = 'r' then 'table'\n/// when c.relkind = 'm' then 'materialized view'\n/// else 'ERROR'\n/// end,\n/// 'indexes', array_agg(pi.indexname order by pi.indexname)\n/// ) as \"metadata!\",\n/// format(\n/// 'duplicate_index_%s_%s_%s',\n/// n.nspname,\n/// c.relname,\n/// array_agg(pi.indexname order by pi.indexname)\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_indexes pi\n/// join pg_catalog.pg_namespace n\n/// on n.nspname = pi.schemaname\n/// join pg_catalog.pg_class c\n/// on pi.tablename = c.relname\n/// and n.oid = c.relnamespace\n/// left join pg_catalog.pg_depend dep\n/// on c.oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// c.relkind in ('r', 'm') -- tables and materialized views\n/// and n.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and dep.objid is null -- exclude tables owned by extensions\n/// group by\n/// n.nspname,\n/// c.relkind,\n/// c.relname,\n/// replace(pi.indexdef, pi.indexname, '')\n/// having\n/// count(*) > 1)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"performance\": {\n/// \"duplicateIndex\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub DuplicateIndex { version : "1.0.0" , name : "duplicateIndex" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for DuplicateIndex { - fn sql_file_path() -> &'static str { - "performance/duplicate_index.sql" - } + const SQL_FILE_PATH: &'static str = "performance/duplicate_index.sql"; + const DESCRIPTION: &'static str = "Detects cases where two ore more identical indexes exist."; + const REMEDIATION: &'static str = + "https://supabase.com/docs/guides/database/database-linter?lint=0009_duplicate_index"; + const REQUIRES_SUPABASE: bool = false; } diff --git a/crates/pgls_splinter/src/rules/performance/multiple_permissive_policies.rs b/crates/pgls_splinter/src/rules/performance/multiple_permissive_policies.rs index 15344e31d..227551a04 100644 --- a/crates/pgls_splinter/src/rules/performance/multiple_permissive_policies.rs +++ b/crates/pgls_splinter/src/rules/performance/multiple_permissive_policies.rs @@ -4,7 +4,8 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Multiple Permissive Policies\n///\n/// Detects if multiple permissive row level security policies are present on a table for the same \\`role\\` and \\`action\\` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'multiple_permissive_policies' as \"name!\",\n/// 'Multiple Permissive Policies' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['PERFORMANCE'] as \"categories!\",\n/// 'Detects if multiple permissive row level security policies are present on a table for the same \\`role\\` and \\`action\\` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` has multiple permissive policies for role \\`%s\\` for action \\`%s\\`. Policies include \\`%s\\`',\n/// n.nspname,\n/// c.relname,\n/// r.rolname,\n/// act.cmd,\n/// array_agg(p.polname order by p.polname)\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format(\n/// 'multiple_permissive_policies_%s_%s_%s_%s',\n/// n.nspname,\n/// c.relname,\n/// r.rolname,\n/// act.cmd\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_policy p\n/// join pg_catalog.pg_class c\n/// on p.polrelid = c.oid\n/// join pg_catalog.pg_namespace n\n/// on c.relnamespace = n.oid\n/// join pg_catalog.pg_roles r\n/// on p.polroles @> array[r.oid]\n/// or p.polroles = array[0::oid]\n/// left join pg_catalog.pg_depend dep\n/// on c.oid = dep.objid\n/// and dep.deptype = 'e',\n/// lateral (\n/// select x.cmd\n/// from unnest((\n/// select\n/// case p.polcmd\n/// when 'r' then array['SELECT']\n/// when 'a' then array['INSERT']\n/// when 'w' then array['UPDATE']\n/// when 'd' then array['DELETE']\n/// when '*' then array['SELECT', 'INSERT', 'UPDATE', 'DELETE']\n/// else array['ERROR']\n/// end as actions\n/// )) x(cmd)\n/// ) act(cmd)\n/// where\n/// c.relkind = 'r' -- regular tables\n/// and p.polpermissive -- policy is permissive\n/// and n.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and r.rolname not like 'pg_%'\n/// and r.rolname not like 'supabase%admin'\n/// and not r.rolbypassrls\n/// and dep.objid is null -- exclude tables owned by extensions\n/// group by\n/// n.nspname,\n/// c.relname,\n/// r.rolname,\n/// act.cmd\n/// having\n/// count(1) > 1)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"performance\": {\n/// \"multiplePermissivePolicies\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub MultiplePermissivePolicies { version : "1.0.0" , name : "multiplePermissivePolicies" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for MultiplePermissivePolicies { - fn sql_file_path() -> &'static str { - "performance/multiple_permissive_policies.sql" - } + const SQL_FILE_PATH: &'static str = "performance/multiple_permissive_policies.sql"; + const DESCRIPTION: &'static str = "Detects if multiple permissive row level security policies are present on a table for the same \\`role\\` and \\`action\\` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query."; + const REMEDIATION: &'static str = "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies"; + const REQUIRES_SUPABASE: bool = false; } diff --git a/crates/pgls_splinter/src/rules/performance/no_primary_key.rs b/crates/pgls_splinter/src/rules/performance/no_primary_key.rs index 97c6e30d8..d65f9fc80 100644 --- a/crates/pgls_splinter/src/rules/performance/no_primary_key.rs +++ b/crates/pgls_splinter/src/rules/performance/no_primary_key.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # No Primary Key\n///\n/// Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'no_primary_key' as \"name!\",\n/// 'No Primary Key' as \"title!\",\n/// 'INFO' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['PERFORMANCE'] as \"categories!\",\n/// 'Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale.' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` does not have a primary key',\n/// pgns.nspname,\n/// pgc.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0004_no_primary_key' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', pgns.nspname,\n/// 'name', pgc.relname,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format(\n/// 'no_primary_key_%s_%s',\n/// pgns.nspname,\n/// pgc.relname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_class pgc\n/// join pg_catalog.pg_namespace pgns\n/// on pgns.oid = pgc.relnamespace\n/// left join pg_catalog.pg_index pgi\n/// on pgi.indrelid = pgc.oid\n/// left join pg_catalog.pg_depend dep\n/// on pgc.oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// pgc.relkind = 'r' -- regular tables\n/// and pgns.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and dep.objid is null -- exclude tables owned by extensions\n/// group by\n/// pgc.oid,\n/// pgns.nspname,\n/// pgc.relname\n/// having\n/// max(coalesce(pgi.indisprimary, false)::int) = 0)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"performance\": {\n/// \"noPrimaryKey\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub NoPrimaryKey { version : "1.0.0" , name : "noPrimaryKey" , severity : pgls_diagnostics :: Severity :: Information , } } impl SplinterRule for NoPrimaryKey { - fn sql_file_path() -> &'static str { - "performance/no_primary_key.sql" - } + const SQL_FILE_PATH: &'static str = "performance/no_primary_key.sql"; + const DESCRIPTION: &'static str = "Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale."; + const REMEDIATION: &'static str = + "https://supabase.com/docs/guides/database/database-linter?lint=0004_no_primary_key"; + const REQUIRES_SUPABASE: bool = false; } diff --git a/crates/pgls_splinter/src/rules/performance/table_bloat.rs b/crates/pgls_splinter/src/rules/performance/table_bloat.rs index 6c30e0483..58e34d3b2 100644 --- a/crates/pgls_splinter/src/rules/performance/table_bloat.rs +++ b/crates/pgls_splinter/src/rules/performance/table_bloat.rs @@ -4,7 +4,8 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Table Bloat\n///\n/// Detects if a table has excess bloat and may benefit from maintenance operations like vacuum full or cluster.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// with constants as (\n/// select current_setting('block_size')::numeric as bs, 23 as hdr, 4 as ma\n/// ),\n/// \n/// bloat_info as (\n/// select\n/// ma,\n/// bs,\n/// schemaname,\n/// tablename,\n/// (datawidth + (hdr + ma - (case when hdr % ma = 0 then ma else hdr % ma end)))::numeric as datahdr,\n/// (maxfracsum * (nullhdr + ma - (case when nullhdr % ma = 0 then ma else nullhdr % ma end))) as nullhdr2\n/// from (\n/// select\n/// schemaname,\n/// tablename,\n/// hdr,\n/// ma,\n/// bs,\n/// sum((1 - null_frac) * avg_width) as datawidth,\n/// max(null_frac) as maxfracsum,\n/// hdr + (\n/// select 1 + count(*) / 8\n/// from pg_stats s2\n/// where\n/// null_frac <> 0\n/// and s2.schemaname = s.schemaname\n/// and s2.tablename = s.tablename\n/// ) as nullhdr\n/// from pg_stats s, constants\n/// group by 1, 2, 3, 4, 5\n/// ) as foo\n/// ),\n/// \n/// table_bloat as (\n/// select\n/// schemaname,\n/// tablename,\n/// cc.relpages,\n/// bs,\n/// ceil((cc.reltuples * ((datahdr + ma -\n/// (case when datahdr % ma = 0 then ma else datahdr % ma end)) + nullhdr2 + 4)) / (bs - 20::float)) as otta\n/// from\n/// bloat_info\n/// join pg_class cc\n/// on cc.relname = bloat_info.tablename\n/// join pg_namespace nn\n/// on cc.relnamespace = nn.oid\n/// and nn.nspname = bloat_info.schemaname\n/// and nn.nspname <> 'information_schema'\n/// where\n/// cc.relkind = 'r'\n/// and cc.relam = (select oid from pg_am where amname = 'heap')\n/// ),\n/// \n/// bloat_data as (\n/// select\n/// 'table' as type,\n/// schemaname,\n/// tablename as object_name,\n/// round(case when otta = 0 then 0.0 else table_bloat.relpages / otta::numeric end, 1) as bloat,\n/// case when relpages < otta then 0 else (bs * (table_bloat.relpages - otta)::bigint)::bigint end as raw_waste\n/// from\n/// table_bloat\n/// )\n/// \n/// select\n/// 'table_bloat' as \"name!\",\n/// 'Table Bloat' as \"title!\",\n/// 'INFO' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['PERFORMANCE'] as \"categories!\",\n/// 'Detects if a table has excess bloat and may benefit from maintenance operations like vacuum full or cluster.' as \"description!\",\n/// format(\n/// 'Table `%s`.`%s` has excessive bloat',\n/// bloat_data.schemaname,\n/// bloat_data.object_name\n/// ) as \"detail!\",\n/// 'Consider running vacuum full (WARNING: incurs downtime) and tweaking autovacuum settings to reduce bloat.' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', bloat_data.schemaname,\n/// 'name', bloat_data.object_name,\n/// 'type', bloat_data.type\n/// ) as \"metadata!\",\n/// format(\n/// 'table_bloat_%s_%s',\n/// bloat_data.schemaname,\n/// bloat_data.object_name\n/// ) as \"cache_key!\"\n/// from\n/// bloat_data\n/// where\n/// bloat > 70.0\n/// and raw_waste > (20 * 1024 * 1024) -- filter for waste > 200 MB\n/// order by\n/// schemaname,\n/// object_name)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"performance\": {\n/// \"tableBloat\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub TableBloat { version : "1.0.0" , name : "tableBloat" , severity : pgls_diagnostics :: Severity :: Information , } } impl SplinterRule for TableBloat { - fn sql_file_path() -> &'static str { - "performance/table_bloat.sql" - } + const SQL_FILE_PATH: &'static str = "performance/table_bloat.sql"; + const DESCRIPTION: &'static str = "Detects if a table has excess bloat and may benefit from maintenance operations like vacuum full or cluster."; + const REMEDIATION: &'static str = "Consider running vacuum full (WARNING: incurs downtime) and tweaking autovacuum settings to reduce bloat."; + const REQUIRES_SUPABASE: bool = false; } diff --git a/crates/pgls_splinter/src/rules/performance/unindexed_foreign_keys.rs b/crates/pgls_splinter/src/rules/performance/unindexed_foreign_keys.rs index 0afe3636e..c4542cb0f 100644 --- a/crates/pgls_splinter/src/rules/performance/unindexed_foreign_keys.rs +++ b/crates/pgls_splinter/src/rules/performance/unindexed_foreign_keys.rs @@ -4,7 +4,8 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Unindexed foreign keys\n///\n/// Identifies foreign key constraints without a covering index, which can impact database performance.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// with foreign_keys as (\n/// select\n/// cl.relnamespace::regnamespace::text as schema_name,\n/// cl.relname as table_name,\n/// cl.oid as table_oid,\n/// ct.conname as fkey_name,\n/// ct.conkey as col_attnums\n/// from\n/// pg_catalog.pg_constraint ct\n/// join pg_catalog.pg_class cl -- fkey owning table\n/// on ct.conrelid = cl.oid\n/// left join pg_catalog.pg_depend d\n/// on d.objid = cl.oid\n/// and d.deptype = 'e'\n/// where\n/// ct.contype = 'f' -- foreign key constraints\n/// and d.objid is null -- exclude tables that are dependencies of extensions\n/// and cl.relnamespace::regnamespace::text not in (\n/// 'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'extensions'\n/// )\n/// ),\n/// index_ as (\n/// select\n/// pi.indrelid as table_oid,\n/// indexrelid::regclass as index_,\n/// string_to_array(indkey::text, ' ')::smallint[] as col_attnums\n/// from\n/// pg_catalog.pg_index pi\n/// where\n/// indisvalid\n/// )\n/// select\n/// 'unindexed_foreign_keys' as \"name!\",\n/// 'Unindexed foreign keys' as \"title!\",\n/// 'INFO' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['PERFORMANCE'] as \"categories!\",\n/// 'Identifies foreign key constraints without a covering index, which can impact database performance.' as \"description!\",\n/// format(\n/// 'Table `%s.%s` has a foreign key `%s` without a covering index. This can lead to suboptimal query performance.',\n/// fk.schema_name,\n/// fk.table_name,\n/// fk.fkey_name\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', fk.schema_name,\n/// 'name', fk.table_name,\n/// 'type', 'table',\n/// 'fkey_name', fk.fkey_name,\n/// 'fkey_columns', fk.col_attnums\n/// ) as \"metadata!\",\n/// format('unindexed_foreign_keys_%s_%s_%s', fk.schema_name, fk.table_name, fk.fkey_name) as \"cache_key!\"\n/// from\n/// foreign_keys fk\n/// left join index_ idx\n/// on fk.table_oid = idx.table_oid\n/// and fk.col_attnums = idx.col_attnums[1:array_length(fk.col_attnums, 1)]\n/// left join pg_catalog.pg_depend dep\n/// on idx.table_oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// idx.index_ is null\n/// and fk.schema_name not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and dep.objid is null -- exclude tables owned by extensions\n/// order by\n/// fk.schema_name,\n/// fk.table_name,\n/// fk.fkey_name\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"performance\": {\n/// \"unindexedForeignKeys\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub UnindexedForeignKeys { version : "1.0.0" , name : "unindexedForeignKeys" , severity : pgls_diagnostics :: Severity :: Information , } } impl SplinterRule for UnindexedForeignKeys { - fn sql_file_path() -> &'static str { - "performance/unindexed_foreign_keys.sql" - } + const SQL_FILE_PATH: &'static str = "performance/unindexed_foreign_keys.sql"; + const DESCRIPTION: &'static str = "Identifies foreign key constraints without a covering index, which can impact database performance."; + const REMEDIATION: &'static str = "https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys"; + const REQUIRES_SUPABASE: bool = false; } diff --git a/crates/pgls_splinter/src/rules/performance/unused_index.rs b/crates/pgls_splinter/src/rules/performance/unused_index.rs index 813bce6d4..3f0c4db67 100644 --- a/crates/pgls_splinter/src/rules/performance/unused_index.rs +++ b/crates/pgls_splinter/src/rules/performance/unused_index.rs @@ -4,7 +4,10 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Unused Index\n///\n/// Detects if an index has never been used and may be a candidate for removal.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'unused_index' as \"name!\",\n/// 'Unused Index' as \"title!\",\n/// 'INFO' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['PERFORMANCE'] as \"categories!\",\n/// 'Detects if an index has never been used and may be a candidate for removal.' as \"description!\",\n/// format(\n/// 'Index \\`%s\\` on table \\`%s.%s\\` has not been used',\n/// psui.indexrelname,\n/// psui.schemaname,\n/// psui.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0005_unused_index' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', psui.schemaname,\n/// 'name', psui.relname,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format(\n/// 'unused_index_%s_%s_%s',\n/// psui.schemaname,\n/// psui.relname,\n/// psui.indexrelname\n/// ) as \"cache_key!\"\n/// \n/// from\n/// pg_catalog.pg_stat_user_indexes psui\n/// join pg_catalog.pg_index pi\n/// on psui.indexrelid = pi.indexrelid\n/// left join pg_catalog.pg_depend dep\n/// on psui.relid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// psui.idx_scan = 0\n/// and not pi.indisunique\n/// and not pi.indisprimary\n/// and dep.objid is null -- exclude tables owned by extensions\n/// and psui.schemaname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// ))\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"performance\": {\n/// \"unusedIndex\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub UnusedIndex { version : "1.0.0" , name : "unusedIndex" , severity : pgls_diagnostics :: Severity :: Information , } } impl SplinterRule for UnusedIndex { - fn sql_file_path() -> &'static str { - "performance/unused_index.sql" - } + const SQL_FILE_PATH: &'static str = "performance/unused_index.sql"; + const DESCRIPTION: &'static str = + "Detects if an index has never been used and may be a candidate for removal."; + const REMEDIATION: &'static str = + "https://supabase.com/docs/guides/database/database-linter?lint=0005_unused_index"; + const REQUIRES_SUPABASE: bool = false; } diff --git a/crates/pgls_splinter/src/rules/security/auth_users_exposed.rs b/crates/pgls_splinter/src/rules/security/auth_users_exposed.rs index d8cee82d7..9f0372fdf 100644 --- a/crates/pgls_splinter/src/rules/security/auth_users_exposed.rs +++ b/crates/pgls_splinter/src/rules/security/auth_users_exposed.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Exposed Auth Users\n///\n/// Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security.\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'auth_users_exposed' as \"name!\",\n/// 'Exposed Auth Users' as \"title!\",\n/// 'ERROR' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security.' as \"description!\",\n/// format(\n/// 'View/Materialized View \"%s\" in the public schema may expose \\`auth.users\\` data to anon or authenticated roles.',\n/// c.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'view',\n/// 'exposed_to', array_remove(array_agg(DISTINCT case when pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') then 'anon' when pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') then 'authenticated' end), null)\n/// ) as \"metadata!\",\n/// format('auth_users_exposed_%s_%s', n.nspname, c.relname) as \"cache_key!\"\n/// from\n/// -- Identify the oid for auth.users\n/// pg_catalog.pg_class auth_users_pg_class\n/// join pg_catalog.pg_namespace auth_users_pg_namespace\n/// on auth_users_pg_class.relnamespace = auth_users_pg_namespace.oid\n/// and auth_users_pg_class.relname = 'users'\n/// and auth_users_pg_namespace.nspname = 'auth'\n/// -- Depends on auth.users\n/// join pg_catalog.pg_depend d\n/// on d.refobjid = auth_users_pg_class.oid\n/// join pg_catalog.pg_rewrite r\n/// on r.oid = d.objid\n/// join pg_catalog.pg_class c\n/// on c.oid = r.ev_class\n/// join pg_catalog.pg_namespace n\n/// on n.oid = c.relnamespace\n/// join pg_catalog.pg_class pg_class_auth_users\n/// on d.refobjid = pg_class_auth_users.oid\n/// where\n/// d.deptype = 'n'\n/// and (\n/// pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n/// or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n/// )\n/// and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n/// -- Exclude self\n/// and c.relname <> '0002_auth_users_exposed'\n/// -- There are 3 insecure configurations\n/// and\n/// (\n/// -- Materialized views don't support RLS so this is insecure by default\n/// (c.relkind in ('m')) -- m for materialized view\n/// or\n/// -- Standard View, accessible to anon or authenticated that is security_definer\n/// (\n/// c.relkind = 'v' -- v for view\n/// -- Exclude security invoker views\n/// and not (\n/// lower(coalesce(c.reloptions::text,'{}'))::text[]\n/// && array[\n/// 'security_invoker=1',\n/// 'security_invoker=true',\n/// 'security_invoker=yes',\n/// 'security_invoker=on'\n/// ]\n/// )\n/// )\n/// or\n/// -- Standard View, security invoker, but no RLS enabled on auth.users\n/// (\n/// c.relkind in ('v') -- v for view\n/// -- is security invoker\n/// and (\n/// lower(coalesce(c.reloptions::text,'{}'))::text[]\n/// && array[\n/// 'security_invoker=1',\n/// 'security_invoker=true',\n/// 'security_invoker=yes',\n/// 'security_invoker=on'\n/// ]\n/// )\n/// and not pg_class_auth_users.relrowsecurity\n/// )\n/// )\n/// group by\n/// n.nspname,\n/// c.relname,\n/// c.oid)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"authUsersExposed\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub AuthUsersExposed { version : "1.0.0" , name : "authUsersExposed" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for AuthUsersExposed { - fn sql_file_path() -> &'static str { - "security/auth_users_exposed.sql" - } + const SQL_FILE_PATH: &'static str = "security/auth_users_exposed.sql"; + const DESCRIPTION: &'static str = "Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security."; + const REMEDIATION: &'static str = + "https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed"; + const REQUIRES_SUPABASE: bool = true; } diff --git a/crates/pgls_splinter/src/rules/security/extension_in_public.rs b/crates/pgls_splinter/src/rules/security/extension_in_public.rs index e69a01a44..02aa1adec 100644 --- a/crates/pgls_splinter/src/rules/security/extension_in_public.rs +++ b/crates/pgls_splinter/src/rules/security/extension_in_public.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Extension in Public\n///\n/// Detects extensions installed in the \\`public\\` schema.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'extension_in_public' as \"name!\",\n/// 'Extension in Public' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects extensions installed in the \\`public\\` schema.' as \"description!\",\n/// format(\n/// 'Extension \\`%s\\` is installed in the public schema. Move it to another schema.',\n/// pe.extname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0014_extension_in_public' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', pe.extnamespace::regnamespace,\n/// 'name', pe.extname,\n/// 'type', 'extension'\n/// ) as \"metadata!\",\n/// format(\n/// 'extension_in_public_%s',\n/// pe.extname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_extension pe\n/// where\n/// -- plpgsql is installed by default in public and outside user control\n/// -- confirmed safe\n/// pe.extname not in ('plpgsql')\n/// -- Scoping this to public is not optimal. Ideally we would use the postgres\n/// -- search path. That currently isn't available via SQL. In other lints\n/// -- we have used has_schema_privilege('anon', 'extensions', 'USAGE') but that\n/// -- is not appropriate here as it would evaluate true for the extensions schema\n/// and pe.extnamespace::regnamespace::text = 'public')\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"extensionInPublic\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub ExtensionInPublic { version : "1.0.0" , name : "extensionInPublic" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for ExtensionInPublic { - fn sql_file_path() -> &'static str { - "security/extension_in_public.sql" - } + const SQL_FILE_PATH: &'static str = "security/extension_in_public.sql"; + const DESCRIPTION: &'static str = "Detects extensions installed in the \\`public\\` schema."; + const REMEDIATION: &'static str = + "https://supabase.com/docs/guides/database/database-linter?lint=0014_extension_in_public"; + const REQUIRES_SUPABASE: bool = false; } diff --git a/crates/pgls_splinter/src/rules/security/extension_versions_outdated.rs b/crates/pgls_splinter/src/rules/security/extension_versions_outdated.rs index 3cf65de2e..066ba0b9c 100644 --- a/crates/pgls_splinter/src/rules/security/extension_versions_outdated.rs +++ b/crates/pgls_splinter/src/rules/security/extension_versions_outdated.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Extension Versions Outdated\n///\n/// Detects extensions that are not using the default (recommended) version.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'extension_versions_outdated' as \"name!\",\n/// 'Extension Versions Outdated' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects extensions that are not using the default (recommended) version.' as \"description!\",\n/// format(\n/// 'Extension `%s` is using version `%s` but version `%s` is available. Using outdated extension versions may expose the database to security vulnerabilities.',\n/// ext.name,\n/// ext.installed_version,\n/// ext.default_version\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0022_extension_versions_outdated' as \"remediation!\",\n/// jsonb_build_object(\n/// 'extension_name', ext.name,\n/// 'installed_version', ext.installed_version,\n/// 'default_version', ext.default_version\n/// ) as \"metadata!\",\n/// format(\n/// 'extension_versions_outdated_%s_%s',\n/// ext.name,\n/// ext.installed_version\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_available_extensions ext\n/// join\n/// -- ignore versions not in pg_available_extension_versions\n/// -- e.g. residue of pg_upgrade\n/// pg_catalog.pg_available_extension_versions extv\n/// on extv.name = ext.name and extv.installed\n/// where\n/// ext.installed_version is not null\n/// and ext.default_version is not null\n/// and ext.installed_version != ext.default_version\n/// order by\n/// ext.name)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"extensionVersionsOutdated\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub ExtensionVersionsOutdated { version : "1.0.0" , name : "extensionVersionsOutdated" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for ExtensionVersionsOutdated { - fn sql_file_path() -> &'static str { - "security/extension_versions_outdated.sql" - } + const SQL_FILE_PATH: &'static str = "security/extension_versions_outdated.sql"; + const DESCRIPTION: &'static str = + "Detects extensions that are not using the default (recommended) version."; + const REMEDIATION: &'static str = "https://supabase.com/docs/guides/database/database-linter?lint=0022_extension_versions_outdated"; + const REQUIRES_SUPABASE: bool = false; } diff --git a/crates/pgls_splinter/src/rules/security/fkey_to_auth_unique.rs b/crates/pgls_splinter/src/rules/security/fkey_to_auth_unique.rs index e84917504..53dd84031 100644 --- a/crates/pgls_splinter/src/rules/security/fkey_to_auth_unique.rs +++ b/crates/pgls_splinter/src/rules/security/fkey_to_auth_unique.rs @@ -4,7 +4,10 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Foreign Key to Auth Unique Constraint\n///\n/// Detects user defined foreign keys to unique constraints in the auth schema.\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'fkey_to_auth_unique' as \"name!\",\n/// 'Foreign Key to Auth Unique Constraint' as \"title!\",\n/// 'ERROR' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects user defined foreign keys to unique constraints in the auth schema.' as \"description!\",\n/// format(\n/// 'Table `%s`.`%s` has a foreign key `%s` referencing an auth unique constraint',\n/// n.nspname, -- referencing schema\n/// c_rel.relname, -- referencing table\n/// c.conname -- fkey name\n/// ) as \"detail!\",\n/// 'Drop the foreign key constraint that references the auth schema.' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c_rel.relname,\n/// 'foreign_key', c.conname\n/// ) as \"metadata!\",\n/// format(\n/// 'fkey_to_auth_unique_%s_%s_%s',\n/// n.nspname, -- referencing schema\n/// c_rel.relname, -- referencing table\n/// c.conname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_constraint c\n/// join pg_catalog.pg_class c_rel\n/// on c.conrelid = c_rel.oid\n/// join pg_catalog.pg_namespace n\n/// on c_rel.relnamespace = n.oid\n/// join pg_catalog.pg_class ref_rel\n/// on c.confrelid = ref_rel.oid\n/// join pg_catalog.pg_namespace cn\n/// on ref_rel.relnamespace = cn.oid\n/// join pg_catalog.pg_index i\n/// on c.conindid = i.indexrelid\n/// where c.contype = 'f'\n/// and cn.nspname = 'auth'\n/// and i.indisunique\n/// and not i.indisprimary)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"fkeyToAuthUnique\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub FkeyToAuthUnique { version : "1.0.0" , name : "fkeyToAuthUnique" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for FkeyToAuthUnique { - fn sql_file_path() -> &'static str { - "security/fkey_to_auth_unique.sql" - } + const SQL_FILE_PATH: &'static str = "security/fkey_to_auth_unique.sql"; + const DESCRIPTION: &'static str = + "Detects user defined foreign keys to unique constraints in the auth schema."; + const REMEDIATION: &'static str = + "Drop the foreign key constraint that references the auth schema."; + const REQUIRES_SUPABASE: bool = true; } diff --git a/crates/pgls_splinter/src/rules/security/foreign_table_in_api.rs b/crates/pgls_splinter/src/rules/security/foreign_table_in_api.rs index 962db4a02..808b07742 100644 --- a/crates/pgls_splinter/src/rules/security/foreign_table_in_api.rs +++ b/crates/pgls_splinter/src/rules/security/foreign_table_in_api.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Foreign Table in API\n///\n/// Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies.\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'foreign_table_in_api' as \"name!\",\n/// 'Foreign Table in API' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies.' as \"description!\",\n/// format(\n/// 'Foreign table \\`%s.%s\\` is accessible over APIs',\n/// n.nspname,\n/// c.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'foreign table'\n/// ) as \"metadata!\",\n/// format(\n/// 'foreign_table_in_api_%s_%s',\n/// n.nspname,\n/// c.relname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_class c\n/// join pg_catalog.pg_namespace n\n/// on n.oid = c.relnamespace\n/// left join pg_catalog.pg_depend dep\n/// on c.oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// c.relkind = 'f'\n/// and (\n/// pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n/// or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n/// )\n/// and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n/// and n.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and dep.objid is null)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"foreignTableInApi\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub ForeignTableInApi { version : "1.0.0" , name : "foreignTableInApi" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for ForeignTableInApi { - fn sql_file_path() -> &'static str { - "security/foreign_table_in_api.sql" - } + const SQL_FILE_PATH: &'static str = "security/foreign_table_in_api.sql"; + const DESCRIPTION: &'static str = "Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies."; + const REMEDIATION: &'static str = + "https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api"; + const REQUIRES_SUPABASE: bool = true; } diff --git a/crates/pgls_splinter/src/rules/security/function_search_path_mutable.rs b/crates/pgls_splinter/src/rules/security/function_search_path_mutable.rs index a33deaf61..6ea976151 100644 --- a/crates/pgls_splinter/src/rules/security/function_search_path_mutable.rs +++ b/crates/pgls_splinter/src/rules/security/function_search_path_mutable.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Function Search Path Mutable\n///\n/// Detects functions where the search_path parameter is not set.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'function_search_path_mutable' as \"name!\",\n/// 'Function Search Path Mutable' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects functions where the search_path parameter is not set.' as \"description!\",\n/// format(\n/// 'Function \\`%s.%s\\` has a role mutable search_path',\n/// n.nspname,\n/// p.proname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0011_function_search_path_mutable' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', p.proname,\n/// 'type', 'function'\n/// ) as \"metadata!\",\n/// format(\n/// 'function_search_path_mutable_%s_%s_%s',\n/// n.nspname,\n/// p.proname,\n/// md5(p.prosrc) -- required when function is polymorphic\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_proc p\n/// join pg_catalog.pg_namespace n\n/// on p.pronamespace = n.oid\n/// left join pg_catalog.pg_depend dep\n/// on p.oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// n.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and dep.objid is null -- exclude functions owned by extensions\n/// -- Search path not set\n/// and not exists (\n/// select 1\n/// from unnest(coalesce(p.proconfig, '{}')) as config\n/// where config like 'search_path=%'\n/// ))\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"functionSearchPathMutable\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub FunctionSearchPathMutable { version : "1.0.0" , name : "functionSearchPathMutable" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for FunctionSearchPathMutable { - fn sql_file_path() -> &'static str { - "security/function_search_path_mutable.sql" - } + const SQL_FILE_PATH: &'static str = "security/function_search_path_mutable.sql"; + const DESCRIPTION: &'static str = + "Detects functions where the search_path parameter is not set."; + const REMEDIATION: &'static str = "https://supabase.com/docs/guides/database/database-linter?lint=0011_function_search_path_mutable"; + const REQUIRES_SUPABASE: bool = false; } diff --git a/crates/pgls_splinter/src/rules/security/insecure_queue_exposed_in_api.rs b/crates/pgls_splinter/src/rules/security/insecure_queue_exposed_in_api.rs index c44223426..a739cb138 100644 --- a/crates/pgls_splinter/src/rules/security/insecure_queue_exposed_in_api.rs +++ b/crates/pgls_splinter/src/rules/security/insecure_queue_exposed_in_api.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Insecure Queue Exposed in API\n///\n/// Detects cases where an insecure Queue is exposed over Data APIs\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'insecure_queue_exposed_in_api' as \"name!\",\n/// 'Insecure Queue Exposed in API' as \"title!\",\n/// 'ERROR' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects cases where an insecure Queue is exposed over Data APIs' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` is public, but RLS has not been enabled.',\n/// n.nspname,\n/// c.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0019_insecure_queue_exposed_in_api' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format(\n/// 'rls_disabled_in_public_%s_%s',\n/// n.nspname,\n/// c.relname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_class c\n/// join pg_catalog.pg_namespace n\n/// on c.relnamespace = n.oid\n/// where\n/// c.relkind in ('r', 'I') -- regular or partitioned tables\n/// and not c.relrowsecurity -- RLS is disabled\n/// and (\n/// pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n/// or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n/// )\n/// and n.nspname = 'pgmq' -- tables in the pgmq schema\n/// and c.relname like 'q_%' -- only queue tables\n/// -- Constant requirements\n/// and 'pgmq_public' = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))))\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"insecureQueueExposedInApi\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub InsecureQueueExposedInApi { version : "1.0.0" , name : "insecureQueueExposedInApi" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for InsecureQueueExposedInApi { - fn sql_file_path() -> &'static str { - "security/insecure_queue_exposed_in_api.sql" - } + const SQL_FILE_PATH: &'static str = "security/insecure_queue_exposed_in_api.sql"; + const DESCRIPTION: &'static str = + "Detects cases where an insecure Queue is exposed over Data APIs"; + const REMEDIATION: &'static str = "https://supabase.com/docs/guides/database/database-linter?lint=0019_insecure_queue_exposed_in_api"; + const REQUIRES_SUPABASE: bool = true; } diff --git a/crates/pgls_splinter/src/rules/security/materialized_view_in_api.rs b/crates/pgls_splinter/src/rules/security/materialized_view_in_api.rs index c3dd23e9e..b659eafd5 100644 --- a/crates/pgls_splinter/src/rules/security/materialized_view_in_api.rs +++ b/crates/pgls_splinter/src/rules/security/materialized_view_in_api.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Materialized View in API\n///\n/// Detects materialized views that are accessible over the Data APIs.\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'materialized_view_in_api' as \"name!\",\n/// 'Materialized View in API' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects materialized views that are accessible over the Data APIs.' as \"description!\",\n/// format(\n/// 'Materialized view \\`%s.%s\\` is selectable by anon or authenticated roles',\n/// n.nspname,\n/// c.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'materialized view'\n/// ) as \"metadata!\",\n/// format(\n/// 'materialized_view_in_api_%s_%s',\n/// n.nspname,\n/// c.relname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_class c\n/// join pg_catalog.pg_namespace n\n/// on n.oid = c.relnamespace\n/// left join pg_catalog.pg_depend dep\n/// on c.oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// c.relkind = 'm'\n/// and (\n/// pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n/// or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n/// )\n/// and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n/// and n.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and dep.objid is null)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"materializedViewInApi\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub MaterializedViewInApi { version : "1.0.0" , name : "materializedViewInApi" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for MaterializedViewInApi { - fn sql_file_path() -> &'static str { - "security/materialized_view_in_api.sql" - } + const SQL_FILE_PATH: &'static str = "security/materialized_view_in_api.sql"; + const DESCRIPTION: &'static str = + "Detects materialized views that are accessible over the Data APIs."; + const REMEDIATION: &'static str = "https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api"; + const REQUIRES_SUPABASE: bool = true; } diff --git a/crates/pgls_splinter/src/rules/security/policy_exists_rls_disabled.rs b/crates/pgls_splinter/src/rules/security/policy_exists_rls_disabled.rs index ca5047a71..60cfc98a2 100644 --- a/crates/pgls_splinter/src/rules/security/policy_exists_rls_disabled.rs +++ b/crates/pgls_splinter/src/rules/security/policy_exists_rls_disabled.rs @@ -4,7 +4,8 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Policy Exists RLS Disabled\n///\n/// Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'policy_exists_rls_disabled' as \"name!\",\n/// 'Policy Exists RLS Disabled' as \"title!\",\n/// 'ERROR' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table.' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` has RLS policies but RLS is not enabled on the table. Policies include %s.',\n/// n.nspname,\n/// c.relname,\n/// array_agg(p.polname order by p.polname)\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0007_policy_exists_rls_disabled' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format(\n/// 'policy_exists_rls_disabled_%s_%s',\n/// n.nspname,\n/// c.relname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_policy p\n/// join pg_catalog.pg_class c\n/// on p.polrelid = c.oid\n/// join pg_catalog.pg_namespace n\n/// on c.relnamespace = n.oid\n/// left join pg_catalog.pg_depend dep\n/// on c.oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// c.relkind = 'r' -- regular tables\n/// and n.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// -- RLS is disabled\n/// and not c.relrowsecurity\n/// and dep.objid is null -- exclude tables owned by extensions\n/// group by\n/// n.nspname,\n/// c.relname)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"policyExistsRlsDisabled\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub PolicyExistsRlsDisabled { version : "1.0.0" , name : "policyExistsRlsDisabled" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for PolicyExistsRlsDisabled { - fn sql_file_path() -> &'static str { - "security/policy_exists_rls_disabled.sql" - } + const SQL_FILE_PATH: &'static str = "security/policy_exists_rls_disabled.sql"; + const DESCRIPTION: &'static str = "Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table."; + const REMEDIATION: &'static str = "https://supabase.com/docs/guides/database/database-linter?lint=0007_policy_exists_rls_disabled"; + const REQUIRES_SUPABASE: bool = false; } diff --git a/crates/pgls_splinter/src/rules/security/rls_disabled_in_public.rs b/crates/pgls_splinter/src/rules/security/rls_disabled_in_public.rs index ba4fca06d..b441f4619 100644 --- a/crates/pgls_splinter/src/rules/security/rls_disabled_in_public.rs +++ b/crates/pgls_splinter/src/rules/security/rls_disabled_in_public.rs @@ -4,7 +4,8 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # RLS Disabled in Public\n///\n/// Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'rls_disabled_in_public' as \"name!\",\n/// 'RLS Disabled in Public' as \"title!\",\n/// 'ERROR' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` is public, but RLS has not been enabled.',\n/// n.nspname,\n/// c.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format(\n/// 'rls_disabled_in_public_%s_%s',\n/// n.nspname,\n/// c.relname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_class c\n/// join pg_catalog.pg_namespace n\n/// on c.relnamespace = n.oid\n/// where\n/// c.relkind = 'r' -- regular tables\n/// -- RLS is disabled\n/// and not c.relrowsecurity\n/// and (\n/// pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n/// or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n/// )\n/// and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n/// and n.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// ))\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"rlsDisabledInPublic\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub RlsDisabledInPublic { version : "1.0.0" , name : "rlsDisabledInPublic" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for RlsDisabledInPublic { - fn sql_file_path() -> &'static str { - "security/rls_disabled_in_public.sql" - } + const SQL_FILE_PATH: &'static str = "security/rls_disabled_in_public.sql"; + const DESCRIPTION: &'static str = "Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST"; + const REMEDIATION: &'static str = "https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public"; + const REQUIRES_SUPABASE: bool = true; } diff --git a/crates/pgls_splinter/src/rules/security/rls_enabled_no_policy.rs b/crates/pgls_splinter/src/rules/security/rls_enabled_no_policy.rs index 06deface8..1f58b7789 100644 --- a/crates/pgls_splinter/src/rules/security/rls_enabled_no_policy.rs +++ b/crates/pgls_splinter/src/rules/security/rls_enabled_no_policy.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # RLS Enabled No Policy\n///\n/// Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'rls_enabled_no_policy' as \"name!\",\n/// 'RLS Enabled No Policy' as \"title!\",\n/// 'INFO' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created.' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` has RLS enabled, but no policies exist',\n/// n.nspname,\n/// c.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0008_rls_enabled_no_policy' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format(\n/// 'rls_enabled_no_policy_%s_%s',\n/// n.nspname,\n/// c.relname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_class c\n/// left join pg_catalog.pg_policy p\n/// on p.polrelid = c.oid\n/// join pg_catalog.pg_namespace n\n/// on c.relnamespace = n.oid\n/// left join pg_catalog.pg_depend dep\n/// on c.oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// c.relkind = 'r' -- regular tables\n/// and n.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// -- RLS is enabled\n/// and c.relrowsecurity\n/// and p.polname is null\n/// and dep.objid is null -- exclude tables owned by extensions\n/// group by\n/// n.nspname,\n/// c.relname)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"rlsEnabledNoPolicy\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub RlsEnabledNoPolicy { version : "1.0.0" , name : "rlsEnabledNoPolicy" , severity : pgls_diagnostics :: Severity :: Information , } } impl SplinterRule for RlsEnabledNoPolicy { - fn sql_file_path() -> &'static str { - "security/rls_enabled_no_policy.sql" - } + const SQL_FILE_PATH: &'static str = "security/rls_enabled_no_policy.sql"; + const DESCRIPTION: &'static str = "Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created."; + const REMEDIATION: &'static str = + "https://supabase.com/docs/guides/database/database-linter?lint=0008_rls_enabled_no_policy"; + const REQUIRES_SUPABASE: bool = false; } diff --git a/crates/pgls_splinter/src/rules/security/rls_references_user_metadata.rs b/crates/pgls_splinter/src/rules/security/rls_references_user_metadata.rs index 1c9b5d33d..a43c1dcf8 100644 --- a/crates/pgls_splinter/src/rules/security/rls_references_user_metadata.rs +++ b/crates/pgls_splinter/src/rules/security/rls_references_user_metadata.rs @@ -4,7 +4,8 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # RLS references user metadata\n///\n/// Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy.\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// with policies as (\n/// select\n/// nsp.nspname as schema_name,\n/// pb.tablename as table_name,\n/// polname as policy_name,\n/// qual,\n/// with_check\n/// from\n/// pg_catalog.pg_policy pa\n/// join pg_catalog.pg_class pc\n/// on pa.polrelid = pc.oid\n/// join pg_catalog.pg_namespace nsp\n/// on pc.relnamespace = nsp.oid\n/// join pg_catalog.pg_policies pb\n/// on pc.relname = pb.tablename\n/// and nsp.nspname = pb.schemaname\n/// and pa.polname = pb.policyname\n/// )\n/// select\n/// 'rls_references_user_metadata' as \"name!\",\n/// 'RLS references user metadata' as \"title!\",\n/// 'ERROR' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy.' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` has a row level security policy \\`%s\\` that references Supabase Auth \\`user_metadata\\`. \\`user_metadata\\` is editable by end users and should never be used in a security context.',\n/// schema_name,\n/// table_name,\n/// policy_name\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', schema_name,\n/// 'name', table_name,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format('rls_references_user_metadata_%s_%s_%s', schema_name, table_name, policy_name) as \"cache_key!\"\n/// from\n/// policies\n/// where\n/// schema_name not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and (\n/// -- Example: auth.jwt() -> 'user_metadata'\n/// -- False positives are possible, but it isn't practical to string match\n/// -- If false positive rate is too high, this expression can iterate\n/// qual like '%auth.jwt()%user_metadata%'\n/// or qual like '%current_setting(%request.jwt.claims%)%user_metadata%'\n/// or with_check like '%auth.jwt()%user_metadata%'\n/// or with_check like '%current_setting(%request.jwt.claims%)%user_metadata%'\n/// ))\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"rlsReferencesUserMetadata\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub RlsReferencesUserMetadata { version : "1.0.0" , name : "rlsReferencesUserMetadata" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for RlsReferencesUserMetadata { - fn sql_file_path() -> &'static str { - "security/rls_references_user_metadata.sql" - } + const SQL_FILE_PATH: &'static str = "security/rls_references_user_metadata.sql"; + const DESCRIPTION: &'static str = "Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy."; + const REMEDIATION: &'static str = "https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata"; + const REQUIRES_SUPABASE: bool = true; } diff --git a/crates/pgls_splinter/src/rules/security/security_definer_view.rs b/crates/pgls_splinter/src/rules/security/security_definer_view.rs index a6752587b..f36b73670 100644 --- a/crates/pgls_splinter/src/rules/security/security_definer_view.rs +++ b/crates/pgls_splinter/src/rules/security/security_definer_view.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Security Definer View\n///\n/// Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'security_definer_view' as \"name!\",\n/// 'Security Definer View' as \"title!\",\n/// 'ERROR' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user' as \"description!\",\n/// format(\n/// 'View \\`%s.%s\\` is defined with the SECURITY DEFINER property',\n/// n.nspname,\n/// c.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'view'\n/// ) as \"metadata!\",\n/// format(\n/// 'security_definer_view_%s_%s',\n/// n.nspname,\n/// c.relname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_class c\n/// join pg_catalog.pg_namespace n\n/// on n.oid = c.relnamespace\n/// left join pg_catalog.pg_depend dep\n/// on c.oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// c.relkind = 'v'\n/// and (\n/// pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n/// or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n/// )\n/// and substring(pg_catalog.version() from 'PostgreSQL ([0-9]+)') >= '15' -- security invoker was added in pg15\n/// and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n/// and n.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and dep.objid is null -- exclude views owned by extensions\n/// and not (\n/// lower(coalesce(c.reloptions::text,'{}'))::text[]\n/// && array[\n/// 'security_invoker=1',\n/// 'security_invoker=true',\n/// 'security_invoker=yes',\n/// 'security_invoker=on'\n/// ]\n/// ))\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"securityDefinerView\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub SecurityDefinerView { version : "1.0.0" , name : "securityDefinerView" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for SecurityDefinerView { - fn sql_file_path() -> &'static str { - "security/security_definer_view.sql" - } + const SQL_FILE_PATH: &'static str = "security/security_definer_view.sql"; + const DESCRIPTION: &'static str = "Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user"; + const REMEDIATION: &'static str = + "https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view"; + const REQUIRES_SUPABASE: bool = true; } diff --git a/crates/pgls_splinter/src/rules/security/unsupported_reg_types.rs b/crates/pgls_splinter/src/rules/security/unsupported_reg_types.rs index 9e6c2ea36..0d1008df1 100644 --- a/crates/pgls_splinter/src/rules/security/unsupported_reg_types.rs +++ b/crates/pgls_splinter/src/rules/security/unsupported_reg_types.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Unsupported reg types\n///\n/// Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'unsupported_reg_types' as \"name!\",\n/// 'Unsupported reg types' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade.' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` has a column \\`%s\\` with unsupported reg* type \\`%s\\`.',\n/// n.nspname,\n/// c.relname,\n/// a.attname,\n/// t.typname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=unsupported_reg_types' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'column', a.attname,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format(\n/// 'unsupported_reg_types_%s_%s_%s',\n/// n.nspname,\n/// c.relname,\n/// a.attname\n/// ) AS cache_key\n/// from\n/// pg_catalog.pg_attribute a\n/// join pg_catalog.pg_class c\n/// on a.attrelid = c.oid\n/// join pg_catalog.pg_namespace n\n/// on c.relnamespace = n.oid\n/// join pg_catalog.pg_type t\n/// on a.atttypid = t.oid\n/// join pg_catalog.pg_namespace tn\n/// on t.typnamespace = tn.oid\n/// where\n/// tn.nspname = 'pg_catalog'\n/// and t.typname in ('regcollation', 'regconfig', 'regdictionary', 'regnamespace', 'regoper', 'regoperator', 'regproc', 'regprocedure')\n/// and n.nspname not in ('pg_catalog', 'information_schema', 'pgsodium'))\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"unsupportedRegTypes\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub UnsupportedRegTypes { version : "1.0.0" , name : "unsupportedRegTypes" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for UnsupportedRegTypes { - fn sql_file_path() -> &'static str { - "security/unsupported_reg_types.sql" - } + const SQL_FILE_PATH: &'static str = "security/unsupported_reg_types.sql"; + const DESCRIPTION: &'static str = "Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade."; + const REMEDIATION: &'static str = + "https://supabase.com/docs/guides/database/database-linter?lint=unsupported_reg_types"; + const REQUIRES_SUPABASE: bool = false; } diff --git a/docs/codegen/src/splinter_docs.rs b/docs/codegen/src/splinter_docs.rs index 2897049a2..0e8fea9cd 100644 --- a/docs/codegen/src/splinter_docs.rs +++ b/docs/codegen/src/splinter_docs.rs @@ -70,7 +70,7 @@ fn generate_splinter_rule_doc( writeln!(content)?; // Add Supabase requirement notice - if splinter_meta.registry_metadata.requires_supabase { + if splinter_meta.requires_supabase { writeln!(content, "> [!NOTE]")?; writeln!( content, @@ -82,12 +82,12 @@ fn generate_splinter_rule_doc( writeln!(content, "## Description")?; writeln!(content)?; - // Use description from registry metadata - writeln!(content, "{}", splinter_meta.registry_metadata.description)?; + // Use description from trait + writeln!(content, "{}", splinter_meta.description)?; writeln!(content)?; - // Add "Learn More" link with remediation URL from registry metadata - let remediation = splinter_meta.registry_metadata.remediation; + // Add "Learn More" link with remediation URL from trait + let remediation = splinter_meta.remediation; writeln!(content, "[Learn More]({remediation})")?; writeln!(content)?; diff --git a/docs/codegen/src/utils.rs b/docs/codegen/src/utils.rs index e31a71400..695969a9e 100644 --- a/docs/codegen/src/utils.rs +++ b/docs/codegen/src/utils.rs @@ -4,12 +4,14 @@ use pgls_analyse::{ use regex::Regex; use std::collections::BTreeMap; -/// Metadata for a splinter rule with SQL content and registry metadata +/// Metadata for a splinter rule with SQL content and metadata from trait #[derive(Clone)] pub(crate) struct SplinterRuleMetadata { pub(crate) metadata: RuleMetadata, pub(crate) sql_content: &'static str, - pub(crate) registry_metadata: pgls_splinter::registry::SplinterRuleMetadata, + pub(crate) description: &'static str, + pub(crate) remediation: &'static str, + pub(crate) requires_supabase: bool, } pub(crate) fn replace_section( @@ -97,19 +99,21 @@ impl RegistryVisitor for SplinterRulesVisitor { // Get SQL content and metadata from registry let sql_content = pgls_splinter::registry::get_sql_content(R::METADATA.name) .unwrap_or("-- SQL content not found"); - let registry_metadata = pgls_splinter::registry::get_rule_metadata(R::METADATA.name) - .unwrap_or(pgls_splinter::registry::SplinterRuleMetadata { - description: "Detects potential issues in your database schema.", - remediation: "https://supabase.com/docs/guides/database/database-advisors", - requires_supabase: false, - }); + let (description, remediation, requires_supabase) = + pgls_splinter::registry::get_rule_metadata_fields(R::METADATA.name).unwrap_or(( + "Detects potential issues in your database schema.", + "https://supabase.com/docs/guides/database/database-advisors", + false, + )); group.insert( R::METADATA.name, SplinterRuleMetadata { metadata: R::METADATA, sql_content, - registry_metadata, + description, + remediation, + requires_supabase, }, ); } diff --git a/xtask/codegen/src/generate_splinter.rs b/xtask/codegen/src/generate_splinter.rs index fca5b59d7..6dc94bd0e 100644 --- a/xtask/codegen/src/generate_splinter.rs +++ b/xtask/codegen/src/generate_splinter.rs @@ -188,7 +188,16 @@ fn generate_rule_trait() -> Result<()> { /// - Rule logic is in SQL files, not Rust pub trait SplinterRule: RuleMeta { /// Path to the SQL file containing the rule query - fn sql_file_path() -> &'static str; + const SQL_FILE_PATH: &'static str; + + /// Description of what the rule detects + const DESCRIPTION: &'static str; + + /// URL to documentation/remediation guide + const REMEDIATION: &'static str; + + /// Whether this rule requires Supabase roles (anon, authenticated, service_role) + const REQUIRES_SUPABASE: bool; } }; @@ -321,9 +330,10 @@ fn generate_rule_file(category_dir: &Path, metadata: &SqlRuleMetadata) -> Result } impl SplinterRule for #struct_name { - fn sql_file_path() -> &'static str { - #sql_path - } + const SQL_FILE_PATH: &'static str = #sql_path; + const DESCRIPTION: &'static str = #description; + const REMEDIATION: &'static str = #remediation; + const REQUIRES_SUPABASE: bool = #requires_supabase; } }; @@ -502,21 +512,22 @@ fn generate_registry(rules: &BTreeMap) -> Result<()> { }) .collect(); - // Generate match arms for metadata lookup (camelCase → SplinterRuleMetadata) - let metadata_arms: Vec<_> = rules + // Generate match arms for metadata fields lookup (camelCase → tuple) + // These call the trait constants from the generated rule types + let metadata_fields_arms: Vec<_> = rules .values() .map(|rule| { let camel_name = &rule.name; - let description = &rule.description; - let remediation = &rule.remediation; - let requires_supabase = rule.requires_supabase; + let category_ident = format_ident!("{}", rule.category.to_lowercase()); + let module_name = format_ident!("{}", &rule.snake_name); + let struct_name = format_ident!("{}", Case::Pascal.convert(&rule.snake_name)); quote! { - #camel_name => Some(SplinterRuleMetadata { - description: #description, - remediation: #remediation, - requires_supabase: #requires_supabase, - }) + #camel_name => Some(( + ::DESCRIPTION, + ::REMEDIATION, + ::REQUIRES_SUPABASE, + )) } }) .collect(); @@ -565,17 +576,32 @@ fn generate_registry(rules: &BTreeMap) -> Result<()> { } } - /// Get metadata for a rule (camelCase name) - /// Returns None if rule not found + /// Get metadata fields for a rule (camelCase name) + /// Returns (description, remediation, requires_supabase) tuple /// - /// This provides structured access to rule metadata without requiring SQL parsing - pub fn get_rule_metadata(rule_name: &str) -> Option { + /// This calls the trait constants from the generated rule types + pub fn get_rule_metadata_fields( + rule_name: &str, + ) -> Option<(&'static str, &'static str, bool)> { match rule_name { - #( #metadata_arms, )* + #( #metadata_fields_arms, )* _ => None, } } + /// Get metadata for a rule (camelCase name) + /// Returns None if rule not found + /// + /// This provides structured access to rule metadata by calling trait constants + pub fn get_rule_metadata(rule_name: &str) -> Option { + let (description, remediation, requires_supabase) = get_rule_metadata_fields(rule_name)?; + Some(SplinterRuleMetadata { + description, + remediation, + requires_supabase, + }) + } + /// Map rule name from SQL result (snake_case) to diagnostic category /// Returns None if rule not found /// From 2e9fcb092e3c8220e16019db8fb949850e62926a Mon Sep 17 00:00:00 2001 From: psteinroe Date: Tue, 16 Dec 2025 09:03:08 +0100 Subject: [PATCH 29/32] chore: remove obsolete splinter SQL files and build script The combined SQL files (splinter.sql, splinter_generic.sql, splinter_supabase.sql) are no longer used by the runtime. The new system reads individual SQL files from vendor/performance/ and vendor/security/ directories. Changes: - Remove obsolete combined SQL files - Remove build.rs that generated the combined files - Remove ureq build dependency The individual rule SQL files in vendor/ subdirectories are maintained manually and embedded at compile time via include_str! in the registry. --- Cargo.lock | 1 - crates/pgls_splinter/Cargo.toml | 3 - crates/pgls_splinter/build.rs | 214 --- crates/pgls_splinter/vendor/splinter.sql | 1149 ----------------- .../pgls_splinter/vendor/splinter_generic.sql | 652 ---------- .../vendor/splinter_supabase.sql | 516 -------- 6 files changed, 2535 deletions(-) delete mode 100644 crates/pgls_splinter/build.rs delete mode 100644 crates/pgls_splinter/vendor/splinter.sql delete mode 100644 crates/pgls_splinter/vendor/splinter_generic.sql delete mode 100644 crates/pgls_splinter/vendor/splinter_supabase.sql diff --git a/Cargo.lock b/Cargo.lock index 690e4e0d6..27377ace1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3005,7 +3005,6 @@ dependencies = [ "serde", "serde_json", "sqlx", - "ureq", ] [[package]] diff --git a/crates/pgls_splinter/Cargo.toml b/crates/pgls_splinter/Cargo.toml index f6554aaf2..e42d9a51c 100644 --- a/crates/pgls_splinter/Cargo.toml +++ b/crates/pgls_splinter/Cargo.toml @@ -18,9 +18,6 @@ serde.workspace = true serde_json.workspace = true sqlx.workspace = true -[build-dependencies] -ureq = "2.10" - [dev-dependencies] insta.workspace = true pgls_console.workspace = true diff --git a/crates/pgls_splinter/build.rs b/crates/pgls_splinter/build.rs deleted file mode 100644 index d7d55e693..000000000 --- a/crates/pgls_splinter/build.rs +++ /dev/null @@ -1,214 +0,0 @@ -use std::env; -use std::fs; -use std::path::Path; - -// Update this commit SHA to pull in a new version of splinter.sql -const SPLINTER_COMMIT_SHA: &str = "27ea2ece65464213e466cd969cc61b6940d16219"; - -// Rules that work on any PostgreSQL database -const GENERIC_RULES: &[&str] = &[ - "unindexed_foreign_keys", - "no_primary_key", - "unused_index", - "multiple_permissive_policies", - "policy_exists_rls_disabled", - "rls_enabled_no_policy", - "duplicate_index", - "extension_in_public", - "table_bloat", - "extension_versions_outdated", - "function_search_path_mutable", - "unsupported_reg_types", -]; - -// Rules that require Supabase-specific infrastructure (auth schema, anon/authenticated roles, pgrst.db_schemas) -const SUPABASE_ONLY_RULES: &[&str] = &[ - "auth_users_exposed", - "auth_rls_initplan", - "rls_disabled_in_public", - "security_definer_view", - "rls_references_user_metadata", - "materialized_view_in_api", - "foreign_table_in_api", - "insecure_queue_exposed_in_api", - "fkey_to_auth_unique", -]; - -fn main() { - let out_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); - let vendor_dir = Path::new(&out_dir).join("vendor"); - let generic_sql_file = vendor_dir.join("splinter_generic.sql"); - let supabase_sql_file = vendor_dir.join("splinter_supabase.sql"); - let sha_file = vendor_dir.join("COMMIT_SHA.txt"); - - // Create vendor directory if it doesn't exist - if !vendor_dir.exists() { - fs::create_dir_all(&vendor_dir).expect("Failed to create vendor directory"); - } - - // Check if we need to download - let needs_download = - if !generic_sql_file.exists() || !supabase_sql_file.exists() || !sha_file.exists() { - true - } else { - // Check if stored SHA matches current constant - let stored_sha = fs::read_to_string(&sha_file) - .expect("Failed to read COMMIT_SHA.txt") - .trim() - .to_string(); - stored_sha != SPLINTER_COMMIT_SHA - }; - - if needs_download { - println!( - "cargo:warning=Downloading splinter.sql from GitHub (commit: {SPLINTER_COMMIT_SHA})" - ); - download_and_process_sql(&generic_sql_file, &supabase_sql_file); - fs::write(&sha_file, SPLINTER_COMMIT_SHA).expect("Failed to write COMMIT_SHA.txt"); - } - - // Tell cargo to rerun if build.rs or SHA file changes - println!("cargo:rerun-if-changed=build.rs"); - println!("cargo:rerun-if-changed=vendor/COMMIT_SHA.txt"); -} - -fn download_and_process_sql(generic_dest: &Path, supabase_dest: &Path) { - let url = format!( - "https://raw.githubusercontent.com/supabase/splinter/{SPLINTER_COMMIT_SHA}/splinter.sql" - ); - - // Download the file - let response = ureq::get(&url) - .call() - .expect("Failed to download splinter.sql"); - - let content = response - .into_string() - .expect("Failed to read response body"); - - // Remove the SET LOCAL search_path section - let mut processed_content = remove_set_search_path(&content); - - // Add "!" suffix to column aliases for sqlx non-null checking - processed_content = add_not_null_markers(&processed_content); - - // Split into generic and Supabase-specific queries (validates categorization) - let (generic_queries, supabase_queries) = split_queries(&processed_content); - - // Write to destination files - fs::write(generic_dest, generic_queries).expect("Failed to write splinter_generic.sql"); - fs::write(supabase_dest, supabase_queries).expect("Failed to write splinter_supabase.sql"); - - println!( - "cargo:warning=Successfully downloaded and processed splinter.sql into generic and Supabase-specific files" - ); -} - -fn remove_set_search_path(content: &str) -> String { - content - .lines() - .filter(|line| { - let trimmed = line.trim(); - !trimmed.to_lowercase().starts_with("set local search_path") - }) - .collect::>() - .join("\n") -} - -fn add_not_null_markers(content: &str) -> String { - // Add "!" suffix to all column aliases to mark them as non-null for sqlx - // This transforms patterns like: 'value' as name - // Into: 'value' as "name!" - - let columns_to_mark = [ - "name", - "title", - "level", - "facing", - "categories", - "description", - "detail", - "remediation", - "metadata", - "cache_key", - ]; - - let mut result = content.to_string(); - - for column in &columns_to_mark { - // Match patterns like: as name, as name) - let pattern_comma = format!(" as {column}"); - let replacement_comma = format!(" as \"{column}!\""); - result = result.replace(&pattern_comma, &replacement_comma); - } - - result -} - -/// Extract rule name from a query fragment -fn extract_rule_name_from_query(query: &str) -> String { - // Look for pattern 'rule_name' as "name!" - for line in query.lines() { - if line.contains(" as \"name!\"") { - if let Some(start) = line.rfind('\'') { - if let Some(prev_quote) = line[..start].rfind('\'') { - return line[prev_quote + 1..start].to_string(); - } - } - } - } - "unknown".to_string() -} - -fn split_queries(content: &str) -> (String, String) { - // Split the union all queries based on rule names - let queries: Vec<&str> = content.split("union all").collect(); - - let mut generic_queries = Vec::new(); - let mut supabase_queries = Vec::new(); - - for query in queries { - // Extract the rule name from the query (it's the first 'name' field) - let is_supabase = SUPABASE_ONLY_RULES - .iter() - .any(|rule| query.contains(&format!("'{rule}' as \"name!\""))); - - let is_generic = GENERIC_RULES - .iter() - .any(|rule| query.contains(&format!("'{rule}' as \"name!\""))); - - if is_supabase { - supabase_queries.push(query); - } else if is_generic { - generic_queries.push(query); - } else { - // Extract rule name for better error message - let rule_name = extract_rule_name_from_query(query); - panic!( - "Found unknown Splinter rule that is not categorized: {rule_name:?}\n\ - Please add this rule to either GENERIC_RULES or SUPABASE_ONLY_RULES in build.rs.\n\ - \n\ - Guidelines:\n\ - - GENERIC_RULES: Rules that work on any PostgreSQL database\n\ - - SUPABASE_ONLY_RULES: Rules that require Supabase infrastructure (auth schema, roles, pgrst.db_schemas)\n\ - \n\ - This prevents new Supabase-specific rules from breaking linting on non-Supabase databases." - ); - } - } - - // Join queries with "union all" and wrap in parentheses - let generic_sql = if generic_queries.is_empty() { - String::new() - } else { - generic_queries.join("union all\n") - }; - - let supabase_sql = if supabase_queries.is_empty() { - String::new() - } else { - supabase_queries.join("union all\n") - }; - - (generic_sql, supabase_sql) -} diff --git a/crates/pgls_splinter/vendor/splinter.sql b/crates/pgls_splinter/vendor/splinter.sql deleted file mode 100644 index 7f479898e..000000000 --- a/crates/pgls_splinter/vendor/splinter.sql +++ /dev/null @@ -1,1149 +0,0 @@ - -( -with foreign_keys as ( - select - cl.relnamespace::regnamespace::text as schema_name, - cl.relname as table_name, - cl.oid as table_oid, - ct.conname as fkey_name, - ct.conkey as col_attnums - from - pg_catalog.pg_constraint ct - join pg_catalog.pg_class cl -- fkey owning table - on ct.conrelid = cl.oid - left join pg_catalog.pg_depend d - on d.objid = cl.oid - and d.deptype = 'e' - where - ct.contype = 'f' -- foreign key constraints - and d.objid is null -- exclude tables that are dependencies of extensions - and cl.relnamespace::regnamespace::text not in ( - 'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'extensions' - ) -), -index_ as ( - select - pi.indrelid as table_oid, - indexrelid::regclass as index_, - string_to_array(indkey::text, ' ')::smallint[] as col_attnums - from - pg_catalog.pg_index pi - where - indisvalid -) -select - 'unindexed_foreign_keys' as "name!", - 'Unindexed foreign keys' as "title!", - 'INFO' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Identifies foreign key constraints without a covering index, which can impact database performance.' as "description!", - format( - 'Table \`%s.%s\` has a foreign key \`%s\` without a covering index. This can lead to suboptimal query performance.', - fk.schema_name, - fk.table_name, - fk.fkey_name - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys' as "remediation!", - jsonb_build_object( - 'schema', fk.schema_name, - 'name', fk.table_name, - 'type', 'table', - 'fkey_name', fk.fkey_name, - 'fkey_columns', fk.col_attnums - ) as "metadata!", - format('unindexed_foreign_keys_%s_%s_%s', fk.schema_name, fk.table_name, fk.fkey_name) as "cache_key!" -from - foreign_keys fk - left join index_ idx - on fk.table_oid = idx.table_oid - and fk.col_attnums = idx.col_attnums[1:array_length(fk.col_attnums, 1)] - left join pg_catalog.pg_depend dep - on idx.table_oid = dep.objid - and dep.deptype = 'e' -where - idx.index_ is null - and fk.schema_name not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - and dep.objid is null -- exclude tables owned by extensions -order by - fk.schema_name, - fk.table_name, - fk.fkey_name) -union all -( -select - 'auth_users_exposed' as "name!", - 'Exposed Auth Users' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security.' as "description!", - format( - 'View/Materialized View "%s" in the public schema may expose \`auth.users\` data to anon or authenticated roles.', - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'view', - 'exposed_to', array_remove(array_agg(DISTINCT case when pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') then 'anon' when pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') then 'authenticated' end), null) - ) as "metadata!", - format('auth_users_exposed_%s_%s', n.nspname, c.relname) as "cache_key!" -from - -- Identify the oid for auth.users - pg_catalog.pg_class auth_users_pg_class - join pg_catalog.pg_namespace auth_users_pg_namespace - on auth_users_pg_class.relnamespace = auth_users_pg_namespace.oid - and auth_users_pg_class.relname = 'users' - and auth_users_pg_namespace.nspname = 'auth' - -- Depends on auth.users - join pg_catalog.pg_depend d - on d.refobjid = auth_users_pg_class.oid - join pg_catalog.pg_rewrite r - on r.oid = d.objid - join pg_catalog.pg_class c - on c.oid = r.ev_class - join pg_catalog.pg_namespace n - on n.oid = c.relnamespace - join pg_catalog.pg_class pg_class_auth_users - on d.refobjid = pg_class_auth_users.oid -where - d.deptype = 'n' - and ( - pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') - or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') - ) - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) - -- Exclude self - and c.relname <> '0002_auth_users_exposed' - -- There are 3 insecure configurations - and - ( - -- Materialized views don't support RLS so this is insecure by default - (c.relkind in ('m')) -- m for materialized view - or - -- Standard View, accessible to anon or authenticated that is security_definer - ( - c.relkind = 'v' -- v for view - -- Exclude security invoker views - and not ( - lower(coalesce(c.reloptions::text,'{}'))::text[] - && array[ - 'security_invoker=1', - 'security_invoker=true', - 'security_invoker=yes', - 'security_invoker=on' - ] - ) - ) - or - -- Standard View, security invoker, but no RLS enabled on auth.users - ( - c.relkind in ('v') -- v for view - -- is security invoker - and ( - lower(coalesce(c.reloptions::text,'{}'))::text[] - && array[ - 'security_invoker=1', - 'security_invoker=true', - 'security_invoker=yes', - 'security_invoker=on' - ] - ) - and not pg_class_auth_users.relrowsecurity - ) - ) -group by - n.nspname, - c.relname, - c.oid) -union all -( -with policies as ( - select - nsp.nspname as schema_name, - pb.tablename as table_name, - pc.relrowsecurity as is_rls_active, - polname as policy_name, - polpermissive as is_permissive, -- if not, then restrictive - (select array_agg(r::regrole) from unnest(polroles) as x(r)) as roles, - case polcmd - when 'r' then 'SELECT' - when 'a' then 'INSERT' - when 'w' then 'UPDATE' - when 'd' then 'DELETE' - when '*' then 'ALL' - end as command, - qual, - with_check - from - pg_catalog.pg_policy pa - join pg_catalog.pg_class pc - on pa.polrelid = pc.oid - join pg_catalog.pg_namespace nsp - on pc.relnamespace = nsp.oid - join pg_catalog.pg_policies pb - on pc.relname = pb.tablename - and nsp.nspname = pb.schemaname - and pa.polname = pb.policyname -) -select - 'auth_rls_initplan' as "name!", - 'Auth RLS Initialization Plan' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Detects if calls to \`current_setting()\` and \`auth.()\` in RLS policies are being unnecessarily re-evaluated for each row' as "description!", - format( - 'Table \`%s.%s\` has a row level security policy \`%s\` that re-evaluates current_setting() or auth.() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing \`auth.()\` with \`(select auth.())\`. See [docs](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) for more info.', - schema_name, - table_name, - policy_name - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan' as "remediation!", - jsonb_build_object( - 'schema', schema_name, - 'name', table_name, - 'type', 'table' - ) as "metadata!", - format('auth_rls_init_plan_%s_%s_%s', schema_name, table_name, policy_name) as "cache_key!" -from - policies -where - is_rls_active - -- NOTE: does not include realtime in support of monitoring policies on realtime.messages - and schema_name not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - and ( - -- Example: auth.uid() - ( - qual like '%auth.uid()%' - and lower(qual) not like '%select auth.uid()%' - ) - or ( - qual like '%auth.jwt()%' - and lower(qual) not like '%select auth.jwt()%' - ) - or ( - qual like '%auth.role()%' - and lower(qual) not like '%select auth.role()%' - ) - or ( - qual like '%auth.email()%' - and lower(qual) not like '%select auth.email()%' - ) - or ( - qual like '%current\_setting(%)%' - and lower(qual) not like '%select current\_setting(%)%' - ) - or ( - with_check like '%auth.uid()%' - and lower(with_check) not like '%select auth.uid()%' - ) - or ( - with_check like '%auth.jwt()%' - and lower(with_check) not like '%select auth.jwt()%' - ) - or ( - with_check like '%auth.role()%' - and lower(with_check) not like '%select auth.role()%' - ) - or ( - with_check like '%auth.email()%' - and lower(with_check) not like '%select auth.email()%' - ) - or ( - with_check like '%current\_setting(%)%' - and lower(with_check) not like '%select current\_setting(%)%' - ) - )) -union all -( -select - 'no_primary_key' as "name!", - 'No Primary Key' as "title!", - 'INFO' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale.' as "description!", - format( - 'Table \`%s.%s\` does not have a primary key', - pgns.nspname, - pgc.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0004_no_primary_key' as "remediation!", - jsonb_build_object( - 'schema', pgns.nspname, - 'name', pgc.relname, - 'type', 'table' - ) as "metadata!", - format( - 'no_primary_key_%s_%s', - pgns.nspname, - pgc.relname - ) as "cache_key!" -from - pg_catalog.pg_class pgc - join pg_catalog.pg_namespace pgns - on pgns.oid = pgc.relnamespace - left join pg_catalog.pg_index pgi - on pgi.indrelid = pgc.oid - left join pg_catalog.pg_depend dep - on pgc.oid = dep.objid - and dep.deptype = 'e' -where - pgc.relkind = 'r' -- regular tables - and pgns.nspname not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - and dep.objid is null -- exclude tables owned by extensions -group by - pgc.oid, - pgns.nspname, - pgc.relname -having - max(coalesce(pgi.indisprimary, false)::int) = 0) -union all -( -select - 'unused_index' as "name!", - 'Unused Index' as "title!", - 'INFO' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Detects if an index has never been used and may be a candidate for removal.' as "description!", - format( - 'Index \`%s\` on table \`%s.%s\` has not been used', - psui.indexrelname, - psui.schemaname, - psui.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0005_unused_index' as "remediation!", - jsonb_build_object( - 'schema', psui.schemaname, - 'name', psui.relname, - 'type', 'table' - ) as "metadata!", - format( - 'unused_index_%s_%s_%s', - psui.schemaname, - psui.relname, - psui.indexrelname - ) as "cache_key!" - -from - pg_catalog.pg_stat_user_indexes psui - join pg_catalog.pg_index pi - on psui.indexrelid = pi.indexrelid - left join pg_catalog.pg_depend dep - on psui.relid = dep.objid - and dep.deptype = 'e' -where - psui.idx_scan = 0 - and not pi.indisunique - and not pi.indisprimary - and dep.objid is null -- exclude tables owned by extensions - and psui.schemaname not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - )) -union all -( -select - 'multiple_permissive_policies' as "name!", - 'Multiple Permissive Policies' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Detects if multiple permissive row level security policies are present on a table for the same \`role\` and \`action\` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.' as "description!", - format( - 'Table \`%s.%s\` has multiple permissive policies for role \`%s\` for action \`%s\`. Policies include \`%s\`', - n.nspname, - c.relname, - r.rolname, - act.cmd, - array_agg(p.polname order by p.polname) - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'table' - ) as "metadata!", - format( - 'multiple_permissive_policies_%s_%s_%s_%s', - n.nspname, - c.relname, - r.rolname, - act.cmd - ) as "cache_key!" -from - pg_catalog.pg_policy p - join pg_catalog.pg_class c - on p.polrelid = c.oid - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid - join pg_catalog.pg_roles r - on p.polroles @> array[r.oid] - or p.polroles = array[0::oid] - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e', - lateral ( - select x.cmd - from unnest(( - select - case p.polcmd - when 'r' then array['SELECT'] - when 'a' then array['INSERT'] - when 'w' then array['UPDATE'] - when 'd' then array['DELETE'] - when '*' then array['SELECT', 'INSERT', 'UPDATE', 'DELETE'] - else array['ERROR'] - end as actions - )) x(cmd) - ) act(cmd) -where - c.relkind = 'r' -- regular tables - and p.polpermissive -- policy is permissive - and n.nspname not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - and r.rolname not like 'pg_%' - and r.rolname not like 'supabase%admin' - and not r.rolbypassrls - and dep.objid is null -- exclude tables owned by extensions -group by - n.nspname, - c.relname, - r.rolname, - act.cmd -having - count(1) > 1) -union all -( -select - 'policy_exists_rls_disabled' as "name!", - 'Policy Exists RLS Disabled' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table.' as "description!", - format( - 'Table \`%s.%s\` has RLS policies but RLS is not enabled on the table. Policies include %s.', - n.nspname, - c.relname, - array_agg(p.polname order by p.polname) - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0007_policy_exists_rls_disabled' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'table' - ) as "metadata!", - format( - 'policy_exists_rls_disabled_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_policy p - join pg_catalog.pg_class c - on p.polrelid = c.oid - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e' -where - c.relkind = 'r' -- regular tables - and n.nspname not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - -- RLS is disabled - and not c.relrowsecurity - and dep.objid is null -- exclude tables owned by extensions -group by - n.nspname, - c.relname) -union all -( -select - 'rls_enabled_no_policy' as "name!", - 'RLS Enabled No Policy' as "title!", - 'INFO' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created.' as "description!", - format( - 'Table \`%s.%s\` has RLS enabled, but no policies exist', - n.nspname, - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0008_rls_enabled_no_policy' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'table' - ) as "metadata!", - format( - 'rls_enabled_no_policy_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_class c - left join pg_catalog.pg_policy p - on p.polrelid = c.oid - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e' -where - c.relkind = 'r' -- regular tables - and n.nspname not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - -- RLS is enabled - and c.relrowsecurity - and p.polname is null - and dep.objid is null -- exclude tables owned by extensions -group by - n.nspname, - c.relname) -union all -( -select - 'duplicate_index' as "name!", - 'Duplicate Index' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Detects cases where two ore more identical indexes exist.' as "description!", - format( - 'Table \`%s.%s\` has identical indexes %s. Drop all except one of them', - n.nspname, - c.relname, - array_agg(pi.indexname order by pi.indexname) - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0009_duplicate_index' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', case - when c.relkind = 'r' then 'table' - when c.relkind = 'm' then 'materialized view' - else 'ERROR' - end, - 'indexes', array_agg(pi.indexname order by pi.indexname) - ) as "metadata!", - format( - 'duplicate_index_%s_%s_%s', - n.nspname, - c.relname, - array_agg(pi.indexname order by pi.indexname) - ) as "cache_key!" -from - pg_catalog.pg_indexes pi - join pg_catalog.pg_namespace n - on n.nspname = pi.schemaname - join pg_catalog.pg_class c - on pi.tablename = c.relname - and n.oid = c.relnamespace - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e' -where - c.relkind in ('r', 'm') -- tables and materialized views - and n.nspname not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - and dep.objid is null -- exclude tables owned by extensions -group by - n.nspname, - c.relkind, - c.relname, - replace(pi.indexdef, pi.indexname, '') -having - count(*) > 1) -union all -( -select - 'security_definer_view' as "name!", - 'Security Definer View' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user' as "description!", - format( - 'View \`%s.%s\` is defined with the SECURITY DEFINER property', - n.nspname, - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'view' - ) as "metadata!", - format( - 'security_definer_view_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_class c - join pg_catalog.pg_namespace n - on n.oid = c.relnamespace - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e' -where - c.relkind = 'v' - and ( - pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') - or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') - ) - and substring(pg_catalog.version() from 'PostgreSQL ([0-9]+)') >= '15' -- security invoker was added in pg15 - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) - and n.nspname not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - and dep.objid is null -- exclude views owned by extensions - and not ( - lower(coalesce(c.reloptions::text,'{}'))::text[] - && array[ - 'security_invoker=1', - 'security_invoker=true', - 'security_invoker=yes', - 'security_invoker=on' - ] - )) -union all -( -select - 'function_search_path_mutable' as "name!", - 'Function Search Path Mutable' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects functions where the search_path parameter is not set.' as "description!", - format( - 'Function \`%s.%s\` has a role mutable search_path', - n.nspname, - p.proname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0011_function_search_path_mutable' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', p.proname, - 'type', 'function' - ) as "metadata!", - format( - 'function_search_path_mutable_%s_%s_%s', - n.nspname, - p.proname, - md5(p.prosrc) -- required when function is polymorphic - ) as "cache_key!" -from - pg_catalog.pg_proc p - join pg_catalog.pg_namespace n - on p.pronamespace = n.oid - left join pg_catalog.pg_depend dep - on p.oid = dep.objid - and dep.deptype = 'e' -where - n.nspname not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - and dep.objid is null -- exclude functions owned by extensions - -- Search path not set - and not exists ( - select 1 - from unnest(coalesce(p.proconfig, '{}')) as config - where config like 'search_path=%' - )) -union all -( -select - 'rls_disabled_in_public' as "name!", - 'RLS Disabled in Public' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST' as "description!", - format( - 'Table \`%s.%s\` is public, but RLS has not been enabled.', - n.nspname, - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'table' - ) as "metadata!", - format( - 'rls_disabled_in_public_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_class c - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid -where - c.relkind = 'r' -- regular tables - -- RLS is disabled - and not c.relrowsecurity - and ( - pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') - or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') - ) - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) - and n.nspname not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - )) -union all -( -select - 'extension_in_public' as "name!", - 'Extension in Public' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects extensions installed in the \`public\` schema.' as "description!", - format( - 'Extension \`%s\` is installed in the public schema. Move it to another schema.', - pe.extname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0014_extension_in_public' as "remediation!", - jsonb_build_object( - 'schema', pe.extnamespace::regnamespace, - 'name', pe.extname, - 'type', 'extension' - ) as "metadata!", - format( - 'extension_in_public_%s', - pe.extname - ) as "cache_key!" -from - pg_catalog.pg_extension pe -where - -- plpgsql is installed by default in public and outside user control - -- confirmed safe - pe.extname not in ('plpgsql') - -- Scoping this to public is not optimal. Ideally we would use the postgres - -- search path. That currently isn't available via SQL. In other lints - -- we have used has_schema_privilege('anon', 'extensions', 'USAGE') but that - -- is not appropriate here as it would evaluate true for the extensions schema - and pe.extnamespace::regnamespace::text = 'public') -union all -( -with policies as ( - select - nsp.nspname as schema_name, - pb.tablename as table_name, - polname as policy_name, - qual, - with_check - from - pg_catalog.pg_policy pa - join pg_catalog.pg_class pc - on pa.polrelid = pc.oid - join pg_catalog.pg_namespace nsp - on pc.relnamespace = nsp.oid - join pg_catalog.pg_policies pb - on pc.relname = pb.tablename - and nsp.nspname = pb.schemaname - and pa.polname = pb.policyname -) -select - 'rls_references_user_metadata' as "name!", - 'RLS references user metadata' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy.' as "description!", - format( - 'Table \`%s.%s\` has a row level security policy \`%s\` that references Supabase Auth \`user_metadata\`. \`user_metadata\` is editable by end users and should never be used in a security context.', - schema_name, - table_name, - policy_name - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata' as "remediation!", - jsonb_build_object( - 'schema', schema_name, - 'name', table_name, - 'type', 'table' - ) as "metadata!", - format('rls_references_user_metadata_%s_%s_%s', schema_name, table_name, policy_name) as "cache_key!" -from - policies -where - schema_name not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - and ( - -- Example: auth.jwt() -> 'user_metadata' - -- False positives are possible, but it isn't practical to string match - -- If false positive rate is too high, this expression can iterate - qual like '%auth.jwt()%user_metadata%' - or qual like '%current_setting(%request.jwt.claims%)%user_metadata%' - or with_check like '%auth.jwt()%user_metadata%' - or with_check like '%current_setting(%request.jwt.claims%)%user_metadata%' - )) -union all -( -select - 'materialized_view_in_api' as "name!", - 'Materialized View in API' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects materialized views that are accessible over the Data APIs.' as "description!", - format( - 'Materialized view \`%s.%s\` is selectable by anon or authenticated roles', - n.nspname, - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'materialized view' - ) as "metadata!", - format( - 'materialized_view_in_api_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_class c - join pg_catalog.pg_namespace n - on n.oid = c.relnamespace - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e' -where - c.relkind = 'm' - and ( - pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') - or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') - ) - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) - and n.nspname not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - and dep.objid is null) -union all -( -select - 'foreign_table_in_api' as "name!", - 'Foreign Table in API' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies.' as "description!", - format( - 'Foreign table \`%s.%s\` is accessible over APIs', - n.nspname, - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'foreign table' - ) as "metadata!", - format( - 'foreign_table_in_api_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_class c - join pg_catalog.pg_namespace n - on n.oid = c.relnamespace - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e' -where - c.relkind = 'f' - and ( - pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') - or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') - ) - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) - and n.nspname not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - and dep.objid is null) -union all -( -select - 'unsupported_reg_types' as "name!", - 'Unsupported reg types' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade.' as "description!", - format( - 'Table \`%s.%s\` has a column \`%s\` with unsupported reg* type \`%s\`.', - n.nspname, - c.relname, - a.attname, - t.typname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=unsupported_reg_types' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'column', a.attname, - 'type', 'table' - ) as "metadata!", - format( - 'unsupported_reg_types_%s_%s_%s', - n.nspname, - c.relname, - a.attname - ) AS cache_key -from - pg_catalog.pg_attribute a - join pg_catalog.pg_class c - on a.attrelid = c.oid - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid - join pg_catalog.pg_type t - on a.atttypid = t.oid - join pg_catalog.pg_namespace tn - on t.typnamespace = tn.oid -where - tn.nspname = 'pg_catalog' - and t.typname in ('regcollation', 'regconfig', 'regdictionary', 'regnamespace', 'regoper', 'regoperator', 'regproc', 'regprocedure') - and n.nspname not in ('pg_catalog', 'information_schema', 'pgsodium')) -union all -( -select - 'insecure_queue_exposed_in_api' as "name!", - 'Insecure Queue Exposed in API' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects cases where an insecure Queue is exposed over Data APIs' as "description!", - format( - 'Table \`%s.%s\` is public, but RLS has not been enabled.', - n.nspname, - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0019_insecure_queue_exposed_in_api' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'table' - ) as "metadata!", - format( - 'rls_disabled_in_public_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_class c - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid -where - c.relkind in ('r', 'I') -- regular or partitioned tables - and not c.relrowsecurity -- RLS is disabled - and ( - pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') - or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') - ) - and n.nspname = 'pgmq' -- tables in the pgmq schema - and c.relname like 'q_%' -- only queue tables - -- Constant requirements - and 'pgmq_public' = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))) -union all -( -with constants as ( - select current_setting('block_size')::numeric as bs, 23 as hdr, 4 as ma -), - -bloat_info as ( - select - ma, - bs, - schemaname, - tablename, - (datawidth + (hdr + ma - (case when hdr % ma = 0 then ma else hdr % ma end)))::numeric as datahdr, - (maxfracsum * (nullhdr + ma - (case when nullhdr % ma = 0 then ma else nullhdr % ma end))) as nullhdr2 - from ( - select - schemaname, - tablename, - hdr, - ma, - bs, - sum((1 - null_frac) * avg_width) as datawidth, - max(null_frac) as maxfracsum, - hdr + ( - select 1 + count(*) / 8 - from pg_stats s2 - where - null_frac <> 0 - and s2.schemaname = s.schemaname - and s2.tablename = s.tablename - ) as nullhdr - from pg_stats s, constants - group by 1, 2, 3, 4, 5 - ) as foo -), - -table_bloat as ( - select - schemaname, - tablename, - cc.relpages, - bs, - ceil((cc.reltuples * ((datahdr + ma - - (case when datahdr % ma = 0 then ma else datahdr % ma end)) + nullhdr2 + 4)) / (bs - 20::float)) as otta - from - bloat_info - join pg_class cc - on cc.relname = bloat_info.tablename - join pg_namespace nn - on cc.relnamespace = nn.oid - and nn.nspname = bloat_info.schemaname - and nn.nspname <> 'information_schema' - where - cc.relkind = 'r' - and cc.relam = (select oid from pg_am where amname = 'heap') -), - -bloat_data as ( - select - 'table' as type, - schemaname, - tablename as object_name, - round(case when otta = 0 then 0.0 else table_bloat.relpages / otta::numeric end, 1) as bloat, - case when relpages < otta then 0 else (bs * (table_bloat.relpages - otta)::bigint)::bigint end as raw_waste - from - table_bloat -) - -select - 'table_bloat' as "name!", - 'Table Bloat' as "title!", - 'INFO' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Detects if a table has excess bloat and may benefit from maintenance operations like vacuum full or cluster.' as "description!", - format( - 'Table `%s`.`%s` has excessive bloat', - bloat_data.schemaname, - bloat_data.object_name - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0020_table_bloat' as "remediation!", - jsonb_build_object( - 'schema', bloat_data.schemaname, - 'name', bloat_data.object_name, - 'type', bloat_data.type - ) as "metadata!", - format( - 'table_bloat_%s_%s', - bloat_data.schemaname, - bloat_data.object_name - ) as "cache_key!" -from - bloat_data -where - bloat > 70.0 - and raw_waste > (20 * 1024 * 1024) -- filter for waste > 200 MB -order by - schemaname, - object_name) -union all -( -select - 'fkey_to_auth_unique' as "name!", - 'Foreign Key to Auth Unique Constraint' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects user defined foreign keys to unique constraints in the auth schema.' as "description!", - format( - 'Table `%s`.`%s` has a foreign key `%s` referencing an auth unique constraint', - n.nspname, -- referencing schema - c_rel.relname, -- referencing table - c.conname -- fkey name - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0021_fkey_to_auth_unique' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c_rel.relname, - 'foreign_key', c.conname - ) as "metadata!", - format( - 'fkey_to_auth_unique_%s_%s_%s', - n.nspname, -- referencing schema - c_rel.relname, -- referencing table - c.conname - ) as "cache_key!" -from - pg_catalog.pg_constraint c - join pg_catalog.pg_class c_rel - on c.conrelid = c_rel.oid - join pg_catalog.pg_namespace n - on c_rel.relnamespace = n.oid - join pg_catalog.pg_class ref_rel - on c.confrelid = ref_rel.oid - join pg_catalog.pg_namespace cn - on ref_rel.relnamespace = cn.oid - join pg_catalog.pg_index i - on c.conindid = i.indexrelid -where c.contype = 'f' - and cn.nspname = 'auth' - and i.indisunique - and not i.indisprimary) -union all -( -select - 'extension_versions_outdated' as "name!", - 'Extension Versions Outdated' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects extensions that are not using the default (recommended) version.' as "description!", - format( - 'Extension `%s` is using version `%s` but version `%s` is available. Using outdated extension versions may expose the database to security vulnerabilities.', - ext.name, - ext.installed_version, - ext.default_version - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0022_extension_versions_outdated' as "remediation!", - jsonb_build_object( - 'extension_name', ext.name, - 'installed_version', ext.installed_version, - 'default_version', ext.default_version - ) as "metadata!", - format( - 'extension_versions_outdated_%s_%s', - ext.name, - ext.installed_version - ) as "cache_key!" -from - pg_catalog.pg_available_extensions ext -join - -- ignore versions not in pg_available_extension_versions - -- e.g. residue of pg_upgrade - pg_catalog.pg_available_extension_versions extv - on extv.name = ext.name and extv.installed -where - ext.installed_version is not null - and ext.default_version is not null - and ext.installed_version != ext.default_version -order by - ext.name) \ No newline at end of file diff --git a/crates/pgls_splinter/vendor/splinter_generic.sql b/crates/pgls_splinter/vendor/splinter_generic.sql deleted file mode 100644 index 8421701f9..000000000 --- a/crates/pgls_splinter/vendor/splinter_generic.sql +++ /dev/null @@ -1,652 +0,0 @@ - -( -with foreign_keys as ( - select - cl.relnamespace::regnamespace::text as schema_name, - cl.relname as table_name, - cl.oid as table_oid, - ct.conname as fkey_name, - ct.conkey as col_attnums - from - pg_catalog.pg_constraint ct - join pg_catalog.pg_class cl -- fkey owning table - on ct.conrelid = cl.oid - left join pg_catalog.pg_depend d - on d.objid = cl.oid - and d.deptype = 'e' - where - ct.contype = 'f' -- foreign key constraints - and d.objid is null -- exclude tables that are dependencies of extensions - and cl.relnamespace::regnamespace::text not in ( - 'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'extensions' - ) -), -index_ as ( - select - pi.indrelid as table_oid, - indexrelid::regclass as index_, - string_to_array(indkey::text, ' ')::smallint[] as col_attnums - from - pg_catalog.pg_index pi - where - indisvalid -) -select - 'unindexed_foreign_keys' as "name!", - 'Unindexed foreign keys' as "title!", - 'INFO' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Identifies foreign key constraints without a covering index, which can impact database performance.' as "description!", - format( - 'Table \`%s.%s\` has a foreign key \`%s\` without a covering index. This can lead to suboptimal query performance.', - fk.schema_name, - fk.table_name, - fk.fkey_name - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys' as "remediation!", - jsonb_build_object( - 'schema', fk.schema_name, - 'name', fk.table_name, - 'type', 'table', - 'fkey_name', fk.fkey_name, - 'fkey_columns', fk.col_attnums - ) as "metadata!", - format('unindexed_foreign_keys_%s_%s_%s', fk.schema_name, fk.table_name, fk.fkey_name) as "cache_key!" -from - foreign_keys fk - left join index_ idx - on fk.table_oid = idx.table_oid - and fk.col_attnums = idx.col_attnums[1:array_length(fk.col_attnums, 1)] - left join pg_catalog.pg_depend dep - on idx.table_oid = dep.objid - and dep.deptype = 'e' -where - idx.index_ is null - and fk.schema_name not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - and dep.objid is null -- exclude tables owned by extensions -order by - fk.schema_name, - fk.table_name, - fk.fkey_name) -union all - -( -select - 'no_primary_key' as "name!", - 'No Primary Key' as "title!", - 'INFO' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale.' as "description!", - format( - 'Table \`%s.%s\` does not have a primary key', - pgns.nspname, - pgc.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0004_no_primary_key' as "remediation!", - jsonb_build_object( - 'schema', pgns.nspname, - 'name', pgc.relname, - 'type', 'table' - ) as "metadata!", - format( - 'no_primary_key_%s_%s', - pgns.nspname, - pgc.relname - ) as "cache_key!" -from - pg_catalog.pg_class pgc - join pg_catalog.pg_namespace pgns - on pgns.oid = pgc.relnamespace - left join pg_catalog.pg_index pgi - on pgi.indrelid = pgc.oid - left join pg_catalog.pg_depend dep - on pgc.oid = dep.objid - and dep.deptype = 'e' -where - pgc.relkind = 'r' -- regular tables - and pgns.nspname not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - and dep.objid is null -- exclude tables owned by extensions -group by - pgc.oid, - pgns.nspname, - pgc.relname -having - max(coalesce(pgi.indisprimary, false)::int) = 0) -union all - -( -select - 'unused_index' as "name!", - 'Unused Index' as "title!", - 'INFO' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Detects if an index has never been used and may be a candidate for removal.' as "description!", - format( - 'Index \`%s\` on table \`%s.%s\` has not been used', - psui.indexrelname, - psui.schemaname, - psui.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0005_unused_index' as "remediation!", - jsonb_build_object( - 'schema', psui.schemaname, - 'name', psui.relname, - 'type', 'table' - ) as "metadata!", - format( - 'unused_index_%s_%s_%s', - psui.schemaname, - psui.relname, - psui.indexrelname - ) as "cache_key!" - -from - pg_catalog.pg_stat_user_indexes psui - join pg_catalog.pg_index pi - on psui.indexrelid = pi.indexrelid - left join pg_catalog.pg_depend dep - on psui.relid = dep.objid - and dep.deptype = 'e' -where - psui.idx_scan = 0 - and not pi.indisunique - and not pi.indisprimary - and dep.objid is null -- exclude tables owned by extensions - and psui.schemaname not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - )) -union all - -( -select - 'multiple_permissive_policies' as "name!", - 'Multiple Permissive Policies' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Detects if multiple permissive row level security policies are present on a table for the same \`role\` and \`action\` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.' as "description!", - format( - 'Table \`%s.%s\` has multiple permissive policies for role \`%s\` for action \`%s\`. Policies include \`%s\`', - n.nspname, - c.relname, - r.rolname, - act.cmd, - array_agg(p.polname order by p.polname) - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'table' - ) as "metadata!", - format( - 'multiple_permissive_policies_%s_%s_%s_%s', - n.nspname, - c.relname, - r.rolname, - act.cmd - ) as "cache_key!" -from - pg_catalog.pg_policy p - join pg_catalog.pg_class c - on p.polrelid = c.oid - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid - join pg_catalog.pg_roles r - on p.polroles @> array[r.oid] - or p.polroles = array[0::oid] - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e', - lateral ( - select x.cmd - from unnest(( - select - case p.polcmd - when 'r' then array['SELECT'] - when 'a' then array['INSERT'] - when 'w' then array['UPDATE'] - when 'd' then array['DELETE'] - when '*' then array['SELECT', 'INSERT', 'UPDATE', 'DELETE'] - else array['ERROR'] - end as actions - )) x(cmd) - ) act(cmd) -where - c.relkind = 'r' -- regular tables - and p.polpermissive -- policy is permissive - and n.nspname not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - and r.rolname not like 'pg_%' - and r.rolname not like 'supabase%admin' - and not r.rolbypassrls - and dep.objid is null -- exclude tables owned by extensions -group by - n.nspname, - c.relname, - r.rolname, - act.cmd -having - count(1) > 1) -union all - -( -select - 'policy_exists_rls_disabled' as "name!", - 'Policy Exists RLS Disabled' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table.' as "description!", - format( - 'Table \`%s.%s\` has RLS policies but RLS is not enabled on the table. Policies include %s.', - n.nspname, - c.relname, - array_agg(p.polname order by p.polname) - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0007_policy_exists_rls_disabled' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'table' - ) as "metadata!", - format( - 'policy_exists_rls_disabled_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_policy p - join pg_catalog.pg_class c - on p.polrelid = c.oid - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e' -where - c.relkind = 'r' -- regular tables - and n.nspname not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - -- RLS is disabled - and not c.relrowsecurity - and dep.objid is null -- exclude tables owned by extensions -group by - n.nspname, - c.relname) -union all - -( -select - 'rls_enabled_no_policy' as "name!", - 'RLS Enabled No Policy' as "title!", - 'INFO' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created.' as "description!", - format( - 'Table \`%s.%s\` has RLS enabled, but no policies exist', - n.nspname, - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0008_rls_enabled_no_policy' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'table' - ) as "metadata!", - format( - 'rls_enabled_no_policy_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_class c - left join pg_catalog.pg_policy p - on p.polrelid = c.oid - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e' -where - c.relkind = 'r' -- regular tables - and n.nspname not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - -- RLS is enabled - and c.relrowsecurity - and p.polname is null - and dep.objid is null -- exclude tables owned by extensions -group by - n.nspname, - c.relname) -union all - -( -select - 'duplicate_index' as "name!", - 'Duplicate Index' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Detects cases where two ore more identical indexes exist.' as "description!", - format( - 'Table \`%s.%s\` has identical indexes %s. Drop all except one of them', - n.nspname, - c.relname, - array_agg(pi.indexname order by pi.indexname) - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0009_duplicate_index' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', case - when c.relkind = 'r' then 'table' - when c.relkind = 'm' then 'materialized view' - else 'ERROR' - end, - 'indexes', array_agg(pi.indexname order by pi.indexname) - ) as "metadata!", - format( - 'duplicate_index_%s_%s_%s', - n.nspname, - c.relname, - array_agg(pi.indexname order by pi.indexname) - ) as "cache_key!" -from - pg_catalog.pg_indexes pi - join pg_catalog.pg_namespace n - on n.nspname = pi.schemaname - join pg_catalog.pg_class c - on pi.tablename = c.relname - and n.oid = c.relnamespace - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e' -where - c.relkind in ('r', 'm') -- tables and materialized views - and n.nspname not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - and dep.objid is null -- exclude tables owned by extensions -group by - n.nspname, - c.relkind, - c.relname, - replace(pi.indexdef, pi.indexname, '') -having - count(*) > 1) -union all - -( -select - 'function_search_path_mutable' as "name!", - 'Function Search Path Mutable' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects functions where the search_path parameter is not set.' as "description!", - format( - 'Function \`%s.%s\` has a role mutable search_path', - n.nspname, - p.proname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0011_function_search_path_mutable' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', p.proname, - 'type', 'function' - ) as "metadata!", - format( - 'function_search_path_mutable_%s_%s_%s', - n.nspname, - p.proname, - md5(p.prosrc) -- required when function is polymorphic - ) as "cache_key!" -from - pg_catalog.pg_proc p - join pg_catalog.pg_namespace n - on p.pronamespace = n.oid - left join pg_catalog.pg_depend dep - on p.oid = dep.objid - and dep.deptype = 'e' -where - n.nspname not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - and dep.objid is null -- exclude functions owned by extensions - -- Search path not set - and not exists ( - select 1 - from unnest(coalesce(p.proconfig, '{}')) as config - where config like 'search_path=%' - )) -union all - -( -select - 'extension_in_public' as "name!", - 'Extension in Public' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects extensions installed in the \`public\` schema.' as "description!", - format( - 'Extension \`%s\` is installed in the public schema. Move it to another schema.', - pe.extname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0014_extension_in_public' as "remediation!", - jsonb_build_object( - 'schema', pe.extnamespace::regnamespace, - 'name', pe.extname, - 'type', 'extension' - ) as "metadata!", - format( - 'extension_in_public_%s', - pe.extname - ) as "cache_key!" -from - pg_catalog.pg_extension pe -where - -- plpgsql is installed by default in public and outside user control - -- confirmed safe - pe.extname not in ('plpgsql') - -- Scoping this to public is not optimal. Ideally we would use the postgres - -- search path. That currently isn't available via SQL. In other lints - -- we have used has_schema_privilege('anon', 'extensions', 'USAGE') but that - -- is not appropriate here as it would evaluate true for the extensions schema - and pe.extnamespace::regnamespace::text = 'public') -union all - -( -select - 'unsupported_reg_types' as "name!", - 'Unsupported reg types' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade.' as "description!", - format( - 'Table \`%s.%s\` has a column \`%s\` with unsupported reg* type \`%s\`.', - n.nspname, - c.relname, - a.attname, - t.typname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=unsupported_reg_types' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'column', a.attname, - 'type', 'table' - ) as "metadata!", - format( - 'unsupported_reg_types_%s_%s_%s', - n.nspname, - c.relname, - a.attname - ) AS cache_key -from - pg_catalog.pg_attribute a - join pg_catalog.pg_class c - on a.attrelid = c.oid - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid - join pg_catalog.pg_type t - on a.atttypid = t.oid - join pg_catalog.pg_namespace tn - on t.typnamespace = tn.oid -where - tn.nspname = 'pg_catalog' - and t.typname in ('regcollation', 'regconfig', 'regdictionary', 'regnamespace', 'regoper', 'regoperator', 'regproc', 'regprocedure') - and n.nspname not in ('pg_catalog', 'information_schema', 'pgsodium')) -union all - -( -with constants as ( - select current_setting('block_size')::numeric as bs, 23 as hdr, 4 as ma -), - -bloat_info as ( - select - ma, - bs, - schemaname, - tablename, - (datawidth + (hdr + ma - (case when hdr % ma = 0 then ma else hdr % ma end)))::numeric as datahdr, - (maxfracsum * (nullhdr + ma - (case when nullhdr % ma = 0 then ma else nullhdr % ma end))) as nullhdr2 - from ( - select - schemaname, - tablename, - hdr, - ma, - bs, - sum((1 - null_frac) * avg_width) as datawidth, - max(null_frac) as maxfracsum, - hdr + ( - select 1 + count(*) / 8 - from pg_stats s2 - where - null_frac <> 0 - and s2.schemaname = s.schemaname - and s2.tablename = s.tablename - ) as nullhdr - from pg_stats s, constants - group by 1, 2, 3, 4, 5 - ) as foo -), - -table_bloat as ( - select - schemaname, - tablename, - cc.relpages, - bs, - ceil((cc.reltuples * ((datahdr + ma - - (case when datahdr % ma = 0 then ma else datahdr % ma end)) + nullhdr2 + 4)) / (bs - 20::float)) as otta - from - bloat_info - join pg_class cc - on cc.relname = bloat_info.tablename - join pg_namespace nn - on cc.relnamespace = nn.oid - and nn.nspname = bloat_info.schemaname - and nn.nspname <> 'information_schema' - where - cc.relkind = 'r' - and cc.relam = (select oid from pg_am where amname = 'heap') -), - -bloat_data as ( - select - 'table' as type, - schemaname, - tablename as object_name, - round(case when otta = 0 then 0.0 else table_bloat.relpages / otta::numeric end, 1) as bloat, - case when relpages < otta then 0 else (bs * (table_bloat.relpages - otta)::bigint)::bigint end as raw_waste - from - table_bloat -) - -select - 'table_bloat' as "name!", - 'Table Bloat' as "title!", - 'INFO' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Detects if a table has excess bloat and may benefit from maintenance operations like vacuum full or cluster.' as "description!", - format( - 'Table `%s`.`%s` has excessive bloat', - bloat_data.schemaname, - bloat_data.object_name - ) as "detail!", - 'Consider running vacuum full (WARNING: incurs downtime) and tweaking autovacuum settings to reduce bloat.' as "remediation!", - jsonb_build_object( - 'schema', bloat_data.schemaname, - 'name', bloat_data.object_name, - 'type', bloat_data.type - ) as "metadata!", - format( - 'table_bloat_%s_%s', - bloat_data.schemaname, - bloat_data.object_name - ) as "cache_key!" -from - bloat_data -where - bloat > 70.0 - and raw_waste > (20 * 1024 * 1024) -- filter for waste > 200 MB -order by - schemaname, - object_name) -union all - -( -select - 'extension_versions_outdated' as "name!", - 'Extension Versions Outdated' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects extensions that are not using the default (recommended) version.' as "description!", - format( - 'Extension `%s` is using version `%s` but version `%s` is available. Using outdated extension versions may expose the database to security vulnerabilities.', - ext.name, - ext.installed_version, - ext.default_version - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0022_extension_versions_outdated' as "remediation!", - jsonb_build_object( - 'extension_name', ext.name, - 'installed_version', ext.installed_version, - 'default_version', ext.default_version - ) as "metadata!", - format( - 'extension_versions_outdated_%s_%s', - ext.name, - ext.installed_version - ) as "cache_key!" -from - pg_catalog.pg_available_extensions ext -join - -- ignore versions not in pg_available_extension_versions - -- e.g. residue of pg_upgrade - pg_catalog.pg_available_extension_versions extv - on extv.name = ext.name and extv.installed -where - ext.installed_version is not null - and ext.default_version is not null - and ext.installed_version != ext.default_version -order by - ext.name) \ No newline at end of file diff --git a/crates/pgls_splinter/vendor/splinter_supabase.sql b/crates/pgls_splinter/vendor/splinter_supabase.sql deleted file mode 100644 index 79387c083..000000000 --- a/crates/pgls_splinter/vendor/splinter_supabase.sql +++ /dev/null @@ -1,516 +0,0 @@ - -( -select - 'auth_users_exposed' as "name!", - 'Exposed Auth Users' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security.' as "description!", - format( - 'View/Materialized View "%s" in the public schema may expose \`auth.users\` data to anon or authenticated roles.', - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'view', - 'exposed_to', array_remove(array_agg(DISTINCT case when pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') then 'anon' when pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') then 'authenticated' end), null) - ) as "metadata!", - format('auth_users_exposed_%s_%s', n.nspname, c.relname) as "cache_key!" -from - -- Identify the oid for auth.users - pg_catalog.pg_class auth_users_pg_class - join pg_catalog.pg_namespace auth_users_pg_namespace - on auth_users_pg_class.relnamespace = auth_users_pg_namespace.oid - and auth_users_pg_class.relname = 'users' - and auth_users_pg_namespace.nspname = 'auth' - -- Depends on auth.users - join pg_catalog.pg_depend d - on d.refobjid = auth_users_pg_class.oid - join pg_catalog.pg_rewrite r - on r.oid = d.objid - join pg_catalog.pg_class c - on c.oid = r.ev_class - join pg_catalog.pg_namespace n - on n.oid = c.relnamespace - join pg_catalog.pg_class pg_class_auth_users - on d.refobjid = pg_class_auth_users.oid -where - d.deptype = 'n' - and ( - pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') - or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') - ) - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) - -- Exclude self - and c.relname <> '0002_auth_users_exposed' - -- There are 3 insecure configurations - and - ( - -- Materialized views don't support RLS so this is insecure by default - (c.relkind in ('m')) -- m for materialized view - or - -- Standard View, accessible to anon or authenticated that is security_definer - ( - c.relkind = 'v' -- v for view - -- Exclude security invoker views - and not ( - lower(coalesce(c.reloptions::text,'{}'))::text[] - && array[ - 'security_invoker=1', - 'security_invoker=true', - 'security_invoker=yes', - 'security_invoker=on' - ] - ) - ) - or - -- Standard View, security invoker, but no RLS enabled on auth.users - ( - c.relkind in ('v') -- v for view - -- is security invoker - and ( - lower(coalesce(c.reloptions::text,'{}'))::text[] - && array[ - 'security_invoker=1', - 'security_invoker=true', - 'security_invoker=yes', - 'security_invoker=on' - ] - ) - and not pg_class_auth_users.relrowsecurity - ) - ) -group by - n.nspname, - c.relname, - c.oid) -union all - -( -with policies as ( - select - nsp.nspname as schema_name, - pb.tablename as table_name, - pc.relrowsecurity as is_rls_active, - polname as policy_name, - polpermissive as is_permissive, -- if not, then restrictive - (select array_agg(r::regrole) from unnest(polroles) as x(r)) as roles, - case polcmd - when 'r' then 'SELECT' - when 'a' then 'INSERT' - when 'w' then 'UPDATE' - when 'd' then 'DELETE' - when '*' then 'ALL' - end as command, - qual, - with_check - from - pg_catalog.pg_policy pa - join pg_catalog.pg_class pc - on pa.polrelid = pc.oid - join pg_catalog.pg_namespace nsp - on pc.relnamespace = nsp.oid - join pg_catalog.pg_policies pb - on pc.relname = pb.tablename - and nsp.nspname = pb.schemaname - and pa.polname = pb.policyname -) -select - 'auth_rls_initplan' as "name!", - 'Auth RLS Initialization Plan' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Detects if calls to \`current_setting()\` and \`auth.()\` in RLS policies are being unnecessarily re-evaluated for each row' as "description!", - format( - 'Table \`%s.%s\` has a row level security policy \`%s\` that re-evaluates current_setting() or auth.() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing \`auth.()\` with \`(select auth.())\`. See [docs](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) for more info.', - schema_name, - table_name, - policy_name - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan' as "remediation!", - jsonb_build_object( - 'schema', schema_name, - 'name', table_name, - 'type', 'table' - ) as "metadata!", - format('auth_rls_init_plan_%s_%s_%s', schema_name, table_name, policy_name) as "cache_key!" -from - policies -where - is_rls_active - -- NOTE: does not include realtime in support of monitoring policies on realtime.messages - and schema_name not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - and ( - -- Example: auth.uid() - ( - qual like '%auth.uid()%' - and lower(qual) not like '%select auth.uid()%' - ) - or ( - qual like '%auth.jwt()%' - and lower(qual) not like '%select auth.jwt()%' - ) - or ( - qual like '%auth.role()%' - and lower(qual) not like '%select auth.role()%' - ) - or ( - qual like '%auth.email()%' - and lower(qual) not like '%select auth.email()%' - ) - or ( - qual like '%current\_setting(%)%' - and lower(qual) not like '%select current\_setting(%)%' - ) - or ( - with_check like '%auth.uid()%' - and lower(with_check) not like '%select auth.uid()%' - ) - or ( - with_check like '%auth.jwt()%' - and lower(with_check) not like '%select auth.jwt()%' - ) - or ( - with_check like '%auth.role()%' - and lower(with_check) not like '%select auth.role()%' - ) - or ( - with_check like '%auth.email()%' - and lower(with_check) not like '%select auth.email()%' - ) - or ( - with_check like '%current\_setting(%)%' - and lower(with_check) not like '%select current\_setting(%)%' - ) - )) -union all - -( -select - 'security_definer_view' as "name!", - 'Security Definer View' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user' as "description!", - format( - 'View \`%s.%s\` is defined with the SECURITY DEFINER property', - n.nspname, - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'view' - ) as "metadata!", - format( - 'security_definer_view_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_class c - join pg_catalog.pg_namespace n - on n.oid = c.relnamespace - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e' -where - c.relkind = 'v' - and ( - pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') - or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') - ) - and substring(pg_catalog.version() from 'PostgreSQL ([0-9]+)') >= '15' -- security invoker was added in pg15 - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) - and n.nspname not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - and dep.objid is null -- exclude views owned by extensions - and not ( - lower(coalesce(c.reloptions::text,'{}'))::text[] - && array[ - 'security_invoker=1', - 'security_invoker=true', - 'security_invoker=yes', - 'security_invoker=on' - ] - )) -union all - -( -select - 'rls_disabled_in_public' as "name!", - 'RLS Disabled in Public' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST' as "description!", - format( - 'Table \`%s.%s\` is public, but RLS has not been enabled.', - n.nspname, - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'table' - ) as "metadata!", - format( - 'rls_disabled_in_public_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_class c - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid -where - c.relkind = 'r' -- regular tables - -- RLS is disabled - and not c.relrowsecurity - and ( - pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') - or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') - ) - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) - and n.nspname not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - )) -union all - -( -with policies as ( - select - nsp.nspname as schema_name, - pb.tablename as table_name, - polname as policy_name, - qual, - with_check - from - pg_catalog.pg_policy pa - join pg_catalog.pg_class pc - on pa.polrelid = pc.oid - join pg_catalog.pg_namespace nsp - on pc.relnamespace = nsp.oid - join pg_catalog.pg_policies pb - on pc.relname = pb.tablename - and nsp.nspname = pb.schemaname - and pa.polname = pb.policyname -) -select - 'rls_references_user_metadata' as "name!", - 'RLS references user metadata' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy.' as "description!", - format( - 'Table \`%s.%s\` has a row level security policy \`%s\` that references Supabase Auth \`user_metadata\`. \`user_metadata\` is editable by end users and should never be used in a security context.', - schema_name, - table_name, - policy_name - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata' as "remediation!", - jsonb_build_object( - 'schema', schema_name, - 'name', table_name, - 'type', 'table' - ) as "metadata!", - format('rls_references_user_metadata_%s_%s_%s', schema_name, table_name, policy_name) as "cache_key!" -from - policies -where - schema_name not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - and ( - -- Example: auth.jwt() -> 'user_metadata' - -- False positives are possible, but it isn't practical to string match - -- If false positive rate is too high, this expression can iterate - qual like '%auth.jwt()%user_metadata%' - or qual like '%current_setting(%request.jwt.claims%)%user_metadata%' - or with_check like '%auth.jwt()%user_metadata%' - or with_check like '%current_setting(%request.jwt.claims%)%user_metadata%' - )) -union all - -( -select - 'materialized_view_in_api' as "name!", - 'Materialized View in API' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects materialized views that are accessible over the Data APIs.' as "description!", - format( - 'Materialized view \`%s.%s\` is selectable by anon or authenticated roles', - n.nspname, - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'materialized view' - ) as "metadata!", - format( - 'materialized_view_in_api_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_class c - join pg_catalog.pg_namespace n - on n.oid = c.relnamespace - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e' -where - c.relkind = 'm' - and ( - pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') - or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') - ) - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) - and n.nspname not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - and dep.objid is null) -union all - -( -select - 'foreign_table_in_api' as "name!", - 'Foreign Table in API' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies.' as "description!", - format( - 'Foreign table \`%s.%s\` is accessible over APIs', - n.nspname, - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'foreign table' - ) as "metadata!", - format( - 'foreign_table_in_api_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_class c - join pg_catalog.pg_namespace n - on n.oid = c.relnamespace - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e' -where - c.relkind = 'f' - and ( - pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') - or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') - ) - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) - and n.nspname not in ( - '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - and dep.objid is null) -union all - -( -select - 'insecure_queue_exposed_in_api' as "name!", - 'Insecure Queue Exposed in API' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects cases where an insecure Queue is exposed over Data APIs' as "description!", - format( - 'Table \`%s.%s\` is public, but RLS has not been enabled.', - n.nspname, - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0019_insecure_queue_exposed_in_api' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'table' - ) as "metadata!", - format( - 'rls_disabled_in_public_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_class c - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid -where - c.relkind in ('r', 'I') -- regular or partitioned tables - and not c.relrowsecurity -- RLS is disabled - and ( - pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') - or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') - ) - and n.nspname = 'pgmq' -- tables in the pgmq schema - and c.relname like 'q_%' -- only queue tables - -- Constant requirements - and 'pgmq_public' = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))) -union all - -( -select - 'fkey_to_auth_unique' as "name!", - 'Foreign Key to Auth Unique Constraint' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects user defined foreign keys to unique constraints in the auth schema.' as "description!", - format( - 'Table `%s`.`%s` has a foreign key `%s` referencing an auth unique constraint', - n.nspname, -- referencing schema - c_rel.relname, -- referencing table - c.conname -- fkey name - ) as "detail!", - 'Drop the foreign key constraint that references the auth schema.' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c_rel.relname, - 'foreign_key', c.conname - ) as "metadata!", - format( - 'fkey_to_auth_unique_%s_%s_%s', - n.nspname, -- referencing schema - c_rel.relname, -- referencing table - c.conname - ) as "cache_key!" -from - pg_catalog.pg_constraint c - join pg_catalog.pg_class c_rel - on c.conrelid = c_rel.oid - join pg_catalog.pg_namespace n - on c_rel.relnamespace = n.oid - join pg_catalog.pg_class ref_rel - on c.confrelid = ref_rel.oid - join pg_catalog.pg_namespace cn - on ref_rel.relnamespace = cn.oid - join pg_catalog.pg_index i - on c.conindid = i.indexrelid -where c.contype = 'f' - and cn.nspname = 'auth' - and i.indisunique - and not i.indisprimary) From c5f8b077b82eedaecbac1992a96315d4a025c15a Mon Sep 17 00:00:00 2001 From: psteinroe Date: Tue, 16 Dec 2025 09:31:26 +0100 Subject: [PATCH 30/32] chore: trigger CI re-run From 08d9b3818c8a89822a52220ef85aa60869b2e8ec Mon Sep 17 00:00:00 2001 From: psteinroe Date: Tue, 16 Dec 2025 17:49:02 +0100 Subject: [PATCH 31/32] refactor: remove type aliases from pgls_analyser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed confusing type aliases that created two names for the same types: - Rule → LinterRule - RuleContext → LinterRuleContext - RuleDiagnostic → LinterDiagnostic Updated all 34 rule files and the codegen template to use the full type names for better clarity and consistency. The aliases made the API confusing by having two names for the same type (e.g., "is RuleDiagnostic different from LinterDiagnostic?"). All rule implementations now consistently use: - use crate::{LinterRule, LinterRuleContext, LinterDiagnostic}; - impl LinterRule for RuleName { ... } --- PLAN.md | 907 ------------------ crates/pgls_analyser/src/lib.rs | 5 - .../src/lint/safety/add_serial_column.rs | 10 +- .../lint/safety/adding_field_with_default.rs | 12 +- .../safety/adding_foreign_key_constraint.rs | 10 +- .../src/lint/safety/adding_not_null_field.rs | 8 +- .../safety/adding_primary_key_constraint.rs | 10 +- .../src/lint/safety/adding_required_field.rs | 8 +- .../src/lint/safety/ban_char_field.rs | 12 +- ...oncurrent_index_creation_in_transaction.rs | 8 +- .../src/lint/safety/ban_drop_column.rs | 8 +- .../src/lint/safety/ban_drop_database.rs | 8 +- .../src/lint/safety/ban_drop_not_null.rs | 8 +- .../src/lint/safety/ban_drop_table.rs | 8 +- .../src/lint/safety/ban_truncate_cascade.rs | 8 +- .../src/lint/safety/changing_column_type.rs | 8 +- .../safety/constraint_missing_not_valid.rs | 10 +- .../src/lint/safety/creating_enum.rs | 8 +- .../lint/safety/disallow_unique_constraint.rs | 10 +- .../src/lint/safety/lock_timeout_warning.rs | 10 +- .../src/lint/safety/multiple_alter_table.rs | 8 +- .../src/lint/safety/prefer_big_int.rs | 10 +- .../src/lint/safety/prefer_bigint_over_int.rs | 10 +- .../safety/prefer_bigint_over_smallint.rs | 10 +- .../src/lint/safety/prefer_identity.rs | 10 +- .../src/lint/safety/prefer_jsonb.rs | 10 +- .../src/lint/safety/prefer_robust_stmts.rs | 12 +- .../src/lint/safety/prefer_text_field.rs | 10 +- .../src/lint/safety/prefer_timestamptz.rs | 10 +- .../src/lint/safety/renaming_column.rs | 8 +- .../src/lint/safety/renaming_table.rs | 8 +- .../require_concurrent_index_creation.rs | 8 +- .../require_concurrent_index_deletion.rs | 8 +- ...tatement_while_holding_access_exclusive.rs | 8 +- .../src/lint/safety/transaction_nesting.rs | 12 +- .../codegen/src/generate_new_analyser_rule.rs | 10 +- 36 files changed, 158 insertions(+), 1070 deletions(-) delete mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index a1e3ee745..000000000 --- a/PLAN.md +++ /dev/null @@ -1,907 +0,0 @@ -# Splinter Integration Plan - -## Goal -Integrate splinter into the codegen/rule setup used for the analyser, providing a consistent API (both internally and user-facing) for all types of analysers/linters. - -## Architecture Vision - -### Crate Responsibilities - -**`pgls_analyse`** - Generic framework for all analyzer types -- Generic traits: `RuleMeta`, `RuleGroup`, `GroupCategory`, `RegistryVisitor` -- Shared types: `RuleMetadata`, `RuleCategory` -- Configuration traits (execution-agnostic) -- Macros: `declare_rule!`, `declare_group!`, `declare_category!` - -**`pgls_linter`** (renamed from `pgls_analyser`) - AST-based source code linting -- `LinterRule` trait (extends `RuleMeta`) -- `LinterRuleContext` (wraps AST nodes) -- `LinterDiagnostic` (span-based diagnostics) -- `LinterRuleRegistry` (type-erased executors) - -**`pgls_splinter`** - Database-level linting -- `SplinterRule` trait (extends `RuleMeta`) -- `SplinterRuleRegistry` (metadata-based) -- `SplinterDiagnostic` (db-object-based diagnostics) -- Generated rule types from SQL files - -**`pgls_configuration`** -- `analyser/linter/` - Generated from `pgls_linter` -- `analyser/splinter/` - Generated from `pgls_splinter` -- Per-rule configuration for both - -## Implementation Phases - -### Phase 1: Refactor pgls_analyse ✅ COMPLETED -Extract AST-specific code into pgls_linter, keep only generic framework in pgls_analyse. - -**Tasks:** -- [x] Analyze current `pgls_analyse` exports -- [x] Identify AST-specific vs generic code -- [x] Create new modules in `pgls_analyser`: - - [x] `linter_rule.rs` - LinterRule trait, LinterDiagnostic - - [x] `linter_context.rs` - LinterRuleContext, AnalysedFileContext - - [x] `linter_options.rs` - LinterOptions, LinterRules - - [x] `linter_registry.rs` - LinterRuleRegistry, LinterRegistryVisitor -- [x] Create `pgls_analyse/src/metadata.rs` - Generic traits only -- [x] Update `pgls_analyse/src/registry.rs` - Keep MetadataRegistry only -- [x] Update `pgls_analyse/src/lib.rs` - Export generic framework -- [x] Update `pgls_analyser/src/lib.rs` - Use new modules -- [x] Fix imports in filter.rs (RuleMeta instead of Rule) -- [x] Update generated files (options.rs, registry.rs) -- [x] Fix imports in all rule files -- [x] Add rustc-hash dependency -- [x] Verify compilation completes -- [x] Separate visitor concerns from executor creation -- [x] Update codegen to generate factory function -- [x] Fix all import paths across workspace -- [x] Verify full workspace compiles -- [x] Optimize executor creation (zero-cost abstraction) - -**Resolution:** -Separated two concerns: -1. **Visitor pattern** (generic): Collects rule keys that match the filter - - Implementation in `LinterRuleRegistryBuilder::record_rule` - - Only requires `R: RuleMeta` (satisfies trait) -2. **Executor mapping** (AST-specific): Maps rule keys directly to executors - - Function `get_linter_rule_executor` in `registry.rs` - - Generated by codegen with full type information - - Zero-cost abstraction: no Box, no dyn, no closures - -**Final Implementation:** -- `LinterRuleRegistryBuilder` stores `Vec` from visitor traversal -- `record_rule` only collects keys (generic, no LinterRule bounds) -- `build()` calls `get_linter_rule_executor` for each key -- Generated match statement returns executors directly (no heap allocation) - -**Performance:** -- ✅ No `Box` - returns values directly -- ✅ No closure overhead - simple match statement -- ✅ No dynamic dispatch - static dispatch only -- ✅ Clean codegen - 33 rules map to executors efficiently - -**Design Decisions:** -- ✅ Keep `RuleDiagnostic` generic or make it linter-specific? → **Move to pgls_linter as LinterDiagnostic** (Option A) - - Rationale: Fundamentally different location models (spans vs db objects) - - LinterDiagnostic: span-based - - SplinterDiagnostic: db-object-based - -**Code Classification:** - -AST-specific (move to pgls_analyser): -- `Rule` trait -- `RuleContext` -- `RuleDiagnostic` → `LinterDiagnostic` -- `AnalysedFileContext` -- `RegistryRuleParams` -- `RuleRegistry`, `RuleRegistryBuilder` (AST execution) -- `AnalyserOptions`, `AnalyserRules` (rule options storage) - -Generic (keep in pgls_analyse): -- `RuleMeta` trait -- `RuleMetadata` struct -- `RuleGroup` trait -- `GroupCategory` trait -- `RegistryVisitor` trait -- `RuleCategory` enum -- `RuleSource` enum -- `RuleFilter`, `AnalysisFilter`, `RuleKey`, `GroupKey` -- `MetadataRegistry` -- Macros: `declare_rule!`, `declare_lint_rule!`, `declare_lint_group!`, `declare_category!` - ---- - -### Phase 2: Enhance pgls_splinter ✅ COMPLETED -Add rule type generation and registry similar to linter. - -**Tasks:** -- [x] Create `pgls_splinter/src/rule.rs` with `SplinterRule` trait -- [x] Create `pgls_splinter/src/rules/` directory structure -- [x] Generate rule types from SQL files -- [x] Generate registry with `visit_registry()` function -- [x] Split monolithic SQL files into individual rule files with metadata -- [x] Create codegen to extract metadata from SQL comments -- [x] Generate get_sql_file_path() function for SQL file mapping -- [ ] Update diagnostics to use generated categories (deferred) -- [ ] Update runtime to build dynamic queries (deferred to Phase 3) - -**Structure:** -``` -pgls_splinter/src/ - rules/ - performance/ - unindexed_foreign_keys.rs # Generated - auth_rls_initplan.rs # Generated - ... # 7 total - mod.rs # Generated group - security/ - auth_users_exposed.rs # Generated - ... # 14 total - mod.rs # Generated group - mod.rs # Generated category - rule.rs # Generated SplinterRule trait - registry.rs # Generated visit_registry() + get_sql_file_path() - -pgls_splinter/vendor/ - performance/ - *.sql # 7 individual SQL files with metadata - security/ - *.sql # 14 individual SQL files with metadata -``` - -**Implementation Summary:** -- Implemented Option C (Hybrid Approach) from initial analysis -- SQL files remain source of truth with metadata comments -- Codegen extracts metadata and generates Rust structures -- `SplinterRule` trait extends `RuleMeta` with `sql_file_path()` method -- Registry provides centralized rule discovery via visitor pattern -- Category structure: `Splinter` (Lint) → `Performance`/`Security` (groups) → individual rules -- Successfully compiles without errors - ---- - -### Phase 3: Configuration Integration ✅ COMPLETED -Integrate splinter into the configuration system. - -**Tasks:** -- [x] **Configuration Generation**: - - [x] Create `pgls_configuration/src/analyser/splinter/` directory - - [x] Generate splinter configuration types (Performance/Security groups, Rules struct) - - [x] Update `generate_configuration.rs` to visit splinter registry - - [x] Add `SplinterRulesVisitor` for rule collection - - [x] Generate `crates/pgls_configuration/src/analyser/splinter/rules.rs` with: - - [x] `RuleGroup` enum (Performance, Security) - - [x] `Rules` struct with recommended/all/group fields - - [x] `Performance` struct with 7 rules (using `RuleConfiguration<()>`) - - [x] `Security` struct with 14 rules (using `RuleConfiguration<()>`) - - [x] All helper methods (has_rule, severity, get_enabled_rules, etc.) - - [x] Generate `crates/pgls_configuration/src/generated/splinter.rs` with `push_to_analyser_splinter()` - - [x] Update `analyser/mod.rs` to export splinter config - - [x] Fix imports: use `pgls_analyser::RuleOptions` instead of `pgls_analyse::options::RuleOptions` - - [x] Fix type references: use `LinterRules` instead of `AnalyserRules` - - [x] Run `just gen-lint` successfully - - [x] Verify full workspace compiles - -- [ ] **Documentation Enhancement** (PHASE 5): - - [ ] Add SQL query examples to splinter rule docs - - [ ] Extract SQL from vendor/*.sql files into doc comments - - [ ] Add usage examples and remediation steps - - [ ] Generate rule documentation via docs_codegen - -- [x] **Runtime Integration** (PHASE 5 - COMPLETED): - - [x] Update `run_splinter()` to use visitor pattern with AnalysisFilter - - [x] Build dynamic SQL queries from enabled rules only - - [x] Remove hardcoded SQL query execution (removed load_generic/load_supabase functions) - - [ ] Remove hardcoded category mapping in convert.rs (DEFERRED - requires codegen improvements) - - [ ] Add splinter to RuleSelector enum (DEFERRED - requires design decisions for multi-analyzer support) - -**Codegen Outputs After Phase 3:** -``` -Linter: - - crates/pgls_analyser/src/registry.rs (generated - ✅ DONE) - - crates/pgls_analyser/src/options.rs (generated - ✅ DONE) - - crates/pgls_configuration/src/analyser/linter/rules.rs (generated - ✅ DONE) - - crates/pgls_configuration/src/generated/linter.rs (generated - ✅ DONE) - -Splinter: - - crates/pgls_splinter/src/rules/ (generated - ✅ DONE) - - crates/pgls_splinter/src/rule.rs (generated - ✅ DONE) - - crates/pgls_splinter/src/registry.rs (generated - ✅ DONE) - - crates/pgls_configuration/src/analyser/splinter/mod.rs (created - ✅ DONE) - - crates/pgls_configuration/src/analyser/splinter/rules.rs (generated - ✅ DONE) - - crates/pgls_configuration/src/generated/splinter.rs (generated - ✅ DONE) -``` - -**Implementation Details:** -- Configuration structure mirrors linter configuration for consistency -- Splinter rules use `RuleConfiguration<()>` since they have no rule-specific options -- All 21 rules (7 performance + 14 security) are properly configured with severities from SQL metadata -- Category name in `get_severity_from_code()` correctly uses "splinter" prefix -- No recommended rules by default (RECOMMENDED_RULES_AS_FILTERS is empty) - -**Config File Example:** -```json -{ - "splinter": { - "enabled": true, - "rules": { - "all": true, - "performance": { - "unindexedForeignKeys": "warn", - "noPrimaryKey": "off" - }, - "security": { - "authUsersExposed": "error" - } - } - } -} -``` - -**Notes:** -- ✅ Configuration generation is complete and tested -- ✅ Runtime integration (dynamic SQL query building) completed in Phase 5 -- 📋 Documentation enhancement with SQL examples planned for Phase 5 (Part C) -- 📋 RuleSelector integration deferred (requires design for multi-analyzer support) -- 📋 Category mapping in convert.rs still hardcoded (can be improved with codegen) - ---- - -### Phase 4: Rename pgls_analyser → pgls_linter 📋 PLANNED -Final rename to clarify purpose. - -**Tasks:** -- [ ] Rename crate in Cargo.toml -- [ ] Update all imports -- [ ] Update documentation -- [ ] Update CLAUDE.md / AGENTS.md -- [ ] Verify tests pass - ---- - -### Phase 5: Runtime & Documentation Enhancements ✅ COMPLETED -Advanced features for splinter integration. - -**Part B: Dynamic SQL Query Building** ✅ -**Part C: Enhanced Documentation** ✅ -**Deferred Items: All Completed** ✅ - -**Implementation Summary (Part B):** - -The runtime integration has been completed with the following changes: - -1. **Updated `run_splinter()` signature** (`crates/pgls_splinter/src/lib.rs`): - - Now accepts `filter: &AnalysisFilter<'_>` parameter - - Uses visitor pattern to collect enabled rules based on filter - - Returns early if no rules are enabled (performance optimization) - -2. **Implemented `SplinterRuleCollector` visitor**: - - Properly implements all RegistryVisitor methods (record_category, record_group, record_rule) - - Filters at each level (category, group, rule) for efficiency - - Collects rule names (camelCase) for enabled rules only - -3. **Dynamic SQL query building**: - - Reads individual SQL files from `vendor/` directory based on enabled rules - - Uses `crate::registry::get_sql_file_path()` to map rule names to SQL file paths - - Combines multiple SQL queries with `UNION ALL` - - Only executes SQL for enabled rules (major performance improvement) - -4. **Removed hardcoded functions**: - - Deleted `load_generic_splinter_results()` - - Deleted `load_supabase_splinter_results()` - - Removed Supabase role checking logic (rules are now filtered by configuration) - -5. **Updated test call sites**: - - All tests now pass `AnalysisFilter::default()` to enable all rules - - Maintains backward compatibility for test behavior - -6. **Added manual `FromRow` implementation**: - - `SplinterQueryResult` now implements `FromRow` manually (was using compile-time macro) - - Enables dynamic SQL execution while maintaining type safety - -**Performance Benefits:** -- 🚀 Only enabled rules execute SQL queries -- 🚀 Can disable expensive rules individually via configuration -- 🚀 Example: Disabling 18/21 rules means only 3 SQL queries execute instead of all 21 - -**Deferred Items (Now Completed):** -- ✅ Category mapping in `convert.rs` - **COMPLETED** - - Generated `get_rule_category()` function in `registry.rs` via codegen - - Replaced 120-line match statement with single function call - - Maps snake_case SQL result names to static Category references -- ✅ RuleSelector enum multi-analyzer support - **COMPLETED** - - Added splinter-specific variants: `SplinterGroup`, `SplinterRule` - - Implemented prefix-based parsing (`lint/`, `splinter/`) - - Maintains backward compatibility (tries linter first) -- ✅ Supabase role checking - **COMPLETED** - - Added `requires_supabase` metadata to SQL files - - Generated `rule_requires_supabase()` function - - Implemented in-memory role checking via `SchemaCache` - - Automatically filters Supabase rules when roles don't exist - - Zero configuration needed from users -- ✅ Documentation enhancement (Part C) - **COMPLETED** - ---- - -#### **Part A: Configuration Structure Design** - -**Goal:** Create a splinter configuration structure that mirrors linter but shares common code. - -**Shared Components** (from `analyser/mod.rs`): -- `RuleConfiguration` - Configuration wrapper with severity levels -- `RulePlainConfiguration` - Severity enum (Warn, Error, Info, Off) -- Merge/Deserialize traits - -**New Splinter-Specific Types** (to generate in Phase 3): - -```rust -// analyser/splinter/mod.rs -pub struct SplinterConfiguration { - /// Enable/disable splinter linting - pub enabled: bool, - - /// Splinter rules configuration - pub rules: Rules, - - /// Ignore/include patterns (shared with linter) - pub ignore: StringSet, - pub include: StringSet, -} - -// analyser/splinter/rules.rs (GENERATED) -pub enum RuleGroup { - Performance, - Security, -} - -pub struct Rules { - /// Enable recommended rules - pub recommended: Option, - - /// Enable all rules - pub all: Option, - - /// Performance group - pub performance: Option, - - /// Security group - pub security: Option, -} - -// Performance group (GENERATED) -pub struct Performance { - pub recommended: Option, - pub all: Option, - - // Individual rules - note: using RuleConfiguration<()> since no options - pub unindexed_foreign_keys: Option>, - pub auth_rls_initplan: Option>, - pub duplicate_index: Option>, - pub multiple_permissive_policies: Option>, - pub no_primary_key: Option>, - pub table_bloat: Option>, - pub unused_index: Option>, -} - -impl Performance { - const GROUP_NAME: &'static str = "performance"; - const GROUP_RULES: &'static [&'static str] = &[ - "unindexedForeignKeys", - "authRlsInitplan", - // ... all 7 rules - ]; - - // Methods mirroring Safety group - pub fn has_rule(rule_name: &str) -> Option<&'static str> { /* ... */ } - pub fn severity(rule_name: &str) -> Severity { /* ... */ } - pub fn all_rules_as_filters() -> impl Iterator> { /* ... */ } - pub fn recommended_rules_as_filters() -> impl Iterator> { /* ... */ } - pub fn collect_preset_rules(&self, ...) { /* ... */ } - pub fn get_enabled_rules(&self) -> FxHashSet> { /* ... */ } - pub fn get_disabled_rules(&self) -> FxHashSet> { /* ... */ } -} - -// Security group (GENERATED) - same structure, 14 rules -pub struct Security { /* ... */ } -``` - -**Config File Example:** -```json -{ - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "safety": { - "addSerialColumn": "error" - } - } - }, - "splinter": { - "enabled": true, - "rules": { - "recommended": true, - "performance": { - "unindexedForeignKeys": "warn", - "noPrimaryKey": "off" - }, - "security": { - "all": true, - "authUsersExposed": "error" - } - } - } -} -``` - -**Code Sharing Strategy:** -- ✅ Reuse `RuleConfiguration` with `T = ()` for splinter (no rule options) -- ✅ Reuse severity conversion logic -- ✅ Same `as_enabled_rules()` / `as_disabled_rules()` pattern -- ✅ Same methods for each group (has_rule, severity, etc.) -- ⚠️ RuleGroup enum is separate (linter has Safety, splinter has Performance/Security) -- ⚠️ RuleSelector needs updating to handle both "lint/" and "splinter/" prefixes - ---- - -#### **Part B: Dynamic SQL Query Building** - -**Tasks:** -- [ ] Modify `run_splinter()` signature: - ```rust - pub async fn run_splinter( - params: SplinterParams<'_>, - filter: &AnalysisFilter, // NEW - ) -> Result, sqlx::Error> - ``` - -- [ ] Use visitor pattern to collect enabled rules: - ```rust - struct SplinterRuleCollector<'a> { - filter: &'a AnalysisFilter<'a>, - enabled_rules: Vec, // Rule names in camelCase - } - - impl RegistryVisitor for SplinterRuleCollector<'_> { - fn record_rule(&mut self) { - if self.filter.match_rule::() { - self.enabled_rules.push(R::METADATA.name.to_string()); - } - } - } - ``` - -- [ ] Build dynamic SQL query: - ```rust - let mut collector = SplinterRuleCollector { filter, enabled_rules: Vec::new() }; - crate::registry::visit_registry(&mut collector); - - // Map rule names to SQL file paths - let mut sql_queries = Vec::new(); - for rule_name in &collector.enabled_rules { - if let Some(sql_path) = crate::registry::get_sql_file_path(rule_name) { - let sql = std::fs::read_to_string(sql_path)?; - sql_queries.push(sql); - } - } - - // Combine with UNION ALL (only if enabled rules exist) - if sql_queries.is_empty() { - return Ok(Vec::new()); - } - - let combined_sql = sql_queries.join("\nUNION ALL\n"); - let results = sqlx::query_as::<_, SplinterQueryResult>(&combined_sql) - .fetch_all(params.conn) - .await?; - ``` - -- [ ] Remove hardcoded functions: - - Delete `load_generic_splinter_results()` - - Delete `load_supabase_splinter_results()` - - Remove `check_supabase_roles()` logic (rules are filtered by config) - -- [ ] Update convert.rs: - - Use generated category from `RuleMeta::METADATA.category` instead of `rule_name_to_category()` - - Or better: get category from diagnostic category system - ---- - -#### **Part C: Enhanced Documentation** ✅ - -**Status:** COMPLETED - -**Tasks:** -- [x] Extract SQL queries into rule doc comments -- [x] Add configuration examples to documentation -- [x] Include Supabase requirement warnings -- [x] Link to remediation documentation -- [x] Generate comprehensive doc strings via codegen - -**Implementation:** - -The codegen (`xtask/codegen/src/generate_splinter.rs`) now generates rich documentation for all splinter rules: - -1. **Added `sql_query` field to `SqlRuleMetadata`:** - - Extracts SQL content after metadata comment headers - - Preserves formatting for readability - - Strips metadata lines (`-- meta:` prefix) - -2. **Generated comprehensive doc strings** including: - - **Title and description** from SQL metadata - - **Supabase requirement note** (conditional): - ``` - **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). - It will be automatically skipped if these roles don't exist in your database. - ``` - - **Full SQL query** in code fence with `/// ` prefix on each line - - **Configuration JSON example** showing how to enable/disable: - ```json - { - "splinter": { - "rules": { - "security": { - "authUsersExposed": "warn" - } - } - } - } - ``` - - **Remediation link** to Supabase docs or custom guidance - -3. **Generated documentation visible via `cargo doc`:** - - All 21 rules now have comprehensive documentation - - Developers can view SQL queries directly in IDE - - Easy to understand what each rule checks - -**Example Generated Documentation:** -```rust -#[doc = "/// # Unindexed foreign keys\n///\n/// Identifies foreign key constraints without a covering index, which can impact database performance.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// with foreign_keys as (\n/// select\n/// cl.relnamespace::regnamespace::text as schema_name,\n/// ... [full SQL query]\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"performance\": {\n/// \"unindexedForeignKeys\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] -pub UnindexedForeignKeys { ... } -``` - -**Files Updated:** -- `xtask/codegen/src/generate_splinter.rs` - Enhanced documentation generation -- All 21 rule files in `crates/pgls_splinter/src/rules/` - Regenerated with rich docs - ---- - -#### **Benefits Summary:** - -**Performance:** -- 🚀 Only execute SQL for enabled rules (vs. running all 21 rules) -- 🚀 Skip expensive queries when rules are disabled -- 🚀 Example: User disables 18/21 rules → only 3 SQL queries execute - -**Consistency:** -- ✅ Same enable/disable mechanism as linter -- ✅ Same configuration file structure -- ✅ Same visitor pattern for rule discovery - -**Maintainability:** -- ✅ No hardcoded SQL queries in Rust -- ✅ SQL files remain source of truth -- ✅ Adding new rules = add SQL file + run codegen -- ✅ No manual category mapping needed - -**Documentation:** -- ✅ Rule docs show actual SQL query -- ✅ Better understanding of what each rule does -- ✅ Easier to debug and customize - ---- - -#### **Migration Path:** - -**Phase 3** (Configuration Integration): -1. Generate splinter configuration types -2. Wire into config system -3. Users can enable/disable via config (but all enabled rules still run) - -**Phase 5** (This Phase - Runtime Optimization): -1. Update `run_splinter()` to use filter -2. Build dynamic SQL queries -3. Performance benefit: only enabled rules execute - -This allows incremental rollout - config works in Phase 3, optimization comes in Phase 5. - ---- - -## Progress Tracking - -### Current Status -- [x] Requirements gathering & design -- [x] Architecture proposal (Option C - Hybrid Approach) -- [x] Phase 1: Refactor pgls_analyse - **COMPLETED** -- [x] Phase 2: Enhance pgls_splinter - **COMPLETED** -- [x] Phase 3: Configuration Integration - **COMPLETED** -- [x] Phase 5 Part B: Runtime Integration - **COMPLETED** -- [x] Phase 5 Part C: Documentation Enhancement - **COMPLETED** -- [x] Phase 5 Deferred Items: Category mapping, RuleSelector, Supabase roles - **COMPLETED** -- [ ] Phase 4: Rename to pgls_linter - **PLANNED** - -### Summary -**✅ Integration Complete (Phases 1-5):** -- Generic framework (`pgls_analyse`) successfully extracted -- Splinter rules generated from SQL files with metadata -- Configuration system mirrors linter structure -- All 21 splinter rules (7 performance + 14 security) properly configured -- Dynamic SQL query building with configuration-based filtering -- Hardcoded category mapping replaced with generated functions -- RuleSelector supports both linter and splinter prefixes -- Automatic Supabase role detection via schema cache -- Comprehensive documentation generated with SQL queries and examples -- Full workspace compiles successfully - -**📋 Remaining Work:** -- Phase 4: Crate rename `pgls_analyser` → `pgls_linter` (planned) - -### Open Questions -None currently - -### Decisions Made -1. Use `LinterRule` (not `ASTRule` or `SourceCodeRule`) for clarity -2. Use `SplinterRule` for database-level rules -3. Keep codegen in xtask (not build.rs) for consistency -4. Mirror file structure between linter and splinter - ---- - -## Testing Strategy -- [x] Full workspace compiles successfully -- [x] Splinter rules generate correctly from SQL -- [x] Configuration generation runs without errors -- [ ] Existing linter tests continue to pass (not verified) -- [ ] Configuration schema validates (not verified) -- [ ] Integration test: enable/disable rules via config (requires runtime integration) -- [ ] Integration test: severity overrides work (requires runtime integration) - -**Status:** Basic compilation testing complete. Full integration testing deferred to when runtime integration is implemented. - ---- - -Last updated: 2025-12-15 - -## Phase 5 Part B Implementation Notes - -**Date Completed:** 2025-12-15 - -**Changes Made:** - -1. **File: `crates/pgls_splinter/src/lib.rs`** - - Added `SplinterRuleCollector` struct implementing `RegistryVisitor` - - Updated `run_splinter()` to accept `AnalysisFilter` parameter - - Implemented dynamic SQL query building from enabled rules - - Removed dependency on hardcoded `load_generic/load_supabase` functions - -2. **File: `crates/pgls_splinter/src/query.rs`** - - Added manual `FromRow` implementation for `SplinterQueryResult` - - Removed `load_generic_splinter_results()` function - - Removed `load_supabase_splinter_results()` function - - Added note explaining the removal - -3. **File: `crates/pgls_splinter/tests/diagnostics.rs`** - - Updated all test call sites to pass `AnalysisFilter::default()` - - Added import for `pgls_analyse::AnalysisFilter` - -**Testing:** -- Full workspace compiles successfully: ✅ -- `cargo check -p pgls_splinter` passes with only generated code warnings: ✅ -- No functional regressions expected (behavior is equivalent but more efficient) - -**Migration Notes for Users:** -- Any code calling `run_splinter()` must now pass an `AnalysisFilter` -- For "run all rules" behavior, use `AnalysisFilter::default()` -- Tests updated to demonstrate correct usage - ---- - -## Phase 5 Deferred Items - Implementation Notes - -**Date Completed:** 2025-12-15 - -### 1. Category Mapping Removal - -**Problem:** `convert.rs` contained a 120-line hardcoded `rule_name_to_category()` function mapping rule names to categories. - -**Solution:** -- Extended codegen to generate `get_rule_category()` function in `registry.rs` -- Maps snake_case SQL result names (e.g., "unindexed_foreign_keys") to static Category references -- Automatically stays in sync with SQL file metadata - -**Files Changed:** -- `xtask/codegen/src/generate_splinter.rs` - Added category lookup generation -- `crates/pgls_splinter/src/registry.rs` - Generated function (auto-generated) -- `crates/pgls_splinter/src/convert.rs` - Replaced match statement with single function call - -**Example:** -```rust -// Before: 120 lines of match statements -fn rule_name_to_category(name: &str, group: &str) -> &'static Category { - match (group, name) { - ("performance", "unindexed_foreign_keys") => category!("splinter/performance/unindexedForeignKeys"), - // ... 60+ more lines - } -} - -// After: Single function call -let category = crate::registry::get_rule_category(&result.name) - .expect("Rule name should map to a valid category"); -``` - ---- - -### 2. RuleSelector Multi-Analyzer Support - -**Problem:** `RuleSelector` enum only supported linter rules (groups: Safety). - -**Solution:** -- Split enum variants into analyzer-specific types: - - `LinterGroup` / `LinterRule` for linter rules - - `SplinterGroup` / `SplinterRule` for splinter rules -- Added prefix-based parsing (`lint/`, `splinter/`) -- Maintained backward compatibility (unprefixed selectors try linter first) - -**Files Changed:** -- `crates/pgls_configuration/src/analyser/mod.rs` - Updated RuleSelector enum and parsing - -**Example Configuration:** -```json -{ - "linter": { - "ignore": ["lint/safety/banDropTable"] // Linter rule with prefix - }, - "overrides": [ - { - "ignore": [ - "splinter/security/authUsersExposed", // Splinter rule with prefix - "multipleAlterTable" // Linter rule (backward compatible) - ] - } - ] -} -``` - ---- - -### 3. Supabase Role Checking - -**Problem:** Supabase-specific rules (9 out of 21) should automatically be skipped on non-Supabase databases without requiring configuration changes. - -**Solution:** -- Added `-- meta: requires_supabase = true` to 9 SQL files -- Generated `rule_requires_supabase()` function in `registry.rs` -- Updated `SplinterParams` to accept optional `SchemaCache` -- Implemented in-memory role checking (looks for `anon`, `authenticated`, `service_role` roles) -- Filters rules before building SQL query (performance optimization) - -**Files Changed:** -- 9 SQL files in `crates/pgls_splinter/vendor/` - Added metadata -- `xtask/codegen/src/generate_splinter.rs` - Extract and generate metadata -- `crates/pgls_splinter/src/lib.rs` - Added role checking logic -- `crates/pgls_splinter/Cargo.toml` - Added `pgls_schema_cache` dependency - -**Example:** -```rust -// Check if Supabase roles exist -let has_supabase_roles = params.schema_cache.map_or(false, |cache| { - let required_roles = ["anon", "authenticated", "service_role"]; - required_roles.iter().all(|role_name| { - cache.roles.iter().any(|role| role.name.as_str() == *role_name) - }) -}); - -// Skip Supabase-specific rules if roles don't exist -for rule_name in &collector.enabled_rules { - if !has_supabase_roles && crate::registry::rule_requires_supabase(rule_name) { - continue; // Automatically skipped - zero config needed! - } - // ... load and execute SQL -} -``` - -**Supabase-Specific Rules:** -1. `authRlsInitplan` (performance) -2. `authUsersExposed` (security) -3. `fkeyToAuthUnique` (security) -4. `foreignTableInApi` (security) -5. `insecureQueueExposedInApi` (security) -6. `materializedViewInApi` (security) -7. `rlsDisabledInPublic` (security) -8. `rlsReferencesUserMetadata` (security) -9. `securityDefinerView` (security) - ---- - -## Phase 5 Part C - Implementation Notes - -**Date Completed:** 2025-12-15 - -### Documentation Enhancement - -**Goal:** Generate comprehensive documentation for all splinter rules, including SQL queries, configuration examples, and remediation links. - -**Implementation:** - -1. **Extended `SqlRuleMetadata` struct:** - - Added `sql_query: String` field - - Added `requires_supabase: bool` field - - Extracts SQL content after metadata comment headers - - Preserves formatting and removes metadata lines - -2. **Generated comprehensive doc strings:** - - Built using `format!` macro with multiple sections - - Includes title, description, Supabase warning, SQL query, configuration example, and remediation link - - Each line prefixed with `/// ` for Rust doc comments - - SQL query wrapped in triple-backtick code fence - -3. **Documentation Sections:** - - **Title and Description**: From SQL metadata - - **Supabase Note** (conditional): Warns about role requirements - - **SQL Query**: Full query in code fence with syntax highlighting - - **Configuration**: JSON example showing how to enable/disable - - **Remediation**: Link to documentation or custom guidance - -**Files Changed:** -- `xtask/codegen/src/generate_splinter.rs` - Added doc string generation -- All 21 rule files in `crates/pgls_splinter/src/rules/` - Regenerated with rich docs - -**Example Output:** -```rust -/// # Unindexed foreign keys -/// -/// Identifies foreign key constraints without a covering index, which can impact database performance. -/// -/// ## SQL Query -/// -/// ```sql -/// with foreign_keys as ( -/// select -/// cl.relnamespace::regnamespace::text as schema_name, -/// cl.relname as table_name, -/// ... -/// ) -/// select * from foreign_keys where ... -/// ``` -/// -/// ## Configuration -/// -/// Enable or disable this rule in your configuration: -/// -/// ```json -/// { -/// "splinter": { -/// "rules": { -/// "performance": { -/// "unindexedForeignKeys": "warn" -/// } -/// } -/// } -/// } -/// ``` -/// -/// ## Remediation -/// -/// See: -pub struct UnindexedForeignKeys { ... } -``` - -**Benefits:** -- ✅ Developers can view SQL queries directly in IDE via hover/goto-definition -- ✅ `cargo doc` generates comprehensive documentation -- ✅ Easy to understand what each rule checks without reading SQL files -- ✅ Configuration examples reduce setup friction -- ✅ Remediation links provide actionable next steps - ---- - -**Overall Phase 5 Status:** ✅ FULLY COMPLETED - -All planned work and deferred items have been successfully implemented: -- Dynamic SQL query building with configuration filtering -- Hardcoded category mapping replaced with generated functions -- Multi-analyzer RuleSelector support -- Automatic Supabase role detection -- Comprehensive documentation generation - -Full workspace compiles successfully with no errors. diff --git a/crates/pgls_analyser/src/lib.rs b/crates/pgls_analyser/src/lib.rs index 5cfd01897..2e14a1361 100644 --- a/crates/pgls_analyser/src/lib.rs +++ b/crates/pgls_analyser/src/lib.rs @@ -19,11 +19,6 @@ pub use linter_registry::{ }; pub use linter_rule::{LinterDiagnostic, LinterRule}; -// For convenience in macros and rule files - keep these shorter names -pub use LinterDiagnostic as RuleDiagnostic; -pub use LinterRule as Rule; -pub use LinterRuleContext as RuleContext; - pub static METADATA: LazyLock = LazyLock::new(|| { let mut metadata = MetadataRegistry::default(); // Use a separate visitor for metadata that implements pgls_analyse::RegistryVisitor diff --git a/crates/pgls_analyser/src/lint/safety/add_serial_column.rs b/crates/pgls_analyser/src/lint/safety/add_serial_column.rs index c249235fa..1fbf15b7e 100644 --- a/crates/pgls_analyser/src/lint/safety/add_serial_column.rs +++ b/crates/pgls_analyser/src/lint/safety/add_serial_column.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -38,10 +38,10 @@ declare_lint_rule! { } } -impl Rule for AddSerialColumn { +impl LinterRule for AddSerialColumn { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::AlterTableStmt(stmt) = &ctx.stmt() { @@ -56,7 +56,7 @@ impl Rule for AddSerialColumn { let type_str = get_type_name(type_name); if is_serial_type(&type_str) { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { @@ -86,7 +86,7 @@ impl Rule for AddSerialColumn { if has_stored_generated { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/adding_field_with_default.rs b/crates/pgls_analyser/src/lint/safety/adding_field_with_default.rs index e9b55cc8b..ae06abc1b 100644 --- a/crates/pgls_analyser/src/lint/safety/adding_field_with_default.rs +++ b/crates/pgls_analyser/src/lint/safety/adding_field_with_default.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -39,10 +39,10 @@ declare_lint_rule! { } } -impl Rule for AddingFieldWithDefault { +impl LinterRule for AddingFieldWithDefault { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); // Check PostgreSQL version - in 11+, non-volatile defaults are safe @@ -75,7 +75,7 @@ impl Rule for AddingFieldWithDefault { if has_generated { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { @@ -102,7 +102,7 @@ impl Rule for AddingFieldWithDefault { if !is_safe_default { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { @@ -116,7 +116,7 @@ impl Rule for AddingFieldWithDefault { } else { // Pre PG 11, all defaults cause rewrites diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/adding_foreign_key_constraint.rs b/crates/pgls_analyser/src/lint/safety/adding_foreign_key_constraint.rs index 5c707339c..b901fe508 100644 --- a/crates/pgls_analyser/src/lint/safety/adding_foreign_key_constraint.rs +++ b/crates/pgls_analyser/src/lint/safety/adding_foreign_key_constraint.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -43,10 +43,10 @@ declare_lint_rule! { } } -impl Rule for AddingForeignKeyConstraint { +impl LinterRule for AddingForeignKeyConstraint { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::AlterTableStmt(stmt) = &ctx.stmt() { @@ -95,7 +95,7 @@ impl Rule for AddingForeignKeyConstraint { fn check_foreign_key_constraint( constraint: &pgls_query::protobuf::Constraint, is_column_constraint: bool, -) -> Option { +) -> Option { // Only check foreign key constraints if constraint.contype() != pgls_query::protobuf::ConstrType::ConstrForeign { return None; @@ -121,7 +121,7 @@ fn check_foreign_key_constraint( }; Some( - RuleDiagnostic::new(rule_category!(), None, markup! { {message} }) + LinterDiagnostic::new(rule_category!(), None, markup! { {message} }) .detail(None, detail) .note(note), ) diff --git a/crates/pgls_analyser/src/lint/safety/adding_not_null_field.rs b/crates/pgls_analyser/src/lint/safety/adding_not_null_field.rs index bdbbc43b0..d62079faf 100644 --- a/crates/pgls_analyser/src/lint/safety/adding_not_null_field.rs +++ b/crates/pgls_analyser/src/lint/safety/adding_not_null_field.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -42,10 +42,10 @@ declare_lint_rule! { } } -impl Rule for AddingNotNullField { +impl LinterRule for AddingNotNullField { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); // In Postgres 11+, this is less of a concern @@ -60,7 +60,7 @@ impl Rule for AddingNotNullField { for cmd in &stmt.cmds { if let Some(pgls_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { if cmd.subtype() == pgls_query::protobuf::AlterTableType::AtSetNotNull { - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/adding_primary_key_constraint.rs b/crates/pgls_analyser/src/lint/safety/adding_primary_key_constraint.rs index c38225271..a09024ff0 100644 --- a/crates/pgls_analyser/src/lint/safety/adding_primary_key_constraint.rs +++ b/crates/pgls_analyser/src/lint/safety/adding_primary_key_constraint.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -40,10 +40,10 @@ declare_lint_rule! { } } -impl Rule for AddingPrimaryKeyConstraint { +impl LinterRule for AddingPrimaryKeyConstraint { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::AlterTableStmt(stmt) = &ctx.stmt() { @@ -92,12 +92,12 @@ impl Rule for AddingPrimaryKeyConstraint { fn check_for_primary_key_constraint( constraint: &pgls_query::protobuf::Constraint, -) -> Option { +) -> Option { if constraint.contype() == pgls_query::protobuf::ConstrType::ConstrPrimary && constraint.indexname.is_empty() { Some( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/adding_required_field.rs b/crates/pgls_analyser/src/lint/safety/adding_required_field.rs index 22a978d4d..050451ed1 100644 --- a/crates/pgls_analyser/src/lint/safety/adding_required_field.rs +++ b/crates/pgls_analyser/src/lint/safety/adding_required_field.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -25,10 +25,10 @@ declare_lint_rule! { } } -impl Rule for AddingRequiredField { +impl LinterRule for AddingRequiredField { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = vec![]; if let pgls_query::NodeEnum::AlterTableStmt(stmt) = ctx.stmt() { @@ -47,7 +47,7 @@ impl Rule for AddingRequiredField { == pgls_query::protobuf::AlterTableType::AtAddColumn { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/ban_char_field.rs b/crates/pgls_analyser/src/lint/safety/ban_char_field.rs index f9c948129..3e2a10b05 100644 --- a/crates/pgls_analyser/src/lint/safety/ban_char_field.rs +++ b/crates/pgls_analyser/src/lint/safety/ban_char_field.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -41,10 +41,10 @@ declare_lint_rule! { } } -impl Rule for BanCharField { +impl LinterRule for BanCharField { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::CreateStmt(stmt) = &ctx.stmt() { @@ -78,7 +78,9 @@ impl Rule for BanCharField { } } -fn check_column_for_char_type(col_def: &pgls_query::protobuf::ColumnDef) -> Option { +fn check_column_for_char_type( + col_def: &pgls_query::protobuf::ColumnDef, +) -> Option { if let Some(type_name) = &col_def.type_name { for name_node in &type_name.names { if let Some(pgls_query::NodeEnum::String(name)) = &name_node.node { @@ -87,7 +89,7 @@ fn check_column_for_char_type(col_def: &pgls_query::protobuf::ColumnDef) -> Opti let type_str = name.sval.to_lowercase(); if type_str == "bpchar" || type_str == "char" || type_str == "character" { return Some( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/ban_concurrent_index_creation_in_transaction.rs b/crates/pgls_analyser/src/lint/safety/ban_concurrent_index_creation_in_transaction.rs index dcc4af04e..6adddcacf 100644 --- a/crates/pgls_analyser/src/lint/safety/ban_concurrent_index_creation_in_transaction.rs +++ b/crates/pgls_analyser/src/lint/safety/ban_concurrent_index_creation_in_transaction.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -27,10 +27,10 @@ declare_lint_rule! { } } -impl Rule for BanConcurrentIndexCreationInTransaction { +impl LinterRule for BanConcurrentIndexCreationInTransaction { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); // check if the current statement is CREATE INDEX CONCURRENTLY and there is at least one @@ -39,7 +39,7 @@ impl Rule for BanConcurrentIndexCreationInTransaction { // since our analyser assumes we're always in a transaction context, we always flag concurrent indexes if let pgls_query::NodeEnum::IndexStmt(stmt) = ctx.stmt() { if stmt.concurrent && ctx.file_context().stmt_count() > 1 { - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/ban_drop_column.rs b/crates/pgls_analyser/src/lint/safety/ban_drop_column.rs index f499d7afb..238c65ba4 100644 --- a/crates/pgls_analyser/src/lint/safety/ban_drop_column.rs +++ b/crates/pgls_analyser/src/lint/safety/ban_drop_column.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -27,17 +27,17 @@ declare_lint_rule! { } } -impl Rule for BanDropColumn { +impl LinterRule for BanDropColumn { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::AlterTableStmt(stmt) = &ctx.stmt() { for cmd in &stmt.cmds { if let Some(pgls_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { if cmd.subtype() == pgls_query::protobuf::AlterTableType::AtDropColumn { - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/ban_drop_database.rs b/crates/pgls_analyser/src/lint/safety/ban_drop_database.rs index 8448eb26a..833eb4aae 100644 --- a/crates/pgls_analyser/src/lint/safety/ban_drop_database.rs +++ b/crates/pgls_analyser/src/lint/safety/ban_drop_database.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -16,15 +16,15 @@ declare_lint_rule! { } } -impl Rule for BanDropDatabase { +impl LinterRule for BanDropDatabase { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = vec![]; if let pgls_query::NodeEnum::DropdbStmt(_) = &ctx.stmt() { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/ban_drop_not_null.rs b/crates/pgls_analyser/src/lint/safety/ban_drop_not_null.rs index a9ed90ea8..fd1e3a89a 100644 --- a/crates/pgls_analyser/src/lint/safety/ban_drop_not_null.rs +++ b/crates/pgls_analyser/src/lint/safety/ban_drop_not_null.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -27,17 +27,17 @@ declare_lint_rule! { } } -impl Rule for BanDropNotNull { +impl LinterRule for BanDropNotNull { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::AlterTableStmt(stmt) = &ctx.stmt() { for cmd in &stmt.cmds { if let Some(pgls_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { if cmd.subtype() == pgls_query::protobuf::AlterTableType::AtDropNotNull { - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/ban_drop_table.rs b/crates/pgls_analyser/src/lint/safety/ban_drop_table.rs index 77265d24e..4ad99b854 100644 --- a/crates/pgls_analyser/src/lint/safety/ban_drop_table.rs +++ b/crates/pgls_analyser/src/lint/safety/ban_drop_table.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -26,16 +26,16 @@ declare_lint_rule! { } } -impl Rule for BanDropTable { +impl LinterRule for BanDropTable { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = vec![]; if let pgls_query::NodeEnum::DropStmt(stmt) = &ctx.stmt() { if stmt.remove_type() == pgls_query::protobuf::ObjectType::ObjectTable { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/ban_truncate_cascade.rs b/crates/pgls_analyser/src/lint/safety/ban_truncate_cascade.rs index e2bd9009b..3423dcb45 100644 --- a/crates/pgls_analyser/src/lint/safety/ban_truncate_cascade.rs +++ b/crates/pgls_analyser/src/lint/safety/ban_truncate_cascade.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -29,15 +29,15 @@ declare_lint_rule! { } } -impl Rule for BanTruncateCascade { +impl LinterRule for BanTruncateCascade { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::TruncateStmt(stmt) = &ctx.stmt() { if stmt.behavior() == DropBehavior::DropCascade { - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/changing_column_type.rs b/crates/pgls_analyser/src/lint/safety/changing_column_type.rs index f093b4242..d7e7e9159 100644 --- a/crates/pgls_analyser/src/lint/safety/changing_column_type.rs +++ b/crates/pgls_analyser/src/lint/safety/changing_column_type.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -29,17 +29,17 @@ declare_lint_rule! { } } -impl Rule for ChangingColumnType { +impl LinterRule for ChangingColumnType { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::AlterTableStmt(stmt) = ctx.stmt() { for cmd in &stmt.cmds { if let Some(pgls_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { if cmd.subtype() == pgls_query::protobuf::AlterTableType::AtAlterColumnType { - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/constraint_missing_not_valid.rs b/crates/pgls_analyser/src/lint/safety/constraint_missing_not_valid.rs index 1e27fc06e..248765101 100644 --- a/crates/pgls_analyser/src/lint/safety/constraint_missing_not_valid.rs +++ b/crates/pgls_analyser/src/lint/safety/constraint_missing_not_valid.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -35,10 +35,10 @@ declare_lint_rule! { } } -impl Rule for ConstraintMissingNotValid { +impl LinterRule for ConstraintMissingNotValid { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); let pgls_query::NodeEnum::AlterTableStmt(stmt) = ctx.stmt() else { @@ -67,7 +67,7 @@ impl Rule for ConstraintMissingNotValid { fn check_constraint_needs_not_valid( constraint: &pgls_query::protobuf::Constraint, -) -> Option { +) -> Option { // Skip if the constraint has NOT VALID if !constraint.initially_valid { return None; @@ -77,7 +77,7 @@ fn check_constraint_needs_not_valid( match constraint.contype() { pgls_query::protobuf::ConstrType::ConstrCheck | pgls_query::protobuf::ConstrType::ConstrForeign => Some( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/creating_enum.rs b/crates/pgls_analyser/src/lint/safety/creating_enum.rs index e6b6ccf8a..1791361f8 100644 --- a/crates/pgls_analyser/src/lint/safety/creating_enum.rs +++ b/crates/pgls_analyser/src/lint/safety/creating_enum.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -42,17 +42,17 @@ declare_lint_rule! { } } -impl Rule for CreatingEnum { +impl LinterRule for CreatingEnum { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::CreateEnumStmt(stmt) = &ctx.stmt() { let type_name = get_type_name(&stmt.type_name); diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/disallow_unique_constraint.rs b/crates/pgls_analyser/src/lint/safety/disallow_unique_constraint.rs index c7a081133..5009c9640 100644 --- a/crates/pgls_analyser/src/lint/safety/disallow_unique_constraint.rs +++ b/crates/pgls_analyser/src/lint/safety/disallow_unique_constraint.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -39,10 +39,10 @@ declare_lint_rule! { } } -impl Rule for DisallowUniqueConstraint { +impl LinterRule for DisallowUniqueConstraint { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::AlterTableStmt(stmt) = &ctx.stmt() { @@ -79,7 +79,7 @@ impl Rule for DisallowUniqueConstraint { && constraint.indexname.is_empty() { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { @@ -104,7 +104,7 @@ impl Rule for DisallowUniqueConstraint { == pgls_query::protobuf::ConstrType::ConstrUnique { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/lock_timeout_warning.rs b/crates/pgls_analyser/src/lint/safety/lock_timeout_warning.rs index 7660b9db4..df597cb2c 100644 --- a/crates/pgls_analyser/src/lint/safety/lock_timeout_warning.rs +++ b/crates/pgls_analyser/src/lint/safety/lock_timeout_warning.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -44,10 +44,10 @@ declare_lint_rule! { } } -impl Rule for LockTimeoutWarning { +impl LinterRule for LockTimeoutWarning { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); // Check if lock timeout has been set in the transaction @@ -72,7 +72,7 @@ impl Rule for LockTimeoutWarning { if !tx_state.has_created_object(schema, table) { let full_name = format!("{schema}.{table}"); diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { @@ -102,7 +102,7 @@ impl Rule for LockTimeoutWarning { let full_name = format!("{schema}.{table}"); let index_name = &stmt.idxname; diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/multiple_alter_table.rs b/crates/pgls_analyser/src/lint/safety/multiple_alter_table.rs index b7b7178b1..274db62ab 100644 --- a/crates/pgls_analyser/src/lint/safety/multiple_alter_table.rs +++ b/crates/pgls_analyser/src/lint/safety/multiple_alter_table.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -40,10 +40,10 @@ declare_lint_rule! { } } -impl Rule for MultipleAlterTable { +impl LinterRule for MultipleAlterTable { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); // Check if current statement is ALTER TABLE @@ -94,7 +94,7 @@ impl Rule for MultipleAlterTable { }; diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/prefer_big_int.rs b/crates/pgls_analyser/src/lint/safety/prefer_big_int.rs index 5aae8df47..f49c6e214 100644 --- a/crates/pgls_analyser/src/lint/safety/prefer_big_int.rs +++ b/crates/pgls_analyser/src/lint/safety/prefer_big_int.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -52,10 +52,10 @@ declare_lint_rule! { } } -impl Rule for PreferBigInt { +impl LinterRule for PreferBigInt { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); match &ctx.stmt() { @@ -90,7 +90,7 @@ impl Rule for PreferBigInt { } fn check_column_def( - diagnostics: &mut Vec, + diagnostics: &mut Vec, col_def: &pgls_query::protobuf::ColumnDef, ) { if let Some(type_name) = &col_def.type_name { @@ -111,7 +111,7 @@ fn check_column_def( if is_small_int { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/prefer_bigint_over_int.rs b/crates/pgls_analyser/src/lint/safety/prefer_bigint_over_int.rs index 58d0deff3..3940d2efb 100644 --- a/crates/pgls_analyser/src/lint/safety/prefer_bigint_over_int.rs +++ b/crates/pgls_analyser/src/lint/safety/prefer_bigint_over_int.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -58,10 +58,10 @@ declare_lint_rule! { } } -impl Rule for PreferBigintOverInt { +impl LinterRule for PreferBigintOverInt { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); match &ctx.stmt() { @@ -96,7 +96,7 @@ impl Rule for PreferBigintOverInt { } fn check_column_def( - diagnostics: &mut Vec, + diagnostics: &mut Vec, col_def: &pgls_query::protobuf::ColumnDef, ) { let Some(type_name) = &col_def.type_name else { @@ -118,7 +118,7 @@ fn check_column_def( } diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/prefer_bigint_over_smallint.rs b/crates/pgls_analyser/src/lint/safety/prefer_bigint_over_smallint.rs index 066923b02..612943311 100644 --- a/crates/pgls_analyser/src/lint/safety/prefer_bigint_over_smallint.rs +++ b/crates/pgls_analyser/src/lint/safety/prefer_bigint_over_smallint.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -52,10 +52,10 @@ declare_lint_rule! { } } -impl Rule for PreferBigintOverSmallint { +impl LinterRule for PreferBigintOverSmallint { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); match &ctx.stmt() { @@ -90,7 +90,7 @@ impl Rule for PreferBigintOverSmallint { } fn check_column_def( - diagnostics: &mut Vec, + diagnostics: &mut Vec, col_def: &pgls_query::protobuf::ColumnDef, ) { let Some(type_name) = &col_def.type_name else { @@ -111,7 +111,7 @@ fn check_column_def( } diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/prefer_identity.rs b/crates/pgls_analyser/src/lint/safety/prefer_identity.rs index bc82b1967..224f315f6 100644 --- a/crates/pgls_analyser/src/lint/safety/prefer_identity.rs +++ b/crates/pgls_analyser/src/lint/safety/prefer_identity.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -53,10 +53,10 @@ declare_lint_rule! { } } -impl Rule for PreferIdentity { +impl LinterRule for PreferIdentity { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); match ctx.stmt() { @@ -92,7 +92,7 @@ impl Rule for PreferIdentity { } fn check_column_def( - diagnostics: &mut Vec, + diagnostics: &mut Vec, col_def: &pgls_query::protobuf::ColumnDef, ) { let Some(type_name) = &col_def.type_name else { @@ -112,7 +112,7 @@ fn check_column_def( } diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/prefer_jsonb.rs b/crates/pgls_analyser/src/lint/safety/prefer_jsonb.rs index 16f51f9ff..a002f2065 100644 --- a/crates/pgls_analyser/src/lint/safety/prefer_jsonb.rs +++ b/crates/pgls_analyser/src/lint/safety/prefer_jsonb.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -61,10 +61,10 @@ declare_lint_rule! { } } -impl Rule for PreferJsonb { +impl LinterRule for PreferJsonb { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); match &ctx.stmt() { @@ -99,7 +99,7 @@ impl Rule for PreferJsonb { } fn check_column_def( - diagnostics: &mut Vec, + diagnostics: &mut Vec, col_def: &pgls_query::protobuf::ColumnDef, ) { let Some(type_name) = &col_def.type_name else { @@ -117,7 +117,7 @@ fn check_column_def( } diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/prefer_robust_stmts.rs b/crates/pgls_analyser/src/lint/safety/prefer_robust_stmts.rs index 0451db031..ac9a89ceb 100644 --- a/crates/pgls_analyser/src/lint/safety/prefer_robust_stmts.rs +++ b/crates/pgls_analyser/src/lint/safety/prefer_robust_stmts.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -41,10 +41,10 @@ declare_lint_rule! { } } -impl Rule for PreferRobustStmts { +impl LinterRule for PreferRobustStmts { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); // Since we assume we're always in a transaction, we only check for @@ -55,7 +55,7 @@ impl Rule for PreferRobustStmts { if stmt.concurrent { // Check for unnamed index if stmt.idxname.is_empty() { - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { @@ -66,7 +66,7 @@ impl Rule for PreferRobustStmts { // Check for IF NOT EXISTS if !stmt.if_not_exists { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { @@ -85,7 +85,7 @@ impl Rule for PreferRobustStmts { // Concurrent drop runs outside transaction if stmt.concurrent && !stmt.missing_ok { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/prefer_text_field.rs b/crates/pgls_analyser/src/lint/safety/prefer_text_field.rs index 847ad43d5..858b2194f 100644 --- a/crates/pgls_analyser/src/lint/safety/prefer_text_field.rs +++ b/crates/pgls_analyser/src/lint/safety/prefer_text_field.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -45,10 +45,10 @@ declare_lint_rule! { } } -impl Rule for PreferTextField { +impl LinterRule for PreferTextField { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); match &ctx.stmt() { @@ -84,7 +84,7 @@ impl Rule for PreferTextField { } fn check_column_def( - diagnostics: &mut Vec, + diagnostics: &mut Vec, col_def: &pgls_query::protobuf::ColumnDef, ) { if let Some(type_name) = &col_def.type_name { @@ -93,7 +93,7 @@ fn check_column_def( // Check if it's varchar with a size limit if name.sval.to_lowercase() == "varchar" && !type_name.typmods.is_empty() { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/prefer_timestamptz.rs b/crates/pgls_analyser/src/lint/safety/prefer_timestamptz.rs index 8fd048e01..c329c7657 100644 --- a/crates/pgls_analyser/src/lint/safety/prefer_timestamptz.rs +++ b/crates/pgls_analyser/src/lint/safety/prefer_timestamptz.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -58,10 +58,10 @@ declare_lint_rule! { } } -impl Rule for PreferTimestamptz { +impl LinterRule for PreferTimestamptz { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); match &ctx.stmt() { @@ -97,7 +97,7 @@ impl Rule for PreferTimestamptz { } fn check_column_def( - diagnostics: &mut Vec, + diagnostics: &mut Vec, col_def: &pgls_query::protobuf::ColumnDef, ) { if let Some(type_name) = &col_def.type_name { @@ -106,7 +106,7 @@ fn check_column_def( // Check for "timestamp" (without timezone) if name.sval.to_lowercase() == "timestamp" { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/renaming_column.rs b/crates/pgls_analyser/src/lint/safety/renaming_column.rs index a54fbb51a..a4e345be9 100644 --- a/crates/pgls_analyser/src/lint/safety/renaming_column.rs +++ b/crates/pgls_analyser/src/lint/safety/renaming_column.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -27,15 +27,15 @@ declare_lint_rule! { } } -impl Rule for RenamingColumn { +impl LinterRule for RenamingColumn { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::RenameStmt(stmt) = &ctx.stmt() { if stmt.rename_type() == pgls_query::protobuf::ObjectType::ObjectColumn { - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/renaming_table.rs b/crates/pgls_analyser/src/lint/safety/renaming_table.rs index 2cc1046fc..796a9137e 100644 --- a/crates/pgls_analyser/src/lint/safety/renaming_table.rs +++ b/crates/pgls_analyser/src/lint/safety/renaming_table.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -27,15 +27,15 @@ declare_lint_rule! { } } -impl Rule for RenamingTable { +impl LinterRule for RenamingTable { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::RenameStmt(stmt) = &ctx.stmt() { if stmt.rename_type() == pgls_query::protobuf::ObjectType::ObjectTable { - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/require_concurrent_index_creation.rs b/crates/pgls_analyser/src/lint/safety/require_concurrent_index_creation.rs index 257536d3a..15709bfc0 100644 --- a/crates/pgls_analyser/src/lint/safety/require_concurrent_index_creation.rs +++ b/crates/pgls_analyser/src/lint/safety/require_concurrent_index_creation.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -33,10 +33,10 @@ declare_lint_rule! { } } -impl Rule for RequireConcurrentIndexCreation { +impl LinterRule for RequireConcurrentIndexCreation { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); let pgls_query::NodeEnum::IndexStmt(stmt) = &ctx.stmt() else { @@ -61,7 +61,7 @@ impl Rule for RequireConcurrentIndexCreation { } diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/require_concurrent_index_deletion.rs b/crates/pgls_analyser/src/lint/safety/require_concurrent_index_deletion.rs index 7117908c4..23460dffd 100644 --- a/crates/pgls_analyser/src/lint/safety/require_concurrent_index_deletion.rs +++ b/crates/pgls_analyser/src/lint/safety/require_concurrent_index_deletion.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -33,17 +33,17 @@ declare_lint_rule! { } } -impl Rule for RequireConcurrentIndexDeletion { +impl LinterRule for RequireConcurrentIndexDeletion { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::DropStmt(stmt) = &ctx.stmt() { if !stmt.concurrent && stmt.remove_type() == pgls_query::protobuf::ObjectType::ObjectIndex { - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/running_statement_while_holding_access_exclusive.rs b/crates/pgls_analyser/src/lint/safety/running_statement_while_holding_access_exclusive.rs index f481f76a9..86f14bd78 100644 --- a/crates/pgls_analyser/src/lint/safety/running_statement_while_holding_access_exclusive.rs +++ b/crates/pgls_analyser/src/lint/safety/running_statement_while_holding_access_exclusive.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -43,17 +43,17 @@ declare_lint_rule! { } } -impl Rule for RunningStatementWhileHoldingAccessExclusive { +impl LinterRule for RunningStatementWhileHoldingAccessExclusive { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); // Check if we're currently holding an ACCESS EXCLUSIVE lock let tx_state = ctx.file_context().transaction_state(); if tx_state.is_holding_access_exclusive() { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/transaction_nesting.rs b/crates/pgls_analyser/src/lint/safety/transaction_nesting.rs index d7205c945..6d5b94148 100644 --- a/crates/pgls_analyser/src/lint/safety/transaction_nesting.rs +++ b/crates/pgls_analyser/src/lint/safety/transaction_nesting.rs @@ -1,4 +1,4 @@ -use crate::{AnalysedFileContext, Rule, RuleContext, RuleDiagnostic}; +use crate::{AnalysedFileContext, LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -34,10 +34,10 @@ declare_lint_rule! { } } -impl Rule for TransactionNesting { +impl LinterRule for TransactionNesting { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::TransactionStmt(stmt) = &ctx.stmt() { @@ -46,7 +46,7 @@ impl Rule for TransactionNesting { | pgls_query::protobuf::TransactionStmtKind::TransStmtStart => { // Check if there's already a BEGIN in previous statements if has_transaction_start_before(ctx.file_context()) { - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { @@ -55,7 +55,7 @@ impl Rule for TransactionNesting { ).detail(None, "Starting a transaction when already in a transaction can cause issues.")); } // Always warn about BEGIN/START since we assume we're in a transaction - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { @@ -67,7 +67,7 @@ impl Rule for TransactionNesting { pgls_query::protobuf::TransactionStmtKind::TransStmtCommit | pgls_query::protobuf::TransactionStmtKind::TransStmtRollback => { // Always warn about COMMIT/ROLLBACK since we assume we're in a transaction - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/xtask/codegen/src/generate_new_analyser_rule.rs b/xtask/codegen/src/generate_new_analyser_rule.rs index d65a1cbf8..7e7d7b04e 100644 --- a/xtask/codegen/src/generate_new_analyser_rule.rs +++ b/xtask/codegen/src/generate_new_analyser_rule.rs @@ -41,12 +41,10 @@ fn generate_rule_template( }; format!( - r#"use pgls_analyse::{{ - AnalysedFileContext, context::RuleContext, {macro_name}, Rule, RuleDiagnostic, -}}; + r#"use crate::{{LinterRule, LinterRuleContext, LinterDiagnostic}}; +use pgls_analyse::{{RuleSource, {macro_name}}}; use pgls_console::markup; use pgls_diagnostics::Severity; -use pgls_schema_cache::SchemaCache; {macro_name}! {{ /// Succinct description of the rule. @@ -77,10 +75,10 @@ use pgls_schema_cache::SchemaCache; }} }} -impl Rule for {rule_name_upper_camel} {{ +impl LinterRule for {rule_name_upper_camel} {{ type Options = (); - fn run(ctx: &RuleContext) -> Vec {{ + fn run(ctx: &LinterRuleContext) -> Vec {{ Vec::new() }} }} From 4bbff2622f8c3726193902b732a8f219762895fb Mon Sep 17 00:00:00 2001 From: psteinroe Date: Tue, 16 Dec 2025 08:49:41 +0100 Subject: [PATCH 32/32] fix: resolve clippy warnings in pgls_completions - Remove unused get_fully_qualified_name method - Combine duplicate if blocks with identical code using || operator Fixes clippy warnings: - dead_code: method get_fully_qualified_name is never used - if_same_then_else: identical blocks in if/else chain --- .../pgls_completions/src/relevance/scoring.rs | 30 +++++-------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/crates/pgls_completions/src/relevance/scoring.rs b/crates/pgls_completions/src/relevance/scoring.rs index e4da05565..193552d5a 100644 --- a/crates/pgls_completions/src/relevance/scoring.rs +++ b/crates/pgls_completions/src/relevance/scoring.rs @@ -73,14 +73,13 @@ impl CompletionScore<'_> { self.get_table_name() .map(|t| format!("{t}.{name}")) .unwrap_or(name.clone()) - } else if self.get_table_name().is_some_and(|t| t == qualifier) { - name.clone() - } else if ctx - .get_mentioned_table_for_alias(qualifier) - .is_some_and(|alias_tbl| { - self.get_table_name() - .is_some_and(|item_tbl| alias_tbl == item_tbl) - }) + } else if self.get_table_name().is_some_and(|t| t == qualifier) + || ctx + .get_mentioned_table_for_alias(qualifier) + .is_some_and(|alias_tbl| { + self.get_table_name() + .is_some_and(|item_tbl| alias_tbl == item_tbl) + }) { name.clone() } else { @@ -309,21 +308,6 @@ impl CompletionScore<'_> { } } - fn get_fully_qualified_name(&self) -> String { - match self.data { - CompletionRelevanceData::Schema(s) => s.name.clone(), - CompletionRelevanceData::Column(c) => { - format!("{}.{}.{}", c.schema_name, c.table_name, c.name) - } - CompletionRelevanceData::Table(t) => format!("{}.{}", t.schema, t.name), - CompletionRelevanceData::Function(f) => format!("{}.{}", f.schema, f.name), - CompletionRelevanceData::Policy(p) => { - format!("{}.{}.{}", p.schema_name, p.table_name, p.name) - } - CompletionRelevanceData::Role(r) => r.name.clone(), - } - } - fn get_table_name(&self) -> Option<&str> { match self.data { CompletionRelevanceData::Column(c) => Some(c.table_name.as_str()),