diff --git a/src/catalog/query_interceptor.rs b/src/catalog/query_interceptor.rs index 0497afa..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}; @@ -18,6 +18,53 @@ 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"]; + +/// 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. Like the tables above, every one is a +/// pgsqlite-reserved name a user cannot own, so this is a closed, exact-name set +/// (never a `pg_%` / `information_schema_%` wildcard, which would also hide a +/// user's own `pg_my_report` view). Filter them out of client-issued +/// `sqlite_master` / `sqlite_schema` queries so a raw `type = 'view'` scan (or an +/// unqualified `SELECT *`) shows only the user's own schema. Keep this in step +/// with the registry. +const PG_CATALOG_INTERNAL_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", +]; + /// Intercepts and handles queries to pg_catalog tables pub struct CatalogInterceptor; @@ -46,7 +93,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 +269,210 @@ 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 (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 statements = Parser::parse_sql(&dialect, query).ok()?; + if statements.len() != 1 { + return None; + } + let Statement::Query(query_stmt) = &statements[0] else { + return None; + }; + let SetExpr::Select(select) = &*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; + } + + // 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(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 + /// 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 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. 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() + .map(|name| format!("'{name}'")) + .collect::>() + .join(", ") + }; + let table_list = quote(&PG_CATALOG_INTERNAL_TABLES); + let view_list = quote(&PG_CATALOG_INTERNAL_VIEWS); + 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})" + ) + } + 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 +4159,254 @@ 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 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 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( + "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() + ); + } + + /// 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 new file mode 100644 index 0000000..80ae002 --- /dev/null +++ b/tests/catalog_sqlite_master_filter_test.rs @@ -0,0 +1,326 @@ +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?; + // 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?; + // 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(()) + }) + }) + .await +} + +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"]; + +/// 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!( + !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:?})" + ); + 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_"), + "internal index 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:?}"); + // 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(); +} + +#[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 also rejects any idx_enum_values_* internal index, + // which pgsqlite creates on __pgsqlite_enum_values, so a raw index scan + // 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()), + "user index missing: {names:?}" + ); + assert!( + 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(); +} + +#[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:?}" + ); + 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(); +} + +/// 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; + 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(); +}