From 665028b35b4de7916ad58efce55001d6ee71436a Mon Sep 17 00:00:00 2001 From: Bob Bass Date: Fri, 10 Jul 2026 23:57:34 -0500 Subject: [PATCH 1/4] Filter internal __pgsqlite_* tables from client sqlite_master queries pgsqlite keeps its own bookkeeping in tables named __pgsqlite_* (schema, migrations, enum metadata, string/numeric constraints, datetime cache, etc.). The catalog handlers that pgsqlite synthesizes itself already exclude these (e.g. src/catalog/pg_class.rs appends "AND name NOT LIKE '__pgsqlite_%'"), but a sqlite_master / sqlite_schema query written directly by a client is passed through verbatim, so tools that introspect the native SQLite catalog see pgsqlite's internal tables alongside the user's own. Add a branch to CatalogInterceptor::intercept_query that runs before the pg_catalog / information_schema gate (a sqlite_master query contains none of those substrings). It parses the query with the bundled sqlparser, finds every sqlite_master / sqlite_schema relation in the FROM clause (alias-aware and JOIN-safe, matching the SQLite sqlite_schema alias too), ANDs ".name NOT LIKE '__pgsqlite_%'" into the WHERE clause, and executes the rewritten query. It fails open (runs the original query unchanged) on any parse failure or when sqlite_master is not an actual FROM relation, so ordinary queries are untouched. Only the __pgsqlite_% prefix is filtered; sqlite_% internal objects are left as-is. Covered by new unit tests (rewrite: plain / aliased / joined / sqlite_schema / fail-open / literal-not-table / similarly-named-table) and an integration test (catalog_sqlite_master_filter_test) asserting the internal tables are hidden over the wire while user tables, indexes and views are still returned. --- src/catalog/query_interceptor.rs | 197 ++++++++++++++++++++- tests/catalog_sqlite_master_filter_test.rs | 138 +++++++++++++++ 2 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 tests/catalog_sqlite_master_filter_test.rs diff --git a/src/catalog/query_interceptor.rs b/src/catalog/query_interceptor.rs index 0497afa..3c19a06 100644 --- a/src/catalog/query_interceptor.rs +++ b/src/catalog/query_interceptor.rs @@ -46,7 +46,21 @@ impl CatalogInterceptor { lower_query.trim() == "select version()" { return None; } - + + // Filter pgsqlite's internal bookkeeping tables (__pgsqlite_*) out of + // client-issued sqlite_master / sqlite_schema queries. pgsqlite's own + // synthesized catalog queries already exclude these tables, but a query + // written by the client is otherwise passed through verbatim and leaks + // the internal metadata tables. This runs before the pg_catalog / + // information_schema gate below because a sqlite_master query contains + // none of those substrings. Fails open (returns None) on any parse + // failure or when sqlite_master is not an actual FROM relation. + if lower_query.contains("sqlite_master") || lower_query.contains("sqlite_schema") { + if let Some(result) = Self::intercept_sqlite_master_query(query, &db).await { + return Some(result); + } + } + // Check for catalog tables let has_catalog_tables = lower_query.contains("pg_catalog") || lower_query.contains("pg_type") || lower_query.contains("pg_namespace") || lower_query.contains("pg_range") || @@ -208,6 +222,104 @@ impl CatalogInterceptor { None } + /// Rewrite a client-issued `sqlite_master` / `sqlite_schema` query so that + /// pgsqlite's internal `__pgsqlite_*` bookkeeping tables are excluded, then + /// execute it. Returns `None` (letting the original query run unmodified) if + /// the query cannot be parsed as a single SELECT or does not actually select + /// from a `sqlite_master` relation, so this fails open in every ambiguous + /// case. + async fn intercept_sqlite_master_query(query: &str, db: &Arc) -> Option> { + let rewritten = Self::rewrite_sqlite_master_query(query)?; + match db.query(&rewritten).await { + Ok(response) => Some(Ok(response)), + Err(e) => Some(Err(PgSqliteError::Sqlite(e))), + } + } + + /// Rewrite a `sqlite_master` / `sqlite_schema` SELECT so that pgsqlite's + /// internal `__pgsqlite_*` bookkeeping tables are excluded, returning the + /// rewritten SQL. Returns `None` (fail open, run the original query) when the + /// input is not a single SELECT or does not select from a `sqlite_master` + /// relation. + fn rewrite_sqlite_master_query(query: &str) -> Option { + let dialect = PostgreSqlDialect {}; + let mut statements = Parser::parse_sql(&dialect, query).ok()?; + if statements.len() != 1 { + return None; + } + let Statement::Query(query_stmt) = &mut statements[0] else { + return None; + }; + let SetExpr::Select(select) = &mut *query_stmt.body else { + return None; + }; + + // Collect a column qualifier for every sqlite_master / sqlite_schema + // relation referenced in the FROM clause (alias-aware and JOIN-safe). + let mut qualifiers: Vec = Vec::new(); + for table in &select.from { + Self::collect_sqlite_master_qualifiers(&table.relation, &mut qualifiers); + for join in &table.joins { + Self::collect_sqlite_master_qualifiers(&join.relation, &mut qualifiers); + } + } + if qualifiers.is_empty() { + // "sqlite_master" only appeared in a literal or column name, not as + // a FROM relation. Leave the query untouched. + return None; + } + + // AND `.name NOT LIKE '__pgsqlite_%'` into the WHERE clause + // for each referenced sqlite_master relation. + for qualifier in qualifiers { + if let Some(filter) = Self::pgsqlite_name_filter_expr(&qualifier) { + select.selection = Some(match select.selection.take() { + Some(existing) => Expr::BinaryOp { + left: Box::new(existing), + op: sqlparser::ast::BinaryOperator::And, + right: Box::new(filter), + }, + None => filter, + }); + } + } + + Some(statements[0].to_string()) + } + + /// If `factor` is a `sqlite_master` / `sqlite_schema` table, push the column + /// qualifier to use for it (its alias when present, otherwise the bare table + /// name) onto `qualifiers`. + fn collect_sqlite_master_qualifiers(factor: &TableFactor, qualifiers: &mut Vec) { + if let TableFactor::Table { name, alias, .. } = factor { + let full_name = name.to_string().to_lowercase(); + let bare_name = full_name.rsplit('.').next().unwrap_or(&full_name); + if bare_name == "sqlite_master" || bare_name == "sqlite_schema" { + let qualifier = match alias { + Some(alias) => alias.name.to_string(), + None => bare_name.to_string(), + }; + qualifiers.push(qualifier); + } + } + } + + /// Build the `.name NOT LIKE '__pgsqlite_%'` predicate by parsing + /// it, so the exact AST shape stays in step with the sqlparser version in + /// use. Returns `None` if it cannot be parsed. + fn pgsqlite_name_filter_expr(qualifier: &str) -> Option { + let sql = format!("SELECT 1 WHERE {qualifier}.name NOT LIKE '__pgsqlite_%'"); + let dialect = PostgreSqlDialect {}; + let statements = Parser::parse_sql(&dialect, &sql).ok()?; + let Statement::Query(query) = statements.into_iter().next()? else { + return None; + }; + let SetExpr::Select(select) = *query.body else { + return None; + }; + select.selection + } + async fn handle_catalog_query(query: &sqlparser::ast::Query, db: Arc, session: Option>) -> Option { debug!("handle_catalog_query called"); println!("HANDLE_CATALOG_QUERY: called with query"); @@ -3894,3 +4006,86 @@ impl CatalogInterceptor { Ok(filtered) } } + +#[cfg(test)] +mod tests { + use super::*; + + const FILTER: &str = "NOT LIKE '__pgsqlite_%'"; + + #[test] + fn plain_sqlite_master_select_is_filtered() { + let rewritten = + CatalogInterceptor::rewrite_sqlite_master_query("SELECT name FROM sqlite_master") + .expect("plain sqlite_master query should be rewritten"); + assert!(rewritten.contains(FILTER), "rewritten = {rewritten}"); + assert!(rewritten.contains("sqlite_master.name"), "rewritten = {rewritten}"); + } + + #[test] + fn existing_where_clause_is_preserved_with_and() { + let rewritten = CatalogInterceptor::rewrite_sqlite_master_query( + "SELECT name FROM sqlite_master WHERE type = 'table'", + ) + .expect("query with WHERE should be rewritten"); + assert!(rewritten.contains("type = 'table'"), "rewritten = {rewritten}"); + assert!(rewritten.contains(" AND "), "rewritten = {rewritten}"); + assert!(rewritten.contains(FILTER), "rewritten = {rewritten}"); + } + + #[test] + fn aliased_sqlite_master_uses_the_alias_qualifier() { + let rewritten = CatalogInterceptor::rewrite_sqlite_master_query( + "SELECT m.name FROM sqlite_master m WHERE m.type = 'table'", + ) + .expect("aliased sqlite_master query should be rewritten"); + assert!(rewritten.contains("m.name NOT LIKE '__pgsqlite_%'"), "rewritten = {rewritten}"); + } + + #[test] + fn joined_sqlite_master_is_filtered_and_join_safe() { + let rewritten = CatalogInterceptor::rewrite_sqlite_master_query( + "SELECT sm.name FROM sqlite_master sm JOIN pragma_table_info('t') pti", + ) + .expect("joined sqlite_master query should be rewritten"); + // The qualifier is the alias, keeping the filter unambiguous across the join. + assert!(rewritten.contains("sm.name NOT LIKE '__pgsqlite_%'"), "rewritten = {rewritten}"); + } + + #[test] + fn sqlite_schema_alias_is_filtered() { + let rewritten = + CatalogInterceptor::rewrite_sqlite_master_query("SELECT name FROM sqlite_schema") + .expect("sqlite_schema query should be rewritten"); + assert!(rewritten.contains(FILTER), "rewritten = {rewritten}"); + assert!(rewritten.contains("sqlite_schema.name"), "rewritten = {rewritten}"); + } + + #[test] + fn unparseable_sql_fails_open() { + assert!( + CatalogInterceptor::rewrite_sqlite_master_query("SELECT FROM WHERE sqlite_master ???") + .is_none() + ); + } + + #[test] + fn sqlite_master_only_as_a_literal_is_left_untouched() { + // The substring appears, but not as a FROM relation, so nothing is rewritten. + assert!( + CatalogInterceptor::rewrite_sqlite_master_query( + "SELECT 'sqlite_master' AS label FROM users" + ) + .is_none() + ); + } + + #[test] + fn similarly_named_table_is_not_matched() { + // A user table whose name merely contains the substring must not be filtered. + assert!( + CatalogInterceptor::rewrite_sqlite_master_query("SELECT name FROM sqlite_master_backup") + .is_none() + ); + } +} diff --git a/tests/catalog_sqlite_master_filter_test.rs b/tests/catalog_sqlite_master_filter_test.rs new file mode 100644 index 0000000..c5b3c79 --- /dev/null +++ b/tests/catalog_sqlite_master_filter_test.rs @@ -0,0 +1,138 @@ +mod common; +use common::setup_test_server_with_init; + +/// pgsqlite keeps its own bookkeeping in tables named `__pgsqlite_*`. A client +/// that lists `sqlite_master` (or its `sqlite_schema` alias) directly should not +/// see those internal tables, only its own. These tests exercise the filtering +/// added to the catalog interceptor. +async fn setup() -> common::TestServer { + setup_test_server_with_init(|db| { + Box::pin(async move { + db.execute( + "CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL, balance REAL)", + ) + .await?; + db.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER)").await?; + db.execute("CREATE INDEX idx_customers_name ON customers(name)").await?; + db.execute("CREATE VIEW customer_names AS SELECT name FROM customers").await?; + // Guarantee at least one internal-prefixed table exists so the + // filter has something to hide even if pgsqlite's own metadata + // tables are named differently across versions. + db.execute("CREATE TABLE __pgsqlite_probe (k TEXT)").await?; + Ok(()) + }) + }) + .await +} + +fn names(rows: &[tokio_postgres::Row]) -> Vec { + rows.iter().map(|r| r.get::<_, String>(0)).collect() +} + +fn assert_no_internal(names: &[String]) { + for name in names { + assert!( + !name.starts_with("__pgsqlite_"), + "internal table leaked to client: {name} (all: {names:?})" + ); + } +} + +#[tokio::test] +async fn plain_sqlite_master_hides_internal_tables() { + let server = setup().await; + let rows = server + .client + .query("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name", &[]) + .await + .expect("sqlite_master query should succeed"); + let names = names(&rows); + + assert_no_internal(&names); + // User tables are still returned. + assert!(names.contains(&"customers".to_string()), "customers missing: {names:?}"); + assert!(names.contains(&"orders".to_string()), "orders missing: {names:?}"); + + server.abort(); +} + +#[tokio::test] +async fn aliased_sqlite_master_hides_internal_tables() { + let server = setup().await; + let rows = server + .client + .query( + "SELECT m.name FROM sqlite_master m WHERE m.type = 'table' ORDER BY m.name", + &[], + ) + .await + .expect("aliased sqlite_master query should succeed"); + let names = names(&rows); + + assert_no_internal(&names); + assert!(names.contains(&"customers".to_string()), "customers missing: {names:?}"); + + server.abort(); +} + +#[tokio::test] +async fn sqlite_schema_alias_hides_internal_tables() { + let server = setup().await; + let rows = server + .client + .query("SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name", &[]) + .await + .expect("sqlite_schema query should succeed"); + let names = names(&rows); + + assert_no_internal(&names); + assert!(names.contains(&"customers".to_string()), "customers missing: {names:?}"); + + server.abort(); +} + +#[tokio::test] +async fn indexes_and_views_are_returned_without_internal_leak() { + let server = setup().await; + let rows = server + .client + .query( + "SELECT name FROM sqlite_master WHERE type IN ('index', 'view') ORDER BY name", + &[], + ) + .await + .expect("sqlite_master index/view query should succeed"); + let names = names(&rows); + + assert_no_internal(&names); + assert!( + names.contains(&"idx_customers_name".to_string()), + "user index missing: {names:?}" + ); + assert!( + names.contains(&"customer_names".to_string()), + "user view missing: {names:?}" + ); + + server.abort(); +} + +#[tokio::test] +async fn non_sqlite_master_query_is_untouched() { + let server = setup().await; + server + .client + .execute("INSERT INTO customers (id, name, balance) VALUES (1, 'Ada', 10.0)", &[]) + .await + .expect("insert should succeed"); + let rows = server + .client + .query("SELECT name FROM customers ORDER BY id", &[]) + .await + .expect("plain user-table query should succeed"); + let names = names(&rows); + + assert_eq!(names, vec!["Ada".to_string()]); + + server.abort(); +} From 5cabc2925e46afd9cc00033898fe99dd6316baec Mon Sep 17 00:00:00 2001 From: Bob Bass Date: Sat, 11 Jul 2026 07:43:32 -0500 Subject: [PATCH 2/4] Also hide materialized pg_catalog tables and internal indexes from sqlite_master The sqlite_master / sqlite_schema filter previously hid only the __pgsqlite_* bookkeeping tables. Extend the rewritten predicate to also exclude the four pg_catalog tables that are physically materialized as real SQLite tables (pg_attrdef, pg_constraint, pg_depend, pg_index) and any index or trigger owned by an internal table, by filtering on tbl_name as well as name. This keeps an unqualified SELECT * FROM sqlite_master clean, not just a type = 'table' scan. The four table names are a closed set defined next to the __pgsqlite_ prefix as PG_CATALOG_INTERNAL_TABLES, with src/migration/registry.rs cited as the source of truth. Matching is exact-set, not a pg_% wildcard, so a user table such as pg_indexes is still returned. The rewrite keeps its alias/JOIN awareness and fail-open-on-parse-failure behavior. --- src/catalog/query_interceptor.rs | 59 +++++++++++++++++++--- tests/catalog_sqlite_master_filter_test.rs | 53 +++++++++++++++++++ 2 files changed, 106 insertions(+), 6 deletions(-) diff --git a/src/catalog/query_interceptor.rs b/src/catalog/query_interceptor.rs index 3c19a06..e0bf407 100644 --- a/src/catalog/query_interceptor.rs +++ b/src/catalog/query_interceptor.rs @@ -18,6 +18,17 @@ use std::collections::HashMap; /// Type alias for the complex Future type returned by process_expression type ProcessExpressionFuture<'a> = Pin>> + Send + 'a>>; +/// The pg_catalog tables that pgsqlite physically materializes as real SQLite +/// tables. Source of truth: `src/migration/registry.rs`, which is the only place +/// these are `CREATE TABLE`d. Every other `pg_*` / `information_schema_*` name is +/// a function or an interceptor-synthesized virtual result, so only these four +/// show up in a raw `sqlite_master` scan. They are pgsqlite-reserved names a user +/// cannot own, and this is a closed set: filter them out of client-issued +/// `sqlite_master` / `sqlite_schema` queries alongside the `__pgsqlite_*` +/// bookkeeping tables. Keep this in step with the registry. +const PG_CATALOG_INTERNAL_TABLES: [&str; 4] = + ["pg_attrdef", "pg_constraint", "pg_depend", "pg_index"]; + /// Intercepts and handles queries to pg_catalog tables pub struct CatalogInterceptor; @@ -269,8 +280,10 @@ impl CatalogInterceptor { return None; } - // AND `.name NOT LIKE '__pgsqlite_%'` into the WHERE clause - // for each referenced sqlite_master relation. + // AND the pgsqlite-reserved-name filter into the WHERE clause for each + // referenced sqlite_master relation (hides the __pgsqlite_* bookkeeping + // tables, the materialized pg_catalog tables, and any index/trigger that + // belongs to one of them). for qualifier in qualifiers { if let Some(filter) = Self::pgsqlite_name_filter_expr(&qualifier) { select.selection = Some(match select.selection.take() { @@ -304,11 +317,26 @@ impl CatalogInterceptor { } } - /// Build the `.name NOT LIKE '__pgsqlite_%'` predicate by parsing - /// it, so the exact AST shape stays in step with the sqlparser version in - /// use. Returns `None` if it cannot be parsed. + /// Build the pgsqlite-reserved-name filter predicate by parsing it, so the + /// exact AST shape stays in step with the sqlparser version in use. The + /// predicate keeps only rows whose `name` and `tbl_name` are neither + /// `__pgsqlite_*` bookkeeping tables nor one of the materialized pg_catalog + /// tables. Filtering on `tbl_name` as well as `name` hides the indexes and + /// triggers that belong to an internal table, so an unqualified + /// `SELECT * FROM sqlite_master` (not just a `type = 'table'` scan) comes + /// back clean. Returns `None` if it cannot be parsed. fn pgsqlite_name_filter_expr(qualifier: &str) -> Option { - let sql = format!("SELECT 1 WHERE {qualifier}.name NOT LIKE '__pgsqlite_%'"); + let list = PG_CATALOG_INTERNAL_TABLES + .iter() + .map(|name| format!("'{name}'")) + .collect::>() + .join(", "); + let sql = format!( + "SELECT 1 WHERE {qualifier}.name NOT LIKE '__pgsqlite_%' \ + AND {qualifier}.name NOT IN ({list}) \ + AND {qualifier}.tbl_name NOT LIKE '__pgsqlite_%' \ + AND {qualifier}.tbl_name NOT IN ({list})" + ); let dialect = PostgreSqlDialect {}; let statements = Parser::parse_sql(&dialect, &sql).ok()?; let Statement::Query(query) = statements.into_iter().next()? else { @@ -4022,6 +4050,25 @@ mod tests { assert!(rewritten.contains("sqlite_master.name"), "rewritten = {rewritten}"); } + #[test] + fn materialized_pg_catalog_tables_are_filtered_by_name_and_tbl_name() { + let rewritten = + CatalogInterceptor::rewrite_sqlite_master_query("SELECT name FROM sqlite_master") + .expect("plain sqlite_master query should be rewritten"); + // Each of the four materialized pg_catalog tables is excluded by name. + for name in ["pg_attrdef", "pg_constraint", "pg_depend", "pg_index"] { + assert!(rewritten.contains(&format!("'{name}'")), "missing {name}: {rewritten}"); + } + assert!(rewritten.contains("sqlite_master.name NOT IN"), "rewritten = {rewritten}"); + // tbl_name is filtered too, so indexes/triggers owned by an internal + // table are hidden even in a SELECT * (non type='table') scan. + assert!( + rewritten.contains("sqlite_master.tbl_name NOT LIKE '__pgsqlite_%'"), + "rewritten = {rewritten}" + ); + assert!(rewritten.contains("sqlite_master.tbl_name NOT IN"), "rewritten = {rewritten}"); + } + #[test] fn existing_where_clause_is_preserved_with_and() { let rewritten = CatalogInterceptor::rewrite_sqlite_master_query( diff --git a/tests/catalog_sqlite_master_filter_test.rs b/tests/catalog_sqlite_master_filter_test.rs index c5b3c79..c2f92cb 100644 --- a/tests/catalog_sqlite_master_filter_test.rs +++ b/tests/catalog_sqlite_master_filter_test.rs @@ -19,6 +19,10 @@ async fn setup() -> common::TestServer { // filter has something to hide even if pgsqlite's own metadata // tables are named differently across versions. db.execute("CREATE TABLE __pgsqlite_probe (k TEXT)").await?; + // A user table whose name is NOT one of the four materialized + // pg_catalog tables. It must still be returned, proving the filter + // is an exact-set match and not a `pg_%` wildcard. + db.execute("CREATE TABLE pg_indexes (id INTEGER PRIMARY KEY)").await?; Ok(()) }) }) @@ -29,12 +33,26 @@ fn names(rows: &[tokio_postgres::Row]) -> Vec { rows.iter().map(|r| r.get::<_, String>(0)).collect() } +/// The four pg_catalog tables pgsqlite materializes as real SQLite tables +/// (source of truth: src/migration/registry.rs). None of these should leak. +const MATERIALIZED_PG_CATALOG_TABLES: [&str; 4] = + ["pg_attrdef", "pg_constraint", "pg_depend", "pg_index"]; + fn assert_no_internal(names: &[String]) { for name in names { assert!( !name.starts_with("__pgsqlite_"), "internal table leaked to client: {name} (all: {names:?})" ); + assert!( + !MATERIALIZED_PG_CATALOG_TABLES.contains(&name.as_str()), + "materialized pg_catalog table leaked to client: {name} (all: {names:?})" + ); + // Indexes pgsqlite creates on its internal enum bookkeeping table. + assert!( + !name.starts_with("idx_enum_values_"), + "internal index leaked to client: {name} (all: {names:?})" + ); } } @@ -52,6 +70,10 @@ async fn plain_sqlite_master_hides_internal_tables() { // User tables are still returned. assert!(names.contains(&"customers".to_string()), "customers missing: {names:?}"); assert!(names.contains(&"orders".to_string()), "orders missing: {names:?}"); + // A user table named `pg_indexes` (NOT one of the four materialized + // pg_catalog tables) is still returned: the filter is an exact-set match, + // not a `pg_%` wildcard. + assert!(names.contains(&"pg_indexes".to_string()), "pg_indexes missing: {names:?}"); server.abort(); } @@ -104,7 +126,38 @@ async fn indexes_and_views_are_returned_without_internal_leak() { .expect("sqlite_master index/view query should succeed"); let names = names(&rows); + // assert_no_internal also rejects any idx_enum_values_* internal index, + // which pgsqlite creates on __pgsqlite_enum_values, so a raw index scan + // stays clean. + assert_no_internal(&names); + assert!( + names.contains(&"idx_customers_name".to_string()), + "user index missing: {names:?}" + ); + assert!( + names.contains(&"customer_names".to_string()), + "user view missing: {names:?}" + ); + + server.abort(); +} + +#[tokio::test] +async fn select_star_hides_internal_tables_and_their_indexes() { + let server = setup().await; + // An unqualified scan (no `type = 'table'` filter) still comes back clean: + // the tbl_name predicate hides indexes/triggers owned by internal tables. + let rows = server + .client + .query("SELECT name FROM sqlite_master ORDER BY name", &[]) + .await + .expect("SELECT * style sqlite_master query should succeed"); + let names = names(&rows); + assert_no_internal(&names); + // User objects across every type are still present. + assert!(names.contains(&"customers".to_string()), "customers missing: {names:?}"); + assert!(names.contains(&"pg_indexes".to_string()), "pg_indexes missing: {names:?}"); assert!( names.contains(&"idx_customers_name".to_string()), "user index missing: {names:?}" From 07f914a3b73937395f079eed718fc0ba01a8e632 Mon Sep 17 00:00:00 2001 From: Bob Bass Date: Sat, 11 Jul 2026 08:03:08 -0500 Subject: [PATCH 3/4] Also hide materialized pg_catalog/information_schema views from sqlite_master Extends the client sqlite_master/sqlite_schema filter to exclude the 24 pg_catalog and information_schema compatibility views pgsqlite materializes as real SQLite views (source of truth: migration/registry.rs) in addition to the __pgsqlite_* tables, the four materialized pg_catalog tables, and their internal indexes. A raw type='view' scan or an unqualified SELECT * now shows only the user's own schema. The exclusion is an exact-name set, never a pg_% / information_schema_% wildcard, so a user-owned view or table such as pg_my_report is still returned. Filter applies to both name and tbl_name for symmetry, keeps the same alias/JOIN awareness, and fails open on any parse failure. --- src/catalog/query_interceptor.rs | 96 +++++++++++++++++++--- tests/catalog_sqlite_master_filter_test.rs | 79 +++++++++++++++++- 2 files changed, 162 insertions(+), 13 deletions(-) diff --git a/src/catalog/query_interceptor.rs b/src/catalog/query_interceptor.rs index e0bf407..231e2c2 100644 --- a/src/catalog/query_interceptor.rs +++ b/src/catalog/query_interceptor.rs @@ -29,6 +29,42 @@ type ProcessExpressionFuture<'a> = Pin Option { - let list = PG_CATALOG_INTERNAL_TABLES - .iter() - .map(|name| format!("'{name}'")) - .collect::>() - .join(", "); + let quote = |names: &[&str]| { + names + .iter() + .map(|name| format!("'{name}'")) + .collect::>() + .join(", ") + }; + let table_list = quote(&PG_CATALOG_INTERNAL_TABLES); + let view_list = quote(&PG_CATALOG_INTERNAL_VIEWS); let sql = format!( "SELECT 1 WHERE {qualifier}.name NOT LIKE '__pgsqlite_%' \ - AND {qualifier}.name NOT IN ({list}) \ + AND {qualifier}.name NOT IN ({table_list}) \ + AND {qualifier}.name NOT IN ({view_list}) \ AND {qualifier}.tbl_name NOT LIKE '__pgsqlite_%' \ - AND {qualifier}.tbl_name NOT IN ({list})" + AND {qualifier}.tbl_name NOT IN ({table_list}) \ + AND {qualifier}.tbl_name NOT IN ({view_list})" ); let dialect = PostgreSqlDialect {}; let statements = Parser::parse_sql(&dialect, &sql).ok()?; @@ -4069,6 +4112,35 @@ mod tests { assert!(rewritten.contains("sqlite_master.tbl_name NOT IN"), "rewritten = {rewritten}"); } + #[test] + fn materialized_pg_catalog_views_are_filtered_by_name_and_tbl_name() { + let rewritten = + CatalogInterceptor::rewrite_sqlite_master_query("SELECT name FROM sqlite_master") + .expect("plain sqlite_master query should be rewritten"); + // Every reserved pg_catalog / information_schema view is excluded. + // Generated from the const so the test cannot drift from the source list. + for name in PG_CATALOG_INTERNAL_VIEWS { + assert!(rewritten.contains(&format!("'{name}'")), "missing {name}: {rewritten}"); + } + // Both name and tbl_name carry the view exclusion, so an unqualified + // SELECT * (not just a type='view' scan) stays clean. + assert!(rewritten.contains("sqlite_master.name NOT IN"), "rewritten = {rewritten}"); + assert!(rewritten.contains("sqlite_master.tbl_name NOT IN"), "rewritten = {rewritten}"); + } + + #[test] + fn user_named_pg_view_not_in_reserved_set_is_not_filtered() { + // A user object whose name is `pg_*` but NOT in the reserved set must + // NOT appear in the exclusion list: the filter is exact-name, not a + // `pg_%` wildcard. + let rewritten = + CatalogInterceptor::rewrite_sqlite_master_query("SELECT name FROM sqlite_master") + .expect("plain sqlite_master query should be rewritten"); + assert!(!PG_CATALOG_INTERNAL_VIEWS.contains(&"pg_my_report")); + assert!(!PG_CATALOG_INTERNAL_TABLES.contains(&"pg_my_report")); + assert!(!rewritten.contains("'pg_my_report'"), "rewritten = {rewritten}"); + } + #[test] fn existing_where_clause_is_preserved_with_and() { let rewritten = CatalogInterceptor::rewrite_sqlite_master_query( diff --git a/tests/catalog_sqlite_master_filter_test.rs b/tests/catalog_sqlite_master_filter_test.rs index c2f92cb..c69559b 100644 --- a/tests/catalog_sqlite_master_filter_test.rs +++ b/tests/catalog_sqlite_master_filter_test.rs @@ -23,6 +23,10 @@ async fn setup() -> common::TestServer { // pg_catalog tables. It must still be returned, proving the filter // is an exact-set match and not a `pg_%` wildcard. db.execute("CREATE TABLE pg_indexes (id INTEGER PRIMARY KEY)").await?; + // A user VIEW whose name is `pg_*` but is NOT one of the reserved + // materialized pg_catalog / information_schema views. It must still + // be returned, proving the view filter is exact-set, not `pg_%`. + db.execute("CREATE VIEW pg_my_report AS SELECT id FROM customers").await?; Ok(()) }) }) @@ -38,6 +42,37 @@ fn names(rows: &[tokio_postgres::Row]) -> Vec { const MATERIALIZED_PG_CATALOG_TABLES: [&str; 4] = ["pg_attrdef", "pg_constraint", "pg_depend", "pg_index"]; +/// The pg_catalog / information_schema compatibility views pgsqlite materializes +/// as real SQLite views (source of truth: src/migration/registry.rs, the only +/// place these are `CREATE VIEW`d). None of these should leak. Mirrors the +/// private `PG_CATALOG_INTERNAL_VIEWS` const in the interceptor. +const MATERIALIZED_PG_CATALOG_VIEWS: [&str; 24] = [ + "information_schema_columns", + "information_schema_key_column_usage", + "information_schema_referential_constraints", + "information_schema_schemata", + "information_schema_table_constraints", + "information_schema_tables", + "pg_am", + "pg_attribute", + "pg_class", + "pg_database", + "pg_description", + "pg_enum", + "pg_foreign_data_wrapper", + "pg_namespace", + "pg_proc", + "pg_roles", + "pg_stat_activity", + "pg_stat_all_indexes", + "pg_stat_all_tables", + "pg_stat_database", + "pg_stat_user_indexes", + "pg_stat_user_tables", + "pg_type", + "pg_user", +]; + fn assert_no_internal(names: &[String]) { for name in names { assert!( @@ -48,6 +83,10 @@ fn assert_no_internal(names: &[String]) { !MATERIALIZED_PG_CATALOG_TABLES.contains(&name.as_str()), "materialized pg_catalog table leaked to client: {name} (all: {names:?})" ); + assert!( + !MATERIALIZED_PG_CATALOG_VIEWS.contains(&name.as_str()), + "materialized pg_catalog view leaked to client: {name} (all: {names:?})" + ); // Indexes pgsqlite creates on its internal enum bookkeeping table. assert!( !name.starts_with("idx_enum_values_"), @@ -128,7 +167,8 @@ async fn indexes_and_views_are_returned_without_internal_leak() { // assert_no_internal also rejects any idx_enum_values_* internal index, // which pgsqlite creates on __pgsqlite_enum_values, so a raw index scan - // stays clean. + // stays clean. It also rejects every materialized pg_catalog view, so a + // type='view' scan shows only the user's own views. assert_no_internal(&names); assert!( names.contains(&"idx_customers_name".to_string()), @@ -138,6 +178,39 @@ async fn indexes_and_views_are_returned_without_internal_leak() { names.contains(&"customer_names".to_string()), "user view missing: {names:?}" ); + // A user view named `pg_*` that is NOT reserved is still returned. + assert!( + names.contains(&"pg_my_report".to_string()), + "user pg_* view missing: {names:?}" + ); + + server.abort(); +} + +#[tokio::test] +async fn view_scan_hides_materialized_pg_catalog_views() { + let server = setup().await; + // A plain type='view' scan is exactly what an external client uses to list + // views. Every reserved pg_catalog / information_schema view must be hidden; + // only the user's own views come back. + let rows = server + .client + .query("SELECT name FROM sqlite_master WHERE type = 'view' ORDER BY name", &[]) + .await + .expect("sqlite_master view scan should succeed"); + let names = names(&rows); + + assert_no_internal(&names); + // Belt-and-suspenders: assert each reserved view name is absent by name. + for reserved in MATERIALIZED_PG_CATALOG_VIEWS { + assert!( + !names.contains(&reserved.to_string()), + "reserved view {reserved} leaked: {names:?}" + ); + } + // The user's own views are present, including the exact-set proof view. + assert!(names.contains(&"customer_names".to_string()), "user view missing: {names:?}"); + assert!(names.contains(&"pg_my_report".to_string()), "user pg_* view missing: {names:?}"); server.abort(); } @@ -166,6 +239,10 @@ async fn select_star_hides_internal_tables_and_their_indexes() { names.contains(&"customer_names".to_string()), "user view missing: {names:?}" ); + assert!( + names.contains(&"pg_my_report".to_string()), + "user pg_* view missing: {names:?}" + ); server.abort(); } From 1c5ea96bb26183884abc9a4dd9d8cd8011a12fcd Mon Sep 17 00:00:00 2001 From: Bob Bass Date: Sat, 11 Jul 2026 09:02:20 -0500 Subject: [PATCH 4/4] Splice sqlite_master filter instead of re-serializing the query The sqlite_master / sqlite_schema internal-table filter parsed the client query, injected filter predicates into the AST, and re-serialized the whole statement back to SQL. That full round-trip corrupted moderately complex predicates: a client query with a large name NOT IN (...) list plus a name NOT LIKE '\_%' ESCAPE '\' clause returned zero rows once the IN list grew past about 16 entries, breaking a plain list-tables query. Rewrite the filter to never re-serialize the user's statement. sqlparser is now used only to analyze the query (confirm a single SELECT over sqlite_master, capture the alias, and locate the WHERE condition or FROM clause by byte span); the output is produced by string-level splicing onto the original query text, so every user token (ESCAPE clauses, IN lists, JOINs to pragma_table_info, aliases) is preserved byte for byte. A final re-parse gate makes any low-confidence splice fail open and run the original query unmodified. Add unit and integration coverage for the exact failing shape (a type='table' scan with an ESCAPE clause and a 30+ entry NOT IN, ordered by name) plus a large-IN-list regression lock. --- src/catalog/query_interceptor.rs | 280 ++++++++++++++++++--- tests/catalog_sqlite_master_filter_test.rs | 58 +++++ 2 files changed, 299 insertions(+), 39 deletions(-) diff --git a/src/catalog/query_interceptor.rs b/src/catalog/query_interceptor.rs index 231e2c2..b87de90 100644 --- a/src/catalog/query_interceptor.rs +++ b/src/catalog/query_interceptor.rs @@ -4,7 +4,7 @@ use crate::session::SessionState; use crate::PgSqliteError; use crate::translator::{RegexTranslator, SchemaPrefixTranslator}; use crate::query::column_sanitizer::sanitize_column_name; -use sqlparser::ast::{Statement, TableFactor, Select, SetExpr, SelectItem, Expr, FunctionArg, FunctionArgExpr}; +use sqlparser::ast::{Statement, TableFactor, Select, SetExpr, SelectItem, Expr, FunctionArg, FunctionArgExpr, Spanned}; use sqlparser::dialect::PostgreSqlDialect; use sqlparser::parser::Parser; use sqlparser::tokenizer::{Location, Span}; @@ -284,20 +284,34 @@ impl CatalogInterceptor { } /// Rewrite a `sqlite_master` / `sqlite_schema` SELECT so that pgsqlite's - /// internal `__pgsqlite_*` bookkeeping tables are excluded, returning the - /// rewritten SQL. Returns `None` (fail open, run the original query) when the - /// input is not a single SELECT or does not select from a `sqlite_master` - /// relation. + /// internal `__pgsqlite_*` bookkeeping tables (and the materialized + /// pg_catalog objects) are excluded, returning the rewritten SQL. Returns + /// `None` (fail open, run the original query) when the input is not a single + /// SELECT, does not select from a `sqlite_master` relation, or cannot be + /// spliced with confidence. + /// + /// The rewrite NEVER re-serializes the user's original statement. An earlier + /// implementation parsed the query, injected filter predicates into the AST, + /// and re-serialized the WHOLE statement back to SQL text; that full + /// round-trip silently corrupted moderately complex predicates (a large + /// `name NOT IN (...)` list, `ESCAPE '\'`, JOINs to `pragma_table_info`) and + /// returned zero rows for the web console's own list-tables query. Instead we + /// use sqlparser ONLY to ANALYZE the statement (confirm it is a single SELECT + /// over sqlite_master, capture the alias, and locate the WHERE condition / + /// FROM clause by byte span), then produce the output by STRING-level + /// splicing onto the ORIGINAL query text, so every user token is preserved + /// byte-for-byte. A final re-parse gate makes any low-confidence splice fail + /// open. fn rewrite_sqlite_master_query(query: &str) -> Option { let dialect = PostgreSqlDialect {}; - let mut statements = Parser::parse_sql(&dialect, query).ok()?; + let statements = Parser::parse_sql(&dialect, query).ok()?; if statements.len() != 1 { return None; } - let Statement::Query(query_stmt) = &mut statements[0] else { + let Statement::Query(query_stmt) = &statements[0] else { return None; }; - let SetExpr::Select(select) = &mut *query_stmt.body else { + let SetExpr::Select(select) = &*query_stmt.body else { return None; }; @@ -316,24 +330,100 @@ impl CatalogInterceptor { return None; } - // AND the pgsqlite-reserved-name filter into the WHERE clause for each - // referenced sqlite_master relation (hides the __pgsqlite_* bookkeeping - // tables, the materialized pg_catalog tables, and any index/trigger that - // belongs to one of them). - for qualifier in qualifiers { - if let Some(filter) = Self::pgsqlite_name_filter_expr(&qualifier) { - select.selection = Some(match select.selection.take() { - Some(existing) => Expr::BinaryOp { - left: Box::new(existing), - op: sqlparser::ast::BinaryOperator::And, - right: Box::new(filter), - }, - None => filter, - }); + // Build the combined reserved-name filter as plain SQL text: one + // predicate group per referenced sqlite_master relation, ANDed together. + // This text is the ONLY thing we generate; the user's own SQL is spliced + // in verbatim, never re-serialized. + let filter = qualifiers + .iter() + .map(|qualifier| Self::pgsqlite_name_filter_text(qualifier)) + .collect::>() + .join(" AND "); + + let rewritten = match &select.selection { + Some(existing) => { + // Wrap the existing WHERE condition and AND our filter, splicing + // by byte offset so the original predicate text (ESCAPE clauses, + // big IN lists, etc.) is preserved exactly. + let span = existing.span(); + let start = Self::location_to_byte_offset(query, &span.start)?; + let end = Self::location_to_byte_offset(query, &span.end)?; + if start >= end || end > query.len() { + return None; + } + format!( + "{}({}) AND ({}){}", + &query[..start], + &query[start..end], + filter, + &query[end..], + ) + } + None => { + // No WHERE clause: insert one immediately after the FROM clause + // (before any GROUP BY / ORDER BY / LIMIT), located by the byte + // span of the FROM relations so no user tokens are disturbed. + let mut from_end: Option = None; + for table in &select.from { + let end = Self::location_to_byte_offset(query, &table.span().end)?; + from_end = Some(from_end.map_or(end, |cur| cur.max(end))); + } + let insert_at = from_end?; + if insert_at > query.len() { + return None; + } + format!( + "{} WHERE ({}){}", + &query[..insert_at], + filter, + &query[insert_at..], + ) } + }; + + // Sanity gate: the spliced text must still parse as exactly one + // statement. Anything that does not (an unexpected span, an exotic query + // shape) fails open so the ORIGINAL query runs unmodified. Failing open + // is safe because the web console already filters internals itself; our + // filter is a bonus for raw external clients, never load-bearing. + let reparsed = Parser::parse_sql(&dialect, &rewritten).ok()?; + if reparsed.len() != 1 { + return None; } - Some(statements[0].to_string()) + Some(rewritten) + } + + /// Convert a 1-based `(line, column)` character position reported by + /// sqlparser into a byte offset into `query`. Mirrors the tokenizer's own + /// location bookkeeping (column counts characters and resets to 1 after a + /// `\n`), so it is correct for multi-byte UTF-8 input. The end location of a + /// token span is exclusive (it points at the position just past the last + /// character), which slices correctly with `&query[start..end]`. Returns + /// `None` for the empty-span sentinel (line/column 0) or an unresolvable + /// position. + fn location_to_byte_offset(query: &str, loc: &Location) -> Option { + if loc.line == 0 || loc.column == 0 { + return None; + } + let mut line: u64 = 1; + let mut column: u64 = 1; + for (offset, ch) in query.char_indices() { + if line == loc.line && column == loc.column { + return Some(offset); + } + if ch == '\n' { + line += 1; + column = 1; + } else { + column += 1; + } + } + // An exclusive end span at end-of-input points one past the last char. + if line == loc.line && column == loc.column { + return Some(query.len()); + } + None } /// If `factor` is a `sqlite_master` / `sqlite_schema` table, push the column @@ -353,16 +443,17 @@ impl CatalogInterceptor { } } - /// Build the pgsqlite-reserved-name filter predicate by parsing it, so the - /// exact AST shape stays in step with the sqlparser version in use. The + /// Build the pgsqlite-reserved-name filter predicate as plain SQL text, + /// qualified by `qualifier` (an alias or the bare `sqlite_master` name). The /// predicate keeps only rows whose `name` and `tbl_name` are neither /// `__pgsqlite_*` bookkeeping tables, one of the materialized pg_catalog /// tables, nor one of the materialized pg_catalog / information_schema views. /// Filtering on `tbl_name` as well as `name` hides the indexes and triggers /// that belong to an internal object, so an unqualified /// `SELECT * FROM sqlite_master` (not just a `type = 'table'` / `'view'` - /// scan) comes back clean. Returns `None` if it cannot be parsed. - fn pgsqlite_name_filter_expr(qualifier: &str) -> Option { + /// scan) comes back clean. The text is spliced onto the original query; it is + /// never parsed-and-re-serialized (see `rewrite_sqlite_master_query`). + fn pgsqlite_name_filter_text(qualifier: &str) -> String { let quote = |names: &[&str]| { names .iter() @@ -372,23 +463,14 @@ impl CatalogInterceptor { }; let table_list = quote(&PG_CATALOG_INTERNAL_TABLES); let view_list = quote(&PG_CATALOG_INTERNAL_VIEWS); - let sql = format!( - "SELECT 1 WHERE {qualifier}.name NOT LIKE '__pgsqlite_%' \ + format!( + "{qualifier}.name NOT LIKE '__pgsqlite_%' \ AND {qualifier}.name NOT IN ({table_list}) \ AND {qualifier}.name NOT IN ({view_list}) \ AND {qualifier}.tbl_name NOT LIKE '__pgsqlite_%' \ AND {qualifier}.tbl_name NOT IN ({table_list}) \ AND {qualifier}.tbl_name NOT IN ({view_list})" - ); - let dialect = PostgreSqlDialect {}; - let statements = Parser::parse_sql(&dialect, &sql).ok()?; - let Statement::Query(query) = statements.into_iter().next()? else { - return None; - }; - let SetExpr::Select(select) = *query.body else { - return None; - }; - select.selection + ) } async fn handle_catalog_query(query: &sqlparser::ast::Query, db: Arc, session: Option>) -> Option { @@ -4207,4 +4289,124 @@ mod tests { .is_none() ); } + + /// A curated stand-in for the web console's ~85-name NOT IN list. 30+ names + /// is well past the N>=17 threshold where the old AST re-serialization + /// started dropping rows, so this locks the regression. + const CONSOLE_NOT_IN_NAMES: [&str; 32] = [ + "pg_aggregate", "pg_am", "pg_amop", "pg_amproc", "pg_attrdef", "pg_attribute", + "pg_authid", "pg_auth_members", "pg_cast", "pg_class", "pg_collation", + "pg_constraint", "pg_conversion", "pg_database", "pg_depend", "pg_description", + "pg_enum", "pg_event_trigger", "pg_extension", "pg_foreign_data_wrapper", + "pg_foreign_server", "pg_foreign_table", "pg_index", "pg_inherits", + "pg_language", "pg_namespace", "pg_opclass", "pg_operator", "pg_proc", + "pg_range", "pg_rewrite", "pg_type", + ]; + + /// Build the exact SHAPE that broke in production: a `type='table'` scan with + /// a `name NOT LIKE '\_%' ESCAPE '\'` clause and a large `name NOT IN (...)` + /// list, ordered by name. This is the console's own list-tables query. + fn console_list_tables_query() -> String { + let in_list = CONSOLE_NOT_IN_NAMES + .iter() + .map(|n| format!("'{n}'")) + .collect::>() + .join(", "); + format!( + "SELECT name FROM sqlite_master WHERE type='table' \ + AND name NOT LIKE '\\_%' ESCAPE '\\' \ + AND name NOT IN ({in_list}) ORDER BY name" + ) + } + + #[test] + fn console_list_tables_query_is_rewritten_without_corruption() { + // The regression: this exact shape returned zero rows once the NOT IN + // grew past ~16 entries, because the whole statement was re-serialized. + // The splice-based rewrite must (a) return Some, (b) preserve every + // original token byte-for-byte, and (c) still parse. + let query = console_list_tables_query(); + let rewritten = CatalogInterceptor::rewrite_sqlite_master_query(&query) + .expect("console list-tables query must be rewritten, not dropped"); + + // The ESCAPE clause survives byte-for-byte (the round-trip mangled this). + assert!( + rewritten.contains("name NOT LIKE '\\_%' ESCAPE '\\'"), + "ESCAPE clause corrupted: {rewritten}" + ); + // Every name in the original NOT IN list is still present, in order, + // exactly as written - i.e. the user's whole original predicate is intact. + let original_where_tail = query + .split_once("WHERE ") + .map(|(_, tail)| tail) + .expect("query has a WHERE"); + let original_predicate = original_where_tail + .rsplit_once(" ORDER BY") + .map(|(head, _)| head) + .expect("query has ORDER BY"); + assert!( + rewritten.contains(original_predicate), + "original predicate not preserved verbatim.\noriginal: {original_predicate}\nrewritten: {rewritten}" + ); + // ORDER BY is preserved and stays after the spliced filter. + assert!(rewritten.contains("ORDER BY name"), "ORDER BY lost: {rewritten}"); + // Our reserved-name filter was appended. + assert!( + rewritten.contains("sqlite_master.name NOT LIKE '__pgsqlite_%'"), + "reserved-name filter missing: {rewritten}" + ); + // The wrap keeps the original condition parenthesized and ANDed. + assert!(rewritten.contains(") AND ("), "filter not ANDed onto original: {rewritten}"); + + // Sanity: it round-trips through the parser (also enforced inside the fn). + let dialect = PostgreSqlDialect {}; + assert!( + Parser::parse_sql(&dialect, &rewritten).is_ok(), + "rewritten query does not parse: {rewritten}" + ); + } + + #[test] + fn large_not_in_list_survives_verbatim() { + // A 20+ entry NOT IN with no other clauses. The old code corrupted big + // IN lists on re-serialization; the splice must reproduce it exactly. + let names: Vec = (0..24).map(|i| format!("'name_{i:02}'")).collect(); + let in_list = names.join(", "); + let query = format!("SELECT name FROM sqlite_master WHERE name NOT IN ({in_list})"); + let rewritten = CatalogInterceptor::rewrite_sqlite_master_query(&query) + .expect("large NOT IN query must be rewritten"); + assert!( + rewritten.contains(&format!("name NOT IN ({in_list})")), + "large IN list not preserved verbatim: {rewritten}" + ); + for name in &names { + assert!(rewritten.contains(name.as_str()), "dropped {name}: {rewritten}"); + } + } + + #[test] + fn no_where_clause_inserts_before_order_by() { + // The WHERE must be inserted after FROM and before ORDER BY. + let rewritten = CatalogInterceptor::rewrite_sqlite_master_query( + "SELECT name FROM sqlite_master ORDER BY name", + ) + .expect("no-WHERE query with ORDER BY should be rewritten"); + let where_pos = rewritten.find("WHERE").expect("WHERE inserted"); + let order_pos = rewritten.find("ORDER BY").expect("ORDER BY kept"); + assert!(where_pos < order_pos, "WHERE not before ORDER BY: {rewritten}"); + assert!(rewritten.ends_with("ORDER BY name"), "ORDER BY moved: {rewritten}"); + } + + #[test] + fn original_predicate_is_preserved_byte_for_byte_with_where() { + // The user's exact WHERE text (odd spacing included) is spliced in + // verbatim; only wrapping parens and our AND-ed filter are added. + let query = "SELECT name FROM sqlite_master WHERE type = 'table'"; + let rewritten = CatalogInterceptor::rewrite_sqlite_master_query(query) + .expect("query should be rewritten"); + assert!( + rewritten.starts_with("SELECT name FROM sqlite_master WHERE (type = 'table') AND ("), + "original spacing/tokens not preserved: {rewritten}" + ); + } } diff --git a/tests/catalog_sqlite_master_filter_test.rs b/tests/catalog_sqlite_master_filter_test.rs index c69559b..80ae002 100644 --- a/tests/catalog_sqlite_master_filter_test.rs +++ b/tests/catalog_sqlite_master_filter_test.rs @@ -247,6 +247,64 @@ async fn select_star_hides_internal_tables_and_their_indexes() { server.abort(); } +/// The exact shape of the web console's list-tables query that broke in +/// production: a `type='table'` scan with a `name NOT LIKE '\_%' ESCAPE '\'` +/// clause plus a large `name NOT IN (...)` list of pg_* names, ordered by name. +/// The previous AST re-serialization returned ZERO rows once the NOT IN list +/// grew past ~16 entries; the splice-based rewrite must return the user's table. +#[tokio::test] +async fn console_list_tables_query_returns_user_tables() { + let server = setup().await; + + // 32 pg_* names - well past the N>=17 threshold where the old code broke. + // Deliberately EXCLUDES the four names pgsqlite materializes as real tables + // (pg_attrdef, pg_constraint, pg_depend, pg_index). Those do not start with + // '_', so the client's own `\_%` filter cannot hide them: only our rewrite + // can. If the rewrite regressed to fail-open, they would leak here and + // assert_no_internal would catch it - so this test is strictly discriminating. + let pg_names = [ + "pg_aggregate", "pg_am", "pg_amop", "pg_amproc", "pg_attribute", + "pg_authid", "pg_auth_members", "pg_cast", "pg_class", "pg_collation", + "pg_conversion", "pg_database", "pg_description", + "pg_enum", "pg_event_trigger", "pg_extension", "pg_foreign_data_wrapper", + "pg_foreign_server", "pg_foreign_table", "pg_inherits", + "pg_language", "pg_namespace", "pg_opclass", "pg_operator", "pg_proc", + "pg_range", "pg_rewrite", "pg_shdepend", "pg_statistic", "pg_tablespace", + "pg_trigger", "pg_type", + ]; + let in_list = pg_names + .iter() + .map(|n| format!("'{n}'")) + .collect::>() + .join(", "); + let query = format!( + "SELECT name FROM sqlite_master WHERE type = 'table' \ + AND name NOT LIKE '\\_%' ESCAPE '\\' \ + AND name NOT IN ({in_list}) ORDER BY name" + ); + + let rows = server + .client + .query(query.as_str(), &[]) + .await + .expect("console list-tables query should succeed"); + let names = names(&rows); + + // The regression: this MUST return the user's tables, not zero rows. + assert!( + names.contains(&"customers".to_string()), + "customers missing (the production regression): {names:?}" + ); + assert!(names.contains(&"orders".to_string()), "orders missing: {names:?}"); + // Internals stay hidden, and a pg_* name NOT in the client's own NOT IN + // list (pg_indexes) is still returned - the rewrite did not corrupt the + // client's own exclusions either. + assert_no_internal(&names); + assert!(names.contains(&"pg_indexes".to_string()), "pg_indexes missing: {names:?}"); + + server.abort(); +} + #[tokio::test] async fn non_sqlite_master_query_is_untouched() { let server = setup().await;