Skip to content

Commit 79f4c9f

Browse files
committed
refactor: split splinter rules
1 parent 81b811c commit 79f4c9f

11 files changed

+897
-568
lines changed

crates/pgls_splinter/.sqlx/query-02927e584e85871ba6f84c58e8b5e4454b2c36eaf034657d5d2d95633fb85bdb.json

Lines changed: 74 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/pgls_splinter/.sqlx/query-425fc6118e76cea42cf256b3b0f11046dc8b77d84c94314a0ed0716e5803df69.json

Lines changed: 74 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/pgls_splinter/build.rs

Lines changed: 73 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,24 @@ use std::path::Path;
55
// Update this commit SHA to pull in a new version of splinter.sql
66
const SPLINTER_COMMIT_SHA: &str = "27ea2ece65464213e466cd969cc61b6940d16219";
77

8+
// Rules that require Supabase-specific infrastructure (auth schema, anon/authenticated roles, pgrst.db_schemas)
9+
const SUPABASE_ONLY_RULES: &[&str] = &[
10+
"auth_users_exposed",
11+
"auth_rls_initplan",
12+
"rls_disabled_in_public",
13+
"security_definer_view",
14+
"rls_references_user_metadata",
15+
"materialized_view_in_api",
16+
"foreign_table_in_api",
17+
"insecure_queue_exposed_in_api",
18+
"fkey_to_auth_unique",
19+
];
20+
821
fn main() {
922
let out_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
1023
let vendor_dir = Path::new(&out_dir).join("vendor");
11-
let sql_file = vendor_dir.join("splinter.sql");
24+
let generic_sql_file = vendor_dir.join("splinter_generic.sql");
25+
let supabase_sql_file = vendor_dir.join("splinter_supabase.sql");
1226
let sha_file = vendor_dir.join("COMMIT_SHA.txt");
1327

1428
// Create vendor directory if it doesn't exist
@@ -17,22 +31,23 @@ fn main() {
1731
}
1832

1933
// Check if we need to download
20-
let needs_download = if !sql_file.exists() || !sha_file.exists() {
21-
true
22-
} else {
23-
// Check if stored SHA matches current constant
24-
let stored_sha = fs::read_to_string(&sha_file)
25-
.expect("Failed to read COMMIT_SHA.txt")
26-
.trim()
27-
.to_string();
28-
stored_sha != SPLINTER_COMMIT_SHA
29-
};
34+
let needs_download =
35+
if !generic_sql_file.exists() || !supabase_sql_file.exists() || !sha_file.exists() {
36+
true
37+
} else {
38+
// Check if stored SHA matches current constant
39+
let stored_sha = fs::read_to_string(&sha_file)
40+
.expect("Failed to read COMMIT_SHA.txt")
41+
.trim()
42+
.to_string();
43+
stored_sha != SPLINTER_COMMIT_SHA
44+
};
3045

3146
if needs_download {
3247
println!(
3348
"cargo:warning=Downloading splinter.sql from GitHub (commit: {SPLINTER_COMMIT_SHA})"
3449
);
35-
download_and_process_sql(&sql_file);
50+
download_and_process_sql(&generic_sql_file, &supabase_sql_file);
3651
fs::write(&sha_file, SPLINTER_COMMIT_SHA).expect("Failed to write COMMIT_SHA.txt");
3752
}
3853

@@ -41,7 +56,7 @@ fn main() {
4156
println!("cargo:rerun-if-changed=vendor/COMMIT_SHA.txt");
4257
}
4358

44-
fn download_and_process_sql(dest_path: &Path) {
59+
fn download_and_process_sql(generic_dest: &Path, supabase_dest: &Path) {
4560
let url = format!(
4661
"https://raw.githubusercontent.com/supabase/splinter/{SPLINTER_COMMIT_SHA}/splinter.sql"
4762
);
@@ -61,10 +76,16 @@ fn download_and_process_sql(dest_path: &Path) {
6176
// Add "!" suffix to column aliases for sqlx non-null checking
6277
processed_content = add_not_null_markers(&processed_content);
6378

64-
// Write to destination
65-
fs::write(dest_path, processed_content).expect("Failed to write splinter.sql");
79+
// Split into generic and Supabase-specific queries
80+
let (generic_queries, supabase_queries) = split_queries(&processed_content);
6681

67-
println!("cargo:warning=Successfully downloaded and processed splinter.sql");
82+
// Write to destination files
83+
fs::write(generic_dest, generic_queries).expect("Failed to write splinter_generic.sql");
84+
fs::write(supabase_dest, supabase_queries).expect("Failed to write splinter_supabase.sql");
85+
86+
println!(
87+
"cargo:warning=Successfully downloaded and processed splinter.sql into generic and Supabase-specific files"
88+
);
6889
}
6990

7091
fn remove_set_search_path(content: &str) -> String {
@@ -107,3 +128,39 @@ fn add_not_null_markers(content: &str) -> String {
107128

108129
result
109130
}
131+
132+
fn split_queries(content: &str) -> (String, String) {
133+
// Split the union all queries based on rule names
134+
let queries: Vec<&str> = content.split("union all").collect();
135+
136+
let mut generic_queries = Vec::new();
137+
let mut supabase_queries = Vec::new();
138+
139+
for query in queries {
140+
// Extract the rule name from the query (it's the first 'name' field)
141+
let is_supabase = SUPABASE_ONLY_RULES
142+
.iter()
143+
.any(|rule| query.contains(&format!("'{rule}' as \"name!\"")));
144+
145+
if is_supabase {
146+
supabase_queries.push(query);
147+
} else {
148+
generic_queries.push(query);
149+
}
150+
}
151+
152+
// Join queries with "union all" and wrap in parentheses
153+
let generic_sql = if generic_queries.is_empty() {
154+
String::new()
155+
} else {
156+
generic_queries.join("union all\n")
157+
};
158+
159+
let supabase_sql = if supabase_queries.is_empty() {
160+
String::new()
161+
} else {
162+
supabase_queries.join("union all\n")
163+
};
164+
165+
(generic_sql, supabase_sql)
166+
}

crates/pgls_splinter/src/convert.rs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use pgls_diagnostics::{Category, Severity, category};
1+
use pgls_diagnostics::{category, Category, Severity};
22
use serde_json::Value;
33

44
use crate::{SplinterAdvices, SplinterDiagnostic, SplinterQueryResult};
@@ -27,13 +27,45 @@ impl From<SplinterQueryResult> for SplinterDiagnostic {
2727
schema,
2828
object_name,
2929
object_type,
30-
remediation_url: result.remediation,
30+
remediation_url: build_remediation_url(&result.name),
3131
additional_metadata,
3232
},
3333
}
3434
}
3535
}
3636

37+
/// Build remediation URL from rule name
38+
/// Maps rule names to their Supabase linter documentation
39+
fn build_remediation_url(name: &str) -> String {
40+
// Map rule names to their lint IDs
41+
let lint_id = match name {
42+
"unindexed_foreign_keys" => "0001_unindexed_foreign_keys",
43+
"auth_users_exposed" => "0002_auth_users_exposed",
44+
"auth_rls_initplan" => "0003_auth_rls_initplan",
45+
"no_primary_key" => "0004_no_primary_key",
46+
"unused_index" => "0005_unused_index",
47+
"multiple_permissive_policies" => "0006_multiple_permissive_policies",
48+
"policy_exists_rls_disabled" => "0007_policy_exists_rls_disabled",
49+
"rls_enabled_no_policy" => "0008_rls_enabled_no_policy",
50+
"duplicate_index" => "0009_duplicate_index",
51+
"security_definer_view" => "0010_security_definer_view",
52+
"function_search_path_mutable" => "0011_function_search_path_mutable",
53+
"rls_disabled_in_public" => "0013_rls_disabled_in_public",
54+
"extension_in_public" => "0014_extension_in_public",
55+
"rls_references_user_metadata" => "0015_rls_references_user_metadata",
56+
"materialized_view_in_api" => "0016_materialized_view_in_api",
57+
"foreign_table_in_api" => "0017_foreign_table_in_api",
58+
"unsupported_reg_types" => "unsupported_reg_types",
59+
"insecure_queue_exposed_in_api" => "0019_insecure_queue_exposed_in_api",
60+
"table_bloat" => "0020_table_bloat",
61+
"fkey_to_auth_unique" => "0021_fkey_to_auth_unique",
62+
"extension_versions_outdated" => "0022_extension_versions_outdated",
63+
_ => return format!("https://supabase.com/docs/guides/database/database-linter"),
64+
};
65+
66+
format!("https://supabase.com/docs/guides/database/database-linter?lint={lint_id}")
67+
}
68+
3769
/// Parse severity level from the query result
3870
fn parse_severity(level: &str) -> Severity {
3971
match level {

crates/pgls_splinter/src/lib.rs

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ pub struct SplinterParams<'a> {
1212
pub conn: &'a PgPool,
1313
}
1414

15-
async fn check_required_roles(conn: &PgPool) -> Result<bool, sqlx::Error> {
15+
async fn check_supabase_roles(conn: &PgPool) -> Result<bool, sqlx::Error> {
1616
let required_roles = ["anon", "authenticated", "service_role"];
1717

1818
let existing_roles: Vec<String> =
@@ -32,17 +32,19 @@ async fn check_required_roles(conn: &PgPool) -> Result<bool, sqlx::Error> {
3232
pub async fn run_splinter(
3333
params: SplinterParams<'_>,
3434
) -> Result<Vec<SplinterDiagnostic>, sqlx::Error> {
35-
// check if required supabase roles exist
36-
// if they don't exist, return empty diagnostics since splinter is supabase-specific
37-
// opened an issue to make it less supabase-specific: https://github.com/supabase/splinter/issues/135
38-
let has_roles = check_required_roles(params.conn).await?;
39-
if !has_roles {
40-
return Ok(Vec::new());
41-
}
35+
let mut all_results = Vec::new();
36+
37+
let generic_results = query::load_generic_splinter_results(params.conn).await?;
38+
all_results.extend(generic_results);
4239

43-
let results = query::load_splinter_results(params.conn).await?;
40+
// Only run Supabase-specific rules if the required roles exist
41+
let has_supabase_roles = check_supabase_roles(params.conn).await?;
42+
if has_supabase_roles {
43+
let supabase_results = query::load_supabase_splinter_results(params.conn).await?;
44+
all_results.extend(supabase_results);
45+
}
4446

45-
let diagnostics: Vec<SplinterDiagnostic> = results.into_iter().map(Into::into).collect();
47+
let diagnostics: Vec<SplinterDiagnostic> = all_results.into_iter().map(Into::into).collect();
4648

4749
Ok(diagnostics)
4850
}

crates/pgls_splinter/src/query.rs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ pub struct SplinterQueryResult {
3838
pub cache_key: String,
3939
}
4040

41-
pub async fn load_splinter_results(pool: &PgPool) -> Result<Vec<SplinterQueryResult>, sqlx::Error> {
41+
pub async fn load_generic_splinter_results(
42+
pool: &PgPool,
43+
) -> Result<Vec<SplinterQueryResult>, sqlx::Error> {
4244
let mut tx = pool.begin().await?;
4345

4446
// this is done by the splinter.sql file normally, but we remove it so that sqlx can work with
@@ -47,7 +49,27 @@ pub async fn load_splinter_results(pool: &PgPool) -> Result<Vec<SplinterQueryRes
4749
.execute(&mut *tx)
4850
.await?;
4951

50-
let results = sqlx::query_file_as!(SplinterQueryResult, "vendor/splinter.sql")
52+
let results = sqlx::query_file_as!(SplinterQueryResult, "vendor/splinter_generic.sql")
53+
.fetch_all(&mut *tx)
54+
.await?;
55+
56+
tx.commit().await?;
57+
58+
Ok(results)
59+
}
60+
61+
pub async fn load_supabase_splinter_results(
62+
pool: &PgPool,
63+
) -> Result<Vec<SplinterQueryResult>, sqlx::Error> {
64+
let mut tx = pool.begin().await?;
65+
66+
// this is done by the splinter.sql file normally, but we remove it so that sqlx can work with
67+
// the file properly.
68+
sqlx::query("set local search_path = ''")
69+
.execute(&mut *tx)
70+
.await?;
71+
72+
let results = sqlx::query_file_as!(SplinterQueryResult, "vendor/splinter_supabase.sql")
5173
.fetch_all(&mut *tx)
5274
.await?;
5375

crates/pgls_splinter/tests/diagnostics.rs

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use pgls_console::fmt::{Formatter, HTML};
22
use pgls_diagnostics::{Diagnostic, LogCategory, Visit};
3-
use pgls_splinter::{SplinterParams, run_splinter};
3+
use pgls_splinter::{run_splinter, SplinterParams};
44
use sqlx::PgPool;
55
use std::fmt::Write;
66
use std::io;
@@ -247,14 +247,31 @@ async fn multiple_issues(test_db: PgPool) {
247247
}
248248

249249
#[sqlx::test(migrator = "pgls_test_utils::MIGRATIONS")]
250-
async fn missing_roles_returns_empty(test_db: PgPool) {
250+
async fn missing_roles_runs_generic_checks_only(test_db: PgPool) {
251+
// Without Supabase roles, generic rules should still run
252+
// but Supabase-specific rules should be skipped
251253
let diagnostics = run_splinter(SplinterParams { conn: &test_db })
252254
.await
253-
.expect("Should not error when roles are missing");
255+
.expect("Should not error when Supabase roles are missing");
254256

255257
assert!(
256258
diagnostics.is_empty(),
257-
"Expected empty diagnostics when Supabase roles are missing, but got {} diagnostics",
259+
"Expected empty diagnostics for a clean database without Supabase roles, but got {} diagnostics",
258260
diagnostics.len()
259261
);
262+
263+
// Now create a table with a generic issue (no primary key)
264+
sqlx::raw_sql("CREATE TABLE public.test_table (name text)")
265+
.execute(&test_db)
266+
.await
267+
.expect("Failed to create test table");
268+
269+
let diagnostics_with_issue = run_splinter(SplinterParams { conn: &test_db })
270+
.await
271+
.expect("Should not error when checking for issues");
272+
273+
assert!(
274+
!diagnostics_with_issue.is_empty(),
275+
"Expected to detect generic issues (no primary key) even without Supabase roles"
276+
);
260277
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
27ea2ece65464213e466cd969cc61b6940d16219
1+
27ea2ece65464213e466cd969cc61b6940d16219

0 commit comments

Comments
 (0)