diff --git a/.github/workflows/release-layerbase.yml b/.github/workflows/release-layerbase.yml new file mode 100644 index 00000000..b11a88aa --- /dev/null +++ b/.github/workflows/release-layerbase.yml @@ -0,0 +1,87 @@ +name: Release (Layerbase fork) + +# Tag-triggered release for the Layerbase-LLC fork. Pushing a tag of the form +# vX.Y.Z-layerbase (optionally with a fork revision suffix such as +# vX.Y.Z-layerbase-2) builds a linux/amd64 (glibc) binary and publishes +# pgsqlite-linux-amd64.tar.gz as a GitHub release asset. The layerbase-cloud +# universal image (images/Dockerfile.base) downloads and untars exactly that +# asset name. This workflow is separate from the upstream release.yml so a +# fork sync never has to reconcile the two. + +on: + push: + tags: + # Trailing * so both v*-layerbase and suffixed revisions like + # v*-layerbase-2 match (a bare v*-layerbase glob requires the ref to end + # in -layerbase and would miss the suffixed forms). + - 'v*-layerbase*' + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +permissions: + contents: write + +jobs: + build-and-release: + name: Test, build and release linux-amd64 + # Pin to 24.04 so the produced glibc build matches the ubuntu:24.04 base + # image the binary runs inside. + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo build + uses: Swatinem/rust-cache@v2 + with: + key: release-layerbase + + - name: Install PostgreSQL client + run: | + sudo apt-get update + sudo apt-get install -y postgresql-client + + - name: Run unit tests + run: cargo test --release --lib --verbose + + - name: Build release binary + run: cargo build --release + + - name: Strip binary + run: strip target/release/pgsqlite + + - name: Package tarball + run: | + cd target/release + tar czf "${GITHUB_WORKSPACE}/pgsqlite-linux-amd64.tar.gz" pgsqlite + cd "${GITHUB_WORKSPACE}" + sha256sum pgsqlite-linux-amd64.tar.gz > pgsqlite-linux-amd64.tar.gz.sha256 + cat pgsqlite-linux-amd64.tar.gz.sha256 + + - name: Create release + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 + with: + tag_name: ${{ github.ref_name }} + name: ${{ github.ref_name }} + make_latest: false + body: | + Layerbase fork build of pgsqlite for the layerbase-cloud universal image. + + Base: upstream erans/pgsqlite v0.0.22 plus the internal-object + sqlite_master result filter. A client-issued sqlite_master / + sqlite_schema SELECT runs verbatim and pgsqlite's internal objects + (__pgsqlite_* bookkeeping tables, the materialized pg_catalog + tables/views, and indexes/triggers owned by them) are dropped from + the result. The query text is never modified, so no client query can + be corrupted by the filter. + + The pgsqlite-linux-amd64.tar.gz asset is a linux x86_64 (glibc) build + consumed by images/Dockerfile.base in layerbase-cloud. + files: | + pgsqlite-linux-amd64.tar.gz + pgsqlite-linux-amd64.tar.gz.sha256 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8981b2eb..5c6394e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,11 @@ on: push: tags: - 'v*' + # Layerbase fork tags (v*-layerbase, incl. suffixed revisions like + # v*-layerbase-2) are handled by release-layerbase.yml; exclude them so + # both workflows do not race to create a release for the same tag. The + # trailing * must match the release-layerbase.yml trigger glob. + - '!v*-layerbase*' workflow_dispatch: # Allows manual testing env: diff --git a/Cargo.lock b/Cargo.lock index 3ed7115e..3d1a5508 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1254,7 +1254,7 @@ dependencies = [ [[package]] name = "pgsqlite" -version = "0.0.21" +version = "0.0.22" dependencies = [ "anyhow", "arbitrary", diff --git a/src/catalog/query_interceptor.rs b/src/catalog/query_interceptor.rs index 0497afae..071186e4 100644 --- a/src/catalog/query_interceptor.rs +++ b/src/catalog/query_interceptor.rs @@ -13,11 +13,60 @@ use super::{pg_class::PgClassHandler, pg_attribute::PgAttributeHandler, pg_const use std::sync::Arc; use std::pin::Pin; use std::future::Future; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; /// Type alias for the complex Future type returned by process_expression type ProcessExpressionFuture<'a> = Pin>> + Send + 'a>>; +/// The `__pgsqlite_*` prefix marks pgsqlite's internal bookkeeping objects +/// (schema registry, enum values, sequence state, etc.). A user cannot own a +/// name in this namespace, so it is safe to hide from a client-issued +/// `sqlite_master` / `sqlite_schema` scan. +const PGSQLITE_INTERNAL_PREFIX: &str = "__pgsqlite_"; + +/// The pg_catalog tables that pgsqlite physically materializes as real SQLite +/// tables. Source of truth: `src/migration/registry.rs`, 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. Kept as an exact-name set (never +/// a `pg_%` wildcard) so a user-owned table such as `pg_indexes` still shows. +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). 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; @@ -42,11 +91,29 @@ impl CatalogInterceptor { } // Special case: pg_catalog.version() should be handled by SQLite function, not catalog interceptor - if lower_query.trim() == "select pg_catalog.version()" || + if lower_query.trim() == "select pg_catalog.version()" || lower_query.trim() == "select version()" { return None; } - + + // Post-execution result filter for client-issued sqlite_master / + // sqlite_schema scans. pgsqlite exposes its internal bookkeeping tables + // (__pgsqlite_*) and the pg_catalog objects it materializes as real + // SQLite tables/views; a raw sqlite_master scan from an external client + // (psql, TablePlus, DBeaver) would otherwise leak them. This runs BEFORE + // the pg_catalog / information_schema gate below because a bare + // sqlite_master query contains none of those substrings. It runs the + // client's ORIGINAL query verbatim and drops internal rows from the + // result, so the query text is never modified and can never be + // corrupted. Fails open (returns None -> original query runs) on any + // uncertainty. See intercept_sqlite_master_query for the full rationale + // and the documented boundary. + 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") || @@ -768,6 +835,212 @@ impl CatalogInterceptor { None } + /// Handle a client-issued `sqlite_master` / `sqlite_schema` SELECT by running + /// it VERBATIM and then dropping result rows that name pgsqlite's internal + /// objects (`__pgsqlite_*` bookkeeping tables, the materialized pg_catalog + /// tables/views, and any index/trigger owned by one of them). + /// + /// Why post-execution filtering and not query rewriting: an earlier approach + /// modified the query text (re-serializing the parsed AST, then splicing + /// filter predicates by byte span). Both variants silently corrupted + /// moderately complex predicates: a large `name NOT IN (...)` list and an + /// `ESCAPE '\'` clause could come back with wrong or zero rows. Because this + /// path executes the client's ORIGINAL, UNMODIFIED text and only removes rows + /// afterwards, no client query can ever be corrupted by it. + /// + /// Fails open in every ambiguous case (returns `None`, so the original query + /// runs through the normal path unchanged). Failing open is safe: the filter + /// is a courtesy for raw external clients, never a correctness guarantee. + /// + /// DOCUMENTED BOUNDARY: the query runs verbatim, so a query whose TEXT itself + /// trips an earlier interceptor branch (for example the old web-console + /// list-tables query that embedded ~85 `pg_*` names in a `NOT IN`, which made + /// pgsqlite return `pg_tablespace` content instead of `sqlite_master` rows) + /// still returns that hijacked content. Result-filtering cannot repair a + /// query that never reached `sqlite_master`. That is acceptable: external + /// tools issue a simple `SELECT * FROM sqlite_master`, which this branch + /// handles cleanly, and any console sending a pathological query is fixed at + /// the source. + async fn intercept_sqlite_master_query( + query: &str, + db: &Arc, + ) -> Option> { + // Analyze only (never rewrite): confirm this is a single plain SELECT + // whose FROM actually references sqlite_master / sqlite_schema. Anything + // else (a set operation, a CTE, sqlite_master appearing only inside a + // string literal, a parse failure) fails open. + if !Self::query_selects_from_sqlite_master(query) { + return None; + } + // Execute the ORIGINAL, UNMODIFIED query text. + let response = match db.query(query).await { + Ok(response) => response, + Err(e) => return Some(Err(PgSqliteError::Sqlite(e))), + }; + // Build the authoritative set of internal object NAMES from a separate, + // simple probe of sqlite_master. This is what lets us hide an internal + // object even when the client projects only `name` (so its `tbl_name` + // is not in the result to match on): an index/trigger owned by an + // internal table, such as `idx_enum_values_label` on + // `__pgsqlite_enum_values`, is added to the denylist by NAME here. The + // probe cannot be corrupted (it is our own fixed text) and cannot + // recurse (db.query does not re-enter this interceptor). + let denylist = Self::internal_object_names(db).await; + Some(Ok(Self::filter_sqlite_master_response(response, denylist.as_ref()))) + } + + /// Probe sqlite_master directly for the complete set of pgsqlite-internal + /// object NAMES: every object whose own name is reserved + /// (`is_internal_object_name`) OR that belongs to an internal table + /// (`tbl_name` is reserved). The latter is how internal-owned indexes and + /// triggers (whose own names are not reserved, e.g. `idx_enum_values_label` + /// or `sqlite_autoindex___pgsqlite_enum_values_1`) get denied. Returns + /// `None` on any error so the caller degrades to static-name filtering + /// rather than failing the client's query. + async fn internal_object_names(db: &Arc) -> Option> { + let response = db + .query("SELECT name, tbl_name FROM sqlite_master") + .await + .ok()?; + let name_idx = response.columns.iter().position(|c| c.eq_ignore_ascii_case("name"))?; + let tbl_name_idx = response + .columns + .iter() + .position(|c| c.eq_ignore_ascii_case("tbl_name"))?; + let mut denied = HashSet::new(); + for row in &response.rows { + let cell = |idx: usize| -> Option { + row.get(idx) + .and_then(|c| c.as_ref()) + .map(|bytes| String::from_utf8_lossy(bytes).to_string()) + }; + let name = cell(name_idx); + let tbl_name = cell(tbl_name_idx); + let owner_is_internal = tbl_name + .as_deref() + .map(Self::is_internal_object_name) + .unwrap_or(false); + if let Some(name) = name { + if Self::is_internal_object_name(&name) || owner_is_internal { + denied.insert(name); + } + } + } + Some(denied) + } + + /// Return `true` only when `query` parses as a single plain `SELECT` whose + /// FROM clause (including any JOINed relation) references a `sqlite_master` + /// or `sqlite_schema` table. Alias- and schema-qualifier-aware. Any other + /// shape returns `false` so the caller fails open. + fn query_selects_from_sqlite_master(query: &str) -> bool { + let dialect = PostgreSqlDialect {}; + let Ok(statements) = Parser::parse_sql(&dialect, query) else { + return false; + }; + if statements.len() != 1 { + return false; + } + let Statement::Query(query_stmt) = &statements[0] else { + return false; + }; + let SetExpr::Select(select) = &*query_stmt.body else { + return false; + }; + for table in &select.from { + if Self::table_factor_is_sqlite_master(&table.relation) { + return true; + } + for join in &table.joins { + if Self::table_factor_is_sqlite_master(&join.relation) { + return true; + } + } + } + false + } + + /// `true` if `factor` is the `sqlite_master` / `sqlite_schema` table, ignoring + /// any schema qualifier (e.g. `main.sqlite_master`) and case. + fn table_factor_is_sqlite_master(factor: &TableFactor) -> bool { + if let TableFactor::Table { name, .. } = factor { + let full_name = name.to_string().to_lowercase(); + let bare_name = full_name.rsplit('.').next().unwrap_or(&full_name); + return bare_name == "sqlite_master" || bare_name == "sqlite_schema"; + } + false + } + + /// Drop rows that name a pgsqlite-internal object from an already-executed + /// `sqlite_master` response. A row is removed when its `name` value OR (when + /// the projection includes it) its `tbl_name` value identifies an internal + /// object. A value is internal when it is statically reserved + /// (`is_internal_object_name`) OR appears in `denylist`, the dynamic set of + /// internal-owned object names discovered by `internal_object_names`. + /// Consulting `denylist` by NAME is what hides internal-owned indexes and + /// triggers (e.g. `idx_enum_values_*` on `__pgsqlite_enum_values`) even when + /// the client projects only `name`, so both a `type = 'index'` scan and an + /// unqualified `SELECT * FROM sqlite_master` come back clean. + /// + /// If the projection includes NEITHER a `name` nor a `tbl_name` column (for + /// example `SELECT sql FROM sqlite_master` or `SELECT count(*) ...`), internal + /// rows cannot be identified, so the response is returned UNFILTERED. This is + /// a rare, documented, accepted caveat. + fn filter_sqlite_master_response( + response: DbResponse, + denylist: Option<&HashSet>, + ) -> DbResponse { + let name_idx = response + .columns + .iter() + .position(|c| c.eq_ignore_ascii_case("name")); + let tbl_name_idx = response + .columns + .iter() + .position(|c| c.eq_ignore_ascii_case("tbl_name")); + + // Cannot identify internal rows without a name / tbl_name column: return + // the result untouched rather than guess. + if name_idx.is_none() && tbl_name_idx.is_none() { + return response; + } + + let value_is_internal = |value: &str| -> bool { + Self::is_internal_object_name(value) + || denylist.is_some_and(|set| set.contains(value)) + }; + let DbResponse { columns, rows, .. } = response; + let cell_is_internal = |row: &Vec>>, idx: Option| -> bool { + match idx.and_then(|i| row.get(i)).and_then(|cell| cell.as_ref()) { + Some(bytes) => value_is_internal(&String::from_utf8_lossy(bytes)), + None => false, + } + }; + let filtered: Vec>>> = rows + .into_iter() + .filter(|row| { + !(cell_is_internal(row, name_idx) || cell_is_internal(row, tbl_name_idx)) + }) + .collect(); + let rows_affected = filtered.len(); + DbResponse { + columns, + rows: filtered, + rows_affected, + } + } + + /// `true` if `name` is a pgsqlite-reserved internal object: an `__pgsqlite_*` + /// bookkeeping table, one of the four materialized pg_catalog tables, or one + /// of the materialized pg_catalog / information_schema views. Exact-name + /// match against the reserved sets (never a `pg_%` wildcard) so a user-owned + /// object like `pg_indexes` or `pg_my_report` is preserved. + fn is_internal_object_name(name: &str) -> bool { + name.starts_with(PGSQLITE_INTERNAL_PREFIX) + || PG_CATALOG_INTERNAL_TABLES.contains(&name) + || PG_CATALOG_INTERNAL_VIEWS.contains(&name) + } + async fn handle_pg_type_query(select: &Select, db: Arc, session: Option>) -> DbResponse { // Extract which columns are being selected let mut columns = Vec::new(); @@ -3894,3 +4167,176 @@ impl CatalogInterceptor { Ok(filtered) } } + +#[cfg(test)] +mod sqlite_master_filter_tests { + use super::*; + + /// Build a byte-encoded cell the way DbHandler::query encodes TEXT values. + fn cell(value: &str) -> Option> { + Some(value.as_bytes().to_vec()) + } + + fn names(response: &DbResponse, name_col: usize) -> Vec { + response + .rows + .iter() + .map(|row| String::from_utf8_lossy(row[name_col].as_ref().unwrap()).to_string()) + .collect() + } + + #[test] + fn detects_plain_sqlite_master_select() { + assert!(CatalogInterceptor::query_selects_from_sqlite_master( + "SELECT name FROM sqlite_master" + )); + assert!(CatalogInterceptor::query_selects_from_sqlite_master( + "SELECT * FROM sqlite_master WHERE type = 'table'" + )); + assert!(CatalogInterceptor::query_selects_from_sqlite_master( + "SELECT m.name FROM sqlite_master m WHERE m.type = 'view'" + )); + // sqlite_schema alias and schema-qualified name both count. + assert!(CatalogInterceptor::query_selects_from_sqlite_master( + "SELECT name FROM sqlite_schema" + )); + assert!(CatalogInterceptor::query_selects_from_sqlite_master( + "SELECT name FROM main.sqlite_master" + )); + // A JOIN that includes sqlite_master still counts. + assert!(CatalogInterceptor::query_selects_from_sqlite_master( + "SELECT sm.name FROM sqlite_master sm JOIN pragma_table_info('t') pti" + )); + } + + #[test] + fn does_not_detect_non_sqlite_master_queries() { + // sqlite_master only inside a string literal, not a FROM relation. + assert!(!CatalogInterceptor::query_selects_from_sqlite_master( + "SELECT 'sqlite_master' AS label" + )); + // A plain user-table query. + assert!(!CatalogInterceptor::query_selects_from_sqlite_master( + "SELECT name FROM customers" + )); + // Not a single statement / not parseable fails open. + assert!(!CatalogInterceptor::query_selects_from_sqlite_master( + "SELECT name FROM sqlite_master; SELECT 1" + )); + assert!(!CatalogInterceptor::query_selects_from_sqlite_master( + "this is not sql" + )); + } + + #[test] + fn is_internal_object_name_matches_reserved_only() { + // __pgsqlite_ prefix. + assert!(CatalogInterceptor::is_internal_object_name("__pgsqlite_schema")); + assert!(CatalogInterceptor::is_internal_object_name("__pgsqlite_enum_values")); + // Every materialized table and view is reserved. + for name in PG_CATALOG_INTERNAL_TABLES { + assert!(CatalogInterceptor::is_internal_object_name(name), "table {name}"); + } + for name in PG_CATALOG_INTERNAL_VIEWS { + assert!(CatalogInterceptor::is_internal_object_name(name), "view {name}"); + } + // Exact-name set, NOT a pg_% wildcard: user-owned pg_* names survive. + assert!(!CatalogInterceptor::is_internal_object_name("pg_indexes")); + assert!(!CatalogInterceptor::is_internal_object_name("pg_my_report")); + assert!(!CatalogInterceptor::is_internal_object_name("customers")); + // An index whose NAME is not reserved is not matched by name; it is + // hidden via its tbl_name instead (covered in the filter test below). + assert!(!CatalogInterceptor::is_internal_object_name("idx_enum_values_0")); + } + + #[test] + fn filter_drops_internal_rows_by_name_and_tbl_name() { + // Columns mirror a `SELECT type, name, tbl_name FROM sqlite_master`. + let response = DbResponse { + columns: vec!["type".into(), "name".into(), "tbl_name".into()], + rows: vec![ + vec![cell("table"), cell("customers"), cell("customers")], + vec![cell("table"), cell("pg_indexes"), cell("pg_indexes")], + vec![cell("table"), cell("__pgsqlite_schema"), cell("__pgsqlite_schema")], + vec![cell("table"), cell("pg_constraint"), cell("pg_constraint")], + vec![cell("view"), cell("pg_class"), cell("pg_class")], + // An index whose own name is NOT reserved, but which belongs to + // an internal table: must be dropped via tbl_name. + vec![cell("index"), cell("idx_enum_values_0"), cell("__pgsqlite_enum_values")], + vec![cell("index"), cell("idx_customers_name"), cell("customers")], + vec![cell("view"), cell("pg_my_report"), cell("pg_my_report")], + ], + rows_affected: 8, + }; + let filtered = CatalogInterceptor::filter_sqlite_master_response(response, None); + let kept = names(&filtered, 1); + assert_eq!( + kept, + vec![ + "customers".to_string(), + "pg_indexes".to_string(), + "idx_customers_name".to_string(), + "pg_my_report".to_string(), + ], + "unexpected surviving rows" + ); + assert_eq!(filtered.rows_affected, 4); + } + + #[test] + fn filter_without_name_or_tbl_name_column_is_untouched() { + // `SELECT sql FROM sqlite_master`: no name / tbl_name column, so internal + // rows cannot be identified and the response is returned unfiltered. + let response = DbResponse { + columns: vec!["sql".into()], + rows: vec![ + vec![cell("CREATE TABLE customers (...)")], + vec![cell("CREATE TABLE __pgsqlite_schema (...)")], + ], + rows_affected: 2, + }; + let filtered = CatalogInterceptor::filter_sqlite_master_response(response, None); + assert_eq!(filtered.rows.len(), 2, "must be returned unfiltered"); + assert_eq!(filtered.rows_affected, 2); + } + + #[test] + fn filter_on_name_only_projection() { + // `SELECT name FROM sqlite_master`: only the name column present. + let response = DbResponse { + columns: vec!["name".into()], + rows: vec![ + vec![cell("customers")], + vec![cell("__pgsqlite_schema")], + vec![cell("pg_depend")], + vec![cell("pg_indexes")], + ], + rows_affected: 4, + }; + let filtered = CatalogInterceptor::filter_sqlite_master_response(response, None); + assert_eq!(names(&filtered, 0), vec!["customers".to_string(), "pg_indexes".to_string()]); + } + + #[test] + fn denylist_hides_internal_owned_index_in_name_only_projection() { + // `SELECT name FROM sqlite_master WHERE type = 'index'`: only the name + // column is present, so an internal-owned index whose OWN name is not + // reserved (idx_enum_values_label on __pgsqlite_enum_values) can only be + // hidden via the dynamic denylist built by internal_object_names. + let mut denylist = HashSet::new(); + denylist.insert("idx_enum_values_label".to_string()); + denylist.insert("sqlite_autoindex___pgsqlite_schema_1".to_string()); + let response = DbResponse { + columns: vec!["name".into()], + rows: vec![ + vec![cell("idx_customers_name")], + vec![cell("idx_enum_values_label")], + vec![cell("sqlite_autoindex___pgsqlite_schema_1")], + ], + rows_affected: 3, + }; + let filtered = + CatalogInterceptor::filter_sqlite_master_response(response, Some(&denylist)); + assert_eq!(names(&filtered, 0), vec!["idx_customers_name".to_string()]); + } +} diff --git a/tests/catalog_sqlite_master_filter_test.rs b/tests/catalog_sqlite_master_filter_test.rs new file mode 100644 index 00000000..246552cc --- /dev/null +++ b/tests/catalog_sqlite_master_filter_test.rs @@ -0,0 +1,352 @@ +mod common; +use common::setup_test_server_with_init; + +/// pgsqlite keeps its own bookkeeping in tables named `__pgsqlite_*` and +/// materializes some pg_catalog objects as real SQLite tables/views. A client +/// that lists `sqlite_master` (or its `sqlite_schema` alias) directly should not +/// see any of those internal objects, only its own. +/// +/// These tests exercise the POST-EXECUTION result filter in the catalog +/// interceptor: the client's query runs verbatim and internal rows are dropped +/// from the result, so the query text is never modified and can never be +/// corrupted. +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. +/// Mirrors the private `PG_CATALOG_INTERNAL_TABLES` const in the interceptor. +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 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 in the filter hides indexes/triggers owned by + // internal tables. + let rows = server + .client + .query("SELECT * FROM sqlite_master ORDER BY name", &[]) + .await + .expect("SELECT * sqlite_master query should succeed"); + // `name` is the second column of sqlite_master (type, name, tbl_name, ...). + let names: Vec = rows.iter().map(|r| r.get::<_, String>(1)).collect(); + + 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(); +} + +#[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(); +} + +/// The critical non-corruption proof. A large `name NOT IN (...)` list of +/// NON-pg names plus an `ESCAPE '\'` clause is exactly the kind of predicate the +/// previous query-rewriting approach corrupted (it returned wrong or zero rows +/// once the list grew past ~16 entries). Because the result filter runs the +/// query VERBATIM, the client's own predicate is honored exactly: the user's +/// tables come back and every excluded name is respected. +#[tokio::test] +async fn large_not_in_list_of_non_pg_names_is_not_corrupted() { + let server = setup().await; + + // 40 non-pg names, well past the N>=17 threshold where the old code broke. + // None of these are pgsqlite internals, so nothing here can trip an earlier + // interceptor branch: the query reaches sqlite_master untouched and the + // filter simply drops pgsqlite internals from the result. + let excluded: Vec = (0..40).map(|i| format!("legacy_table_{i:02}")).collect(); + let in_list = excluded + .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("large NOT IN query should succeed"); + let names = names(&rows); + + // The regression proof: this MUST return the user's tables, not zero rows. + assert!( + names.contains(&"customers".to_string()), + "customers missing (would be the old corruption bug): {names:?}" + ); + assert!(names.contains(&"orders".to_string()), "orders missing: {names:?}"); + // The client's own exclusions are respected verbatim: pg_indexes is NOT in + // the excluded list, so it comes back; none of the excluded names exist so + // none appear. Internals stay hidden by the result filter. + assert!(names.contains(&"pg_indexes".to_string()), "pg_indexes missing: {names:?}"); + assert_no_internal(&names); + for name in &excluded { + assert!(!names.contains(name), "excluded name leaked: {name}"); + } + + server.abort(); +} + +/// Documented caveat: `SELECT sql FROM sqlite_master` has no `name` / `tbl_name` +/// column in its projection, so internal rows cannot be identified and the +/// result is returned UNFILTERED. This test pins that behavior so the caveat is +/// explicit and intentional, not an accident. +#[tokio::test] +async fn select_sql_only_is_returned_unfiltered() { + let server = setup().await; + let rows = server + .client + .query("SELECT sql FROM sqlite_master WHERE sql IS NOT NULL", &[]) + .await + .expect("SELECT sql query should succeed"); + + // The internal probe table's CREATE statement is present precisely because + // this projection is not filtered. If a future change starts filtering this + // shape, update the caveat in intercept_sqlite_master_query first. + let sql_texts: Vec = rows.iter().map(|r| r.get::<_, String>(0)).collect(); + assert!( + sql_texts.iter().any(|s| s.contains("__pgsqlite_probe")), + "expected unfiltered SELECT sql to include internal CREATE text: {sql_texts:?}" + ); + + 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(); +}