From efef4412e7c4c67d714f258a44b7970e2d16250d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 12 Oct 2026 19:48:40 +1100 Subject: [PATCH 1/4] feat(eql): build the eql_v4 bundle beside eql_v3 ADR-0002 installs one SQL source under two names, so a column's domain says which engine wrote its terms. The build now renders the placeholder source a second time as eql_v4 and writes release/cipherstash-encrypt-v4.sql and its uninstaller. The v3 artefacts are unchanged, byte for byte. verify_no_v3_in_v4.sh fails the build if the v4 bundle names eql_v3 or checks for payload version 3: a stray v3 reference would install cleanly and then compare v4 data with v3 terms. The symbol-order check stays on v3 only, since v4 is the same files in the same order with every name renamed. v3_v4_coexistence_tests installs both bundles into one database and pins what the separate names enforce: a wrong-version payload fails the domain CHECK, and the cross-version function form is ambiguous. It also pins the gap the corrected ADR records: a plain `=` across versions falls back to jsonb = jsonb and returns no rows, silently, until cross-version blocker operators exist (#1160). Refs #1141 --- packages/eql/AGENTS.md | 7 +- packages/eql/tasks/build.sh | 32 ++- packages/eql/tasks/test/verify_no_v3_in_v4.sh | 53 +++++ .../sqlx/tests/v3_v4_coexistence_tests.rs | 208 ++++++++++++++++++ 4 files changed, 288 insertions(+), 12 deletions(-) create mode 100755 packages/eql/tasks/test/verify_no_v3_in_v4.sh create mode 100644 packages/eql/tests/sqlx/tests/v3_v4_coexistence_tests.rs diff --git a/packages/eql/AGENTS.md b/packages/eql/AGENTS.md index 7d091e829..7f457d102 100644 --- a/packages/eql/AGENTS.md +++ b/packages/eql/AGENTS.md @@ -38,9 +38,10 @@ This project uses `mise` for task management. Common commands: ### Build System - Dependencies are resolved using `-- REQUIRE:` comments in SQL files -- Build outputs to `release/` directory (a single installer + uninstaller, assembled from `src/v3` alone): - - `cipherstash-encrypt.sql` - The sole installer: the self-contained `eql_v3` surface, globbed from `src/v3` only (no `eql_v2`; installable into a DB with no `eql_v2` present) +- Build outputs to `release/` directory (one installer + uninstaller per EQL version, both rendered from `src/v3` alone): + - `cipherstash-encrypt.sql` - The `eql_v3` installer: the self-contained surface, globbed from `src/v3` only (no `eql_v2`; installable into a DB with no `eql_v2` present) - `cipherstash-encrypt-uninstall.sql` - Matching uninstaller + - `cipherstash-encrypt-v4.sql` / `cipherstash-encrypt-v4-uninstall.sql` - The same SQL as `eql_v4` (ADR-0002). `tasks/test/verify_no_v3_in_v4.sh` fails the build if either names `eql_v3` or checks for payload version 3. Both versions install side by side; `tests/sqlx/tests/v3_v4_coexistence_tests.rs` pins what that enforces, including the gap that a plain `=` across versions falls back to `jsonb = jsonb` and returns no rows #### Schema and version placeholders @@ -53,7 +54,7 @@ The SQL source never names its schemas or its payload version literally. It writ `{{prefix}}` is the schema and the start of every other name: `{{prefix}}.eq_term`, `{{prefix}}_internal.hmac_256`, `public.{{prefix}}_text_eq`. `{{eql_version}}` appears in every domain CHECK: `VALUE->>'v' = '{{eql_version}}'`. This covers every file under `src/v3/` (hand-written and generated), `src/v3/version.template`, `tasks/pin_search_path_v3.sql` and `tasks/uninstall-v3.sql`. The generator emits the placeholders too: `SCHEMA`, `INTERNAL_SCHEMA`, `PUBLIC_TYPNAME_PREFIX` and `EQL_VERSION` in `crates/eql-codegen/src/consts.rs` are placeholder strings. The Rust bindings are the exception. They describe v3 payloads and keep `eql_domains::PUBLIC_TYPNAME_PREFIX`, the literal `eql_v3_`. -`tasks/build.sh` renders the ordered source into `build/render/eql_v3/` (gitignored, same relative paths) with `eql-codegen render`, runs the symbol-order and installer-completeness gates on the rendered files, and assembles `release/` from them. The renderer refuses three things: an unknown placeholder, an unterminated `{{`, and a **literal `eql_v3` or `eql_v4`** anywhere in the source, comments included. A literal name renders identically for both versions, so the v4 bundle would reach into v3. Write the placeholder in prose too. +`tasks/build.sh` renders the ordered source into `build/render/eql_v3/` and `build/render/eql_v4/` (gitignored, same relative paths) with `eql-codegen render`, runs the symbol-order and installer-completeness gates on the rendered files, and assembles `release/` from them. The renderer refuses three things: an unknown placeholder, an unterminated `{{`, and a **literal `eql_v3` or `eql_v4`** anywhere in the source, comments included. A literal name renders identically for both versions, so the v4 bundle would reach into v3. Write the placeholder in prose too. Anything that needs the source as real SQL reads the rendered tree, not `src/`: `docs:validate:documented-sql` runs psql over `build/render/eql_v3/src`, and the Doxygen input filter (`tasks/docs/doxygen-filter.sh`) reads each file's rendered copy from `build/render/eql_v3/`. A new tool that parses `src/v3` must do the same, and depend on `build`; do not substitute the placeholders a second way. diff --git a/packages/eql/tasks/build.sh b/packages/eql/tasks/build.sh index 4d20af73a..9e7551706 100755 --- a/packages/eql/tasks/build.sh +++ b/packages/eql/tasks/build.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash #MISE description="Build SQL into single release file" #MISE alias="b" -#MISE sources=["src/v3/**/*.sql", "src/v3/version.template", "tasks/pin_search_path_v3.sql", "tasks/uninstall-v3.sql", "crates/eql-domains/src/**/*.rs", "crates/eql-codegen/src/**/*.rs", "crates/eql-codegen/templates/**", "Cargo.toml", "Cargo.lock", "crates/eql-codegen/Cargo.toml", "crates/eql-domains/Cargo.toml", "tasks/build/ordering.sh", "tasks/test/verify_symbol_order_v3.sh", "tasks/test/verify_installer_complete.sh", "tasks/test/symbol_order_allowlist.txt"] -#MISE outputs=["release/cipherstash-encrypt.sql","release/cipherstash-encrypt-uninstall.sql","src/deps-ordered-v3.txt","build/render/eql_v3/deps-ordered.txt"] +#MISE sources=["src/v3/**/*.sql", "src/v3/version.template", "tasks/pin_search_path_v3.sql", "tasks/uninstall-v3.sql", "crates/eql-domains/src/**/*.rs", "crates/eql-codegen/src/**/*.rs", "crates/eql-codegen/templates/**", "Cargo.toml", "Cargo.lock", "crates/eql-codegen/Cargo.toml", "crates/eql-domains/Cargo.toml", "tasks/build/ordering.sh", "tasks/test/verify_symbol_order_v3.sh", "tasks/test/verify_installer_complete.sh", "tasks/test/verify_no_v3_in_v4.sh", "tasks/test/symbol_order_allowlist.txt"] +#MISE outputs=["release/cipherstash-encrypt.sql","release/cipherstash-encrypt-uninstall.sql","release/cipherstash-encrypt-v4.sql","release/cipherstash-encrypt-v4-uninstall.sql","src/deps-ordered-v3.txt","build/render/eql_v3/deps-ordered.txt","build/render/eql_v4/deps-ordered.txt"] #USAGE flag "--version " help="Specify release version of EQL" default="DEV" #!/bin/bash @@ -82,9 +82,9 @@ mv src/deps-ordered-v3.txt.tmp src/deps-ordered-v3.txt # The source names its schemas and payload version by placeholder # ({{prefix}}, {{eql_version}}; ADR-0002), so it is not installable SQL until it -# is rendered. Render the ordered files, the pin script and the uninstaller as -# EQL v3 into build/render/eql_v3/ (same relative paths), then run every gate -# and assemble the installer from the rendered tree. `eql-codegen render` fails +# is rendered. Render the ordered files, the pin script and the uninstaller once +# per EQL version into build/render/eql_v/ (same relative paths), then run +# the gates and assemble that version's installer from the rendered tree. `eql-codegen render` fails # on an unknown placeholder and on a literal eql_v3/eql_v4 in the source, and # writes nothing unless every file renders. assemble() { @@ -98,7 +98,13 @@ assemble() { $(cat src/deps-ordered-v3.txt) tasks/pin_search_path_v3.sql tasks/uninstall-v3.sql sed "s|^|$root/|" src/deps-ordered-v3.txt > "$ordered" - bash tasks/test/verify_symbol_order_v3.sh "$ordered" + # The symbol-order checker parses eql_v3 names. One run covers both + # versions: eql_v4 is the same files in the same order, every name renamed + # alike, so define-before-use holds for one exactly when it holds for the + # other. + if [[ "$version" == 3 ]]; then + bash tasks/test/verify_symbol_order_v3.sh "$ordered" + fi : > "$installer" while IFS= read -r f; do @@ -118,14 +124,22 @@ assemble() { } assemble 3 release/cipherstash-encrypt.sql release/cipherstash-encrypt-uninstall.sql +assemble 4 release/cipherstash-encrypt-v4.sql release/cipherstash-encrypt-v4-uninstall.sql + +# Nothing in the v4 bundle may name v3: a stray eql_v3 reference would install +# cleanly and then compare v4 data with v3 terms. The renderer already rejects +# literal names in the source; this checks the artefact itself. +bash tasks/test/verify_no_v3_in_v4.sh release/cipherstash-encrypt-v4.sql release/cipherstash-encrypt-v4-uninstall.sql echo echo '###############################################' echo "# ✅Build succeeded" echo '###############################################' echo -echo 'Installer:' -echo ' release/cipherstash-encrypt.sql' +echo 'Installers:' +echo ' release/cipherstash-encrypt.sql (eql_v3)' +echo ' release/cipherstash-encrypt-v4.sql (eql_v4)' echo -echo 'Uninstaller:' +echo 'Uninstallers:' echo ' release/cipherstash-encrypt-uninstall.sql' +echo ' release/cipherstash-encrypt-v4-uninstall.sql' diff --git a/packages/eql/tasks/test/verify_no_v3_in_v4.sh b/packages/eql/tasks/test/verify_no_v3_in_v4.sh new file mode 100755 index 000000000..84fa3f5c3 --- /dev/null +++ b/packages/eql/tasks/test/verify_no_v3_in_v4.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +#MISE description="Assert the eql_v4 installer and uninstaller name nothing from eql_v3 (DB-free)" +# +# ADR-0002 installs one SQL source under two names, so a column's domain says +# which engine produced its terms. That holds only while the v4 bundle is v3's +# SQL with EVERY name and version changed. A stray eql_v3 reference installs +# cleanly and then quietly compares v4 data with v3 terms, or casts a v4 payload +# to a v3 domain. +# +# `eql-codegen render` already refuses a literal eql_v3 in the source. This +# checks the built artefact, so it also catches a build step that splices text +# in around the renderer. +# +# Usage: verify_no_v3_in_v4.sh [file...] (exit 1 on a leak, 2 on bad input) +# With no arguments it checks the v4 installer and uninstaller in release/. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +if [[ $# -eq 0 ]]; then + set -- release/cipherstash-encrypt-v4.sql release/cipherstash-encrypt-v4-uninstall.sql +fi + +fail=0 +for f in "$@"; do + if [[ ! -s "$f" ]]; then + echo "ERROR: $f missing or empty — run 'mise run build' first" >&2 + exit 2 + fi + # Non-vacuity: a v4 bundle that never names eql_v4 was not rendered as v4. + if ! grep -q 'eql_v4' "$f"; then + echo "ERROR: $f never names eql_v4 — not a v4 render" >&2 + fail=1 + fi + if grep -n 'eql_v3' "$f" | head -5 | sed "s|^|$f:|" | grep .; then + echo "ERROR: $f names eql_v3 (first 5 shown)" >&2 + fail=1 + fi + # The domain CHECKs compare the payload version. Any v3 comparison left in + # the v4 bundle would admit v3 payloads into v4 columns. + if grep -nE "'v'[[:space:]]*=[[:space:]]*'3'" "$f" | head -5 | sed "s|^|$f:|" | grep .; then + echo "ERROR: $f checks for payload version 3 (first 5 shown)" >&2 + fail=1 + fi +done + +if [[ $fail -ne 0 ]]; then + echo "v4 isolation gate FAILED" >&2 + exit 1 +fi +echo "v4 isolation gate OK ($# files)" diff --git a/packages/eql/tests/sqlx/tests/v3_v4_coexistence_tests.rs b/packages/eql/tests/sqlx/tests/v3_v4_coexistence_tests.rs new file mode 100644 index 000000000..4b4def3ed --- /dev/null +++ b/packages/eql/tests/sqlx/tests/v3_v4_coexistence_tests.rs @@ -0,0 +1,208 @@ +//! EQL v3 and v4 installed side by side in one database (ADR-0002). +//! +//! The two bundles are the same SQL under two names: `eql_v3` for terms written +//! by cipherstash-client, `eql_v4` for terms written by Stack Encrypt. These +//! tests pin what the separate names do and do not enforce when both are +//! installed: +//! +//! - both install without clashing, and each owns its own domains; +//! - a payload of the wrong version fails the domain CHECK on insert; +//! - the function form across versions (`eql_v3.eq(v3col, v4term)`) is +//! ambiguous, so it errors; +//! - the operator form across versions (`v3col = v4term`) does NOT error. +//! With no operator defined for the exact pair, Postgres treats both domains +//! as `jsonb` and uses the built-in `jsonb = jsonb`, which returns no rows. +//! That is pinned here so it cannot change unnoticed. ADR-0002 defers the +//! cross-version blocker operators that would make it an error; when they +//! land, those assertions flip to expecting one. +//! +//! The payloads are real cipherstash-client ciphertexts from the +//! `eql_v3_integer` fixture. A v4 payload is the same row with its envelope +//! version set to 4: the two bundles share their SQL, so only the version +//! decides which domain admits it. + +use anyhow::Result; +use sqlx::PgPool; + +const V4_INSTALLER: &str = "../../release/cipherstash-encrypt-v4.sql"; + +/// SQLSTATE `check_violation`. +const CHECK_VIOLATION: &str = "23514"; +/// SQLSTATE `ambiguous_function`. +const AMBIGUOUS_FUNCTION: &str = "42725"; + +/// Install the v4 bundle into a test database that the migrations have +/// already given v3. +async fn install_v4(pool: &PgPool) -> Result<()> { + let sql = std::fs::read_to_string(V4_INSTALLER).unwrap_or_else(|e| { + panic!( + "failed to read {V4_INSTALLER}: {e} — run `mise run build` \ + (or, in CI, ensure the nextest-archive artifact shipped release/*.sql)" + ) + }); + sqlx::raw_sql(&sql).execute(pool).await?; + Ok(()) +} + +/// One `integer_eq` column per version, each holding the fixture row for 42, +/// written as that version's payload. +async fn tables_with_one_row(pool: &PgPool) -> Result<()> { + sqlx::raw_sql( + "CREATE TABLE t3 (c public.eql_v3_integer_eq); + CREATE TABLE t4 (c public.eql_v4_integer_eq); + INSERT INTO t3 SELECT payload::public.eql_v3_integer_eq + FROM fixtures.eql_v3_integer WHERE plaintext = 42; + INSERT INTO t4 SELECT jsonb_set(payload, '{v}', '4')::public.eql_v4_integer_eq + FROM fixtures.eql_v3_integer WHERE plaintext = 42;", + ) + .execute(pool) + .await?; + Ok(()) +} + +/// The term-only query operand for 42 (no ciphertext `c`), as `version`. +fn query_term(version: u8) -> String { + format!( + "(SELECT jsonb_set(payload - 'c', '{{v}}', '{version}') \ + FROM fixtures.eql_v3_integer WHERE plaintext = 42)::eql_v{version}.query_integer_eq" + ) +} + +fn sqlstate(err: &sqlx::Error) -> Option { + match err { + sqlx::Error::Database(e) => e.code().map(|c| c.into_owned()), + _ => None, + } +} + +#[sqlx::test(fixtures(path = "../fixtures", scripts("eql_v3_integer")))] +async fn v3_and_v4_install_side_by_side(pool: PgPool) -> Result<()> { + install_v4(&pool).await?; + + let schemas: Vec = sqlx::query_scalar( + "SELECT nspname::text FROM pg_namespace WHERE nspname LIKE 'eql\\_v_%' ORDER BY 1", + ) + .fetch_all(&pool) + .await?; + assert_eq!( + schemas, + ["eql_v3", "eql_v3_internal", "eql_v4", "eql_v4_internal"] + ); + + let (v3, v4): (i64, i64) = sqlx::query_as( + "SELECT count(*) FILTER (WHERE typname LIKE 'eql\\_v3\\_%'), + count(*) FILTER (WHERE typname LIKE 'eql\\_v4\\_%') + FROM pg_type WHERE typnamespace = 'public'::regnamespace AND typtype = 'd'", + ) + .fetch_one(&pool) + .await?; + assert!(v3 > 0, "v3 installed no public domains"); + assert_eq!(v3, v4, "each version owns the same set of public domains"); + + let (a, b): (String, String) = sqlx::query_as("SELECT eql_v3.version(), eql_v4.version()") + .fetch_one(&pool) + .await?; + assert_eq!(a, b, "both bundles come from one build"); + Ok(()) +} + +#[sqlx::test(fixtures(path = "../fixtures", scripts("eql_v3_integer")))] +async fn each_version_matches_its_own_terms(pool: PgPool) -> Result<()> { + install_v4(&pool).await?; + tables_with_one_row(&pool).await?; + + for (table, version) in [("t3", 3), ("t4", 4)] { + let n: i64 = sqlx::query_scalar(&format!( + "SELECT count(*) FROM {table} WHERE c = {}", + query_term(version) + )) + .fetch_one(&pool) + .await?; + assert_eq!(n, 1, "{table} = its own version's term must match"); + } + Ok(()) +} + +#[sqlx::test(fixtures(path = "../fixtures", scripts("eql_v3_integer")))] +async fn wrong_version_insert_fails_the_domain_check(pool: PgPool) -> Result<()> { + install_v4(&pool).await?; + + for (domain, version) in [ + ("public.eql_v3_integer_eq", 4), + ("public.eql_v4_integer_eq", 3), + ] { + let err = sqlx::query(&format!( + "SELECT jsonb_set(payload, '{{v}}', '{version}')::{domain} \ + FROM fixtures.eql_v3_integer WHERE plaintext = 42" + )) + .execute(&pool) + .await + .expect_err("a payload of the other version must not fit the domain"); + assert_eq!( + sqlstate(&err).as_deref(), + Some(CHECK_VIOLATION), + "a version-{version} payload cast to {domain}: {err}" + ); + } + Ok(()) +} + +#[sqlx::test(fixtures(path = "../fixtures", scripts("eql_v3_integer")))] +async fn function_form_across_versions_is_ambiguous(pool: PgPool) -> Result<()> { + install_v4(&pool).await?; + tables_with_one_row(&pool).await?; + + for (table, function, term) in [("t3", "eql_v3.eq", 4), ("t4", "eql_v4.eq", 3)] { + let err = sqlx::query(&format!( + "SELECT count(*) FROM {table} WHERE {function}(c, {})", + query_term(term) + )) + .execute(&pool) + .await + .expect_err("a cross-version function call must not resolve"); + assert_eq!( + sqlstate(&err).as_deref(), + Some(AMBIGUOUS_FUNCTION), + "{function}({table}.c, v{term} term): {err}" + ); + } + Ok(()) +} + +/// Pins a known gap: see the module docs. When cross-version blocker operators +/// exist, these comparisons raise instead, and this test changes with them. +#[sqlx::test(fixtures(path = "../fixtures", scripts("eql_v3_integer")))] +async fn operator_across_versions_returns_no_rows(pool: PgPool) -> Result<()> { + install_v4(&pool).await?; + tables_with_one_row(&pool).await?; + + for (table, term) in [("t3", 4), ("t4", 3)] { + let n: i64 = sqlx::query_scalar(&format!( + "SELECT count(*) FROM {table} WHERE c = {}", + query_term(term) + )) + .fetch_one(&pool) + .await?; + assert_eq!(n, 0, "{table}.c = v{term} term runs, and finds nothing"); + + // Why: no EQL operator takes this pair, so the planner chose the + // built-in jsonb equality on the two domains' base type. + let plan: Vec = sqlx::query_scalar(&format!( + "EXPLAIN (COSTS OFF) SELECT * FROM {table} WHERE c = {}", + query_term(term) + )) + .fetch_all(&pool) + .await?; + let plan = plan.join("\n"); + assert!( + plan.contains("(c)::jsonb = "), + "{table}.c = v{term} term should fall back to jsonb = jsonb:\n{plan}" + ); + } + + let joined: i64 = sqlx::query_scalar("SELECT count(*) FROM t3 JOIN t4 ON t3.c = t4.c") + .fetch_one(&pool) + .await?; + assert_eq!(joined, 0, "a v3 column joined to a v4 column finds nothing"); + Ok(()) +} From ed5bcb3052ecff710c9c74185cdbe1e0208cb110 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 12 Oct 2026 22:50:04 +1100 Subject: [PATCH 2/4] fix(eql): make the v4 isolation gate and both lints hold with two bundles verify_no_v3_in_v4.sh piped grep into head under pipefail. When the matches outgrew the pipe buffer, grep died of SIGPIPE, the condition read false, and a wholesale eql_v3 leak passed while a single stray reference failed. It now captures the matches before printing them, and a new self-test (test:v4_isolation, run in CI) feeds it a 20,000-line leak. With v3 and v4 in one database, each lints() reported 3,840 errors about the other version's blocker operators: encrypted_domain_types took every jsonb domain in public, while blockers are recognised only in the lint's own schemas. The public arm is now limited to the lint's own prefix, and the coexistence test asserts both lints are clean with both installed. The coexistence tests also pin OPERATOR(public.=) between versions (ambiguous) and the eq_term text comparison (runs, refuses nothing), and build.sh and mise run clean remove the v4 artefacts too. --- .changeset/eql-lints-own-domains.md | 5 ++ .github/workflows/test-eql.yml | 2 + packages/eql/mise.toml | 11 ++++ packages/eql/src/v3/lint/lints.sql | 7 ++- packages/eql/tasks/build.sh | 2 + .../eql/tasks/test/v4_isolation_selftest.sh | 40 ++++++++++++ packages/eql/tasks/test/verify_no_v3_in_v4.sh | 20 ++++-- .../sqlx/tests/v3_v4_coexistence_tests.rs | 62 ++++++++++++++++++- 8 files changed, 139 insertions(+), 10 deletions(-) create mode 100644 .changeset/eql-lints-own-domains.md create mode 100755 packages/eql/tasks/test/v4_isolation_selftest.sh diff --git a/.changeset/eql-lints-own-domains.md b/.changeset/eql-lints-own-domains.md new file mode 100644 index 000000000..e8ed05994 --- /dev/null +++ b/.changeset/eql-lints-own-domains.md @@ -0,0 +1,5 @@ +--- +'@cipherstash/eql': patch +--- + +`eql_v3.lints()` now judges only EQL's own `public.eql_v3_*` domains. It used to take every `jsonb` domain in `public`, so a database with another set of encrypted domains installed beside EQL v3 got thousands of false `inlinability_*` errors from the self-check. diff --git a/.github/workflows/test-eql.yml b/.github/workflows/test-eql.yml index a75d02a28..9a5e21080 100644 --- a/.github/workflows/test-eql.yml +++ b/.github/workflows/test-eql.yml @@ -733,6 +733,8 @@ jobs: run: mise run test:installer_complete - name: Symbol-order cross-check (v3) run: mise run test:symbol_order_v3 + - name: v4 isolation gate and its self-test + run: mise run test:v4_isolation - name: Build-ordering helper unit tests run: mise run test:build_ordering_helpers diff --git a/packages/eql/mise.toml b/packages/eql/mise.toml index 4eb97643c..7858afd3a 100644 --- a/packages/eql/mise.toml +++ b/packages/eql/mise.toml @@ -66,6 +66,8 @@ description = "Clean release" run = """ rm -f release/cipherstash-encrypt-uninstall.sql rm -f release/cipherstash-encrypt.sql + rm -f release/cipherstash-encrypt-v4-uninstall.sql + rm -f release/cipherstash-encrypt-v4.sql """ [tasks."release:prepare_bindings_assets"] @@ -247,6 +249,15 @@ bash tasks/test/symbol_order_selftest.sh bash tasks/test/verify_symbol_order_v3.sh build/render/eql_v3/deps-ordered.txt """ +[tasks."test:v4_isolation"] +description = "Assert the eql_v4 bundle names nothing from eql_v3, and self-test that check (DB-free)" +depends = ["build"] +dir = "{{config_root}}" +run = """ +bash tasks/test/v4_isolation_selftest.sh +bash tasks/test/verify_no_v3_in_v4.sh +""" + [tasks."test:installer_complete"] description = "Assert the installer contains every ordered src/v3 file's body (DB-free)" depends = ["build"] diff --git a/packages/eql/src/v3/lint/lints.sql b/packages/eql/src/v3/lint/lints.sql index c68307f0c..ab5d1c06b 100644 --- a/packages/eql/src/v3/lint/lints.sql +++ b/packages/eql/src/v3/lint/lints.sql @@ -85,7 +85,10 @@ AS $$ -- User-column encrypted domains now live in public so application tables -- survive EQL uninstall. Keep this separate from owned_schemas(): public is -- not installer-owned, but its EQL jsonb-backed domains are still the domain - -- types whose blockers/operator surfaces the lint must understand. + -- types whose blockers/operator surfaces the lint must understand. Only this + -- version's public domains: with EQL v3 and v4 installed side by side, the + -- other version's domains and blockers are not this lint's to judge, and its + -- blockers sit outside owned_schemas() where they would read as errors. encrypted_domain_types AS ( SELECT dt.oid AS typid @@ -97,7 +100,7 @@ AS $$ AND bt.typname = 'jsonb' AND bn.nspname = 'pg_catalog' AND ( - dn.nspname = 'public' + (dn.nspname = 'public' AND dt.typname LIKE '{{prefix}}\_%') OR dn.nspname = ANY({{prefix}}_internal.owned_schemas()) ) ), diff --git a/packages/eql/tasks/build.sh b/packages/eql/tasks/build.sh index 9e7551706..df286e9c6 100755 --- a/packages/eql/tasks/build.sh +++ b/packages/eql/tasks/build.sh @@ -47,6 +47,8 @@ mkdir -p release rm -f release/cipherstash-encrypt.sql rm -f release/cipherstash-encrypt-uninstall.sql +rm -f release/cipherstash-encrypt-v4.sql +rm -f release/cipherstash-encrypt-v4-uninstall.sql rm -f src/deps-ordered-v3.txt src/deps-ordered-v3.txt.tmp rm -f src/v3/version.sql diff --git a/packages/eql/tasks/test/v4_isolation_selftest.sh b/packages/eql/tasks/test/v4_isolation_selftest.sh new file mode 100755 index 000000000..9bd96630b --- /dev/null +++ b/packages/eql/tasks/test/v4_isolation_selftest.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +#MISE description="DB-free self-test for the v4 isolation gate (clean passes, small and wholesale leaks fail)" +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT + +gate() { bash tasks/test/verify_no_v3_in_v4.sh "$@" >/dev/null 2>&1; } + +# CLEAN: names eql_v4, checks version 4, names nothing from v3. +printf "CREATE SCHEMA eql_v4;\nCHECK (VALUE->>'v' = '4');\n" > "$tmp/clean.sql" +gate "$tmp/clean.sql" || { echo "FAIL: clean v4 bundle rejected"; exit 1; } +echo "ok: clean v4 bundle accepted" + +# ONE stray reference. +printf "CREATE SCHEMA eql_v4;\nSELECT eql_v3.eq_term(x);\n" > "$tmp/one.sql" +if gate "$tmp/one.sql"; then echo "FAIL: one eql_v3 reference accepted"; exit 1; fi +echo "ok: one eql_v3 reference rejected" + +# WHOLESALE leak: far more matching lines than a pipe buffer holds. A +# `grep | head` check under pipefail read this as no leak (SIGPIPE). +{ + echo "CREATE SCHEMA eql_v4;" + for i in $(seq 1 20000); do echo "SELECT eql_v3_internal.f$i(eql_v4.g());"; done +} > "$tmp/wholesale.sql" +if gate "$tmp/wholesale.sql"; then echo "FAIL: wholesale eql_v3 leak accepted"; exit 1; fi +echo "ok: wholesale eql_v3 leak rejected" + +# The version check, wholesale too. +{ + echo "CREATE SCHEMA eql_v4;" + for i in $(seq 1 20000); do echo "CHECK (VALUE->>'v' = '3');"; done +} > "$tmp/version.sql" +if gate "$tmp/version.sql"; then echo "FAIL: wholesale version-3 checks accepted"; exit 1; fi +echo "ok: wholesale version-3 checks rejected" + +# NON-VACUITY: a file that never names eql_v4 is not a v4 render. +printf "SELECT 1;\n" > "$tmp/none.sql" +if gate "$tmp/none.sql"; then echo "FAIL: non-v4 render accepted"; exit 1; fi +echo "ok: non-v4 render rejected" diff --git a/packages/eql/tasks/test/verify_no_v3_in_v4.sh b/packages/eql/tasks/test/verify_no_v3_in_v4.sh index 84fa3f5c3..52cb118af 100755 --- a/packages/eql/tasks/test/verify_no_v3_in_v4.sh +++ b/packages/eql/tasks/test/verify_no_v3_in_v4.sh @@ -23,6 +23,16 @@ if [[ $# -eq 0 ]]; then set -- release/cipherstash-encrypt-v4.sql release/cipherstash-encrypt-v4-uninstall.sql fi +# Capture matches before printing them. `grep | head -5` under pipefail is +# false whenever grep has more output than the pipe holds: head exits, grep +# dies of SIGPIPE, and a wholesale leak read as no leak at all. +report() { + local f=$1 what=$2 hits=$3 + sed -n "1,5s|^|$f:|p" <<<"$hits" + echo "ERROR: $f $what (first 5 shown)" >&2 + fail=1 +} + fail=0 for f in "$@"; do if [[ ! -s "$f" ]]; then @@ -34,15 +44,13 @@ for f in "$@"; do echo "ERROR: $f never names eql_v4 — not a v4 render" >&2 fail=1 fi - if grep -n 'eql_v3' "$f" | head -5 | sed "s|^|$f:|" | grep .; then - echo "ERROR: $f names eql_v3 (first 5 shown)" >&2 - fail=1 + if hits=$(grep -n 'eql_v3' "$f"); then + report "$f" "names eql_v3" "$hits" fi # The domain CHECKs compare the payload version. Any v3 comparison left in # the v4 bundle would admit v3 payloads into v4 columns. - if grep -nE "'v'[[:space:]]*=[[:space:]]*'3'" "$f" | head -5 | sed "s|^|$f:|" | grep .; then - echo "ERROR: $f checks for payload version 3 (first 5 shown)" >&2 - fail=1 + if hits=$(grep -nE "'v'[[:space:]]*=[[:space:]]*'3'" "$f"); then + report "$f" "checks for payload version 3" "$hits" fi done diff --git a/packages/eql/tests/sqlx/tests/v3_v4_coexistence_tests.rs b/packages/eql/tests/sqlx/tests/v3_v4_coexistence_tests.rs index 4b4def3ed..e7878b736 100644 --- a/packages/eql/tests/sqlx/tests/v3_v4_coexistence_tests.rs +++ b/packages/eql/tests/sqlx/tests/v3_v4_coexistence_tests.rs @@ -5,16 +5,20 @@ //! tests pin what the separate names do and do not enforce when both are //! installed: //! -//! - both install without clashing, and each owns its own domains; +//! - both install without clashing, each owns its own domains, and each +//! version's `lints()` stays clean with the other installed; //! - a payload of the wrong version fails the domain CHECK on insert; //! - the function form across versions (`eql_v3.eq(v3col, v4term)`) is -//! ambiguous, so it errors; +//! ambiguous, so it errors, and so is `OPERATOR(public.=)` between a v3 and +//! a v4 column; //! - the operator form across versions (`v3col = v4term`) does NOT error. //! With no operator defined for the exact pair, Postgres treats both domains //! as `jsonb` and uses the built-in `jsonb = jsonb`, which returns no rows. //! That is pinned here so it cannot change unnoticed. ADR-0002 defers the //! cross-version blocker operators that would make it an error; when they //! land, those assertions flip to expecting one. +//! - `eql_v3.eq_term(v3col) = eql_v4.eq_term(v4col)` does not error either: it +//! compares two text terms. //! //! The payloads are real cipherstash-client ciphertexts from the //! `eql_v3_integer` fixture. A v4 payload is the same row with its envelope @@ -103,6 +107,22 @@ async fn v3_and_v4_install_side_by_side(pool: PgPool) -> Result<()> { .fetch_one(&pool) .await?; assert_eq!(a, b, "both bundles come from one build"); + + // Each lint judges only its own version's public domains. Before that was + // scoped, each version reported thousands of errors about the other's + // blocker operators. + for schema in ["eql_v3", "eql_v4"] { + let errors: Vec = sqlx::query_scalar(&format!( + "SELECT category || ': ' || object_name FROM {schema}.lints() \ + WHERE severity = 'error' ORDER BY 1 LIMIT 5" + )) + .fetch_all(&pool) + .await?; + assert!( + errors.is_empty(), + "{schema}.lints() reports errors with both versions installed: {errors:#?}" + ); + } Ok(()) } @@ -206,3 +226,41 @@ async fn operator_across_versions_returns_no_rows(pool: PgPool) -> Result<()> { assert_eq!(joined, 0, "a v3 column joined to a v4 column finds nothing"); Ok(()) } + +#[sqlx::test(fixtures(path = "../fixtures", scripts("eql_v3_integer")))] +async fn qualified_operator_across_versions_is_ambiguous(pool: PgPool) -> Result<()> { + install_v4(&pool).await?; + tables_with_one_row(&pool).await?; + + let err = sqlx::query("SELECT count(*) FROM t3 JOIN t4 ON t3.c OPERATOR(public.=) t4.c") + .execute(&pool) + .await + .expect_err("OPERATOR(public.=) between a v3 and a v4 column must not resolve"); + assert_eq!( + sqlstate(&err).as_deref(), + Some(AMBIGUOUS_FUNCTION), + "t3.c OPERATOR(public.=) t4.c: {err}" + ); + Ok(()) +} + +/// Pins a second silent path: see the module docs. The terms are text, so +/// comparing them across versions is ordinary text equality. The fixture's v4 +/// row reuses the v3 row's terms, so here it even matches; with real Stack +/// Encrypt terms it finds nothing. Either way nothing refuses it. +#[sqlx::test(fixtures(path = "../fixtures", scripts("eql_v3_integer")))] +async fn eq_term_across_versions_compares_text(pool: PgPool) -> Result<()> { + install_v4(&pool).await?; + tables_with_one_row(&pool).await?; + + let n: i64 = sqlx::query_scalar( + "SELECT count(*) FROM t3 JOIN t4 ON eql_v3.eq_term(t3.c) = eql_v4.eq_term(t4.c)", + ) + .fetch_one(&pool) + .await?; + assert_eq!( + n, 1, + "the cross-version term comparison runs as text equality" + ); + Ok(()) +} From 8f9e8a7ac7a2637e776389032c39e29bc781537d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 13 Oct 2026 13:11:26 +1100 Subject: [PATCH 3/4] fix(eql): make the v4 isolation gate check versions positively - The gate accepts a payload-version read only in the form `->> 'v' = '4'`, so IN (...), swapped operands, casts and jsonb comparisons are refused without being listed. A grep fault ends the gate with 2 rather than reading as no match. - The self-test asserts the exact exit status for every case: 0 clean, 1 leak, 2 bad input. A crashed gate no longer passes the rejection cases. - test:installer_complete checks the v4 installer as well as v3. - lints() matches its own public domains with starts_with: in LIKE, the underscores inside the prefix are wildcards. --- packages/eql/mise.toml | 7 ++- packages/eql/src/v3/lint/lints.sql | 3 +- .../eql/tasks/test/v4_isolation_selftest.sh | 50 ++++++++++++++----- packages/eql/tasks/test/verify_no_v3_in_v4.sh | 38 +++++++++++--- 4 files changed, 77 insertions(+), 21 deletions(-) diff --git a/packages/eql/mise.toml b/packages/eql/mise.toml index 7858afd3a..e9feeb73d 100644 --- a/packages/eql/mise.toml +++ b/packages/eql/mise.toml @@ -259,10 +259,13 @@ bash tasks/test/verify_no_v3_in_v4.sh """ [tasks."test:installer_complete"] -description = "Assert the installer contains every ordered src/v3 file's body (DB-free)" +description = "Assert the v3 and v4 installers each contain every ordered src/v3 file's body (DB-free)" depends = ["build"] dir = "{{config_root}}" -run = "bash tasks/test/verify_installer_complete.sh build/render/eql_v3/deps-ordered.txt release/cipherstash-encrypt.sql build/render/eql_v3" +run = """ +bash tasks/test/verify_installer_complete.sh build/render/eql_v3/deps-ordered.txt release/cipherstash-encrypt.sql build/render/eql_v3 +bash tasks/test/verify_installer_complete.sh build/render/eql_v4/deps-ordered.txt release/cipherstash-encrypt-v4.sql build/render/eql_v4 +""" [tasks."test:build_ordering_helpers"] description = "Unit tests for tasks/build/ordering.sh strip_require_lines (DB-free)" diff --git a/packages/eql/src/v3/lint/lints.sql b/packages/eql/src/v3/lint/lints.sql index ab5d1c06b..1e87deade 100644 --- a/packages/eql/src/v3/lint/lints.sql +++ b/packages/eql/src/v3/lint/lints.sql @@ -89,6 +89,7 @@ AS $$ -- version's public domains: with EQL v3 and v4 installed side by side, the -- other version's domains and blockers are not this lint's to judge, and its -- blockers sit outside owned_schemas() where they would read as errors. + -- starts_with, not LIKE: the underscores in the prefix are LIKE wildcards. encrypted_domain_types AS ( SELECT dt.oid AS typid @@ -100,7 +101,7 @@ AS $$ AND bt.typname = 'jsonb' AND bn.nspname = 'pg_catalog' AND ( - (dn.nspname = 'public' AND dt.typname LIKE '{{prefix}}\_%') + (dn.nspname = 'public' AND starts_with(dt.typname::text, '{{prefix}}_')) OR dn.nspname = ANY({{prefix}}_internal.owned_schemas()) ) ), diff --git a/packages/eql/tasks/test/v4_isolation_selftest.sh b/packages/eql/tasks/test/v4_isolation_selftest.sh index 9bd96630b..74e7025ff 100755 --- a/packages/eql/tasks/test/v4_isolation_selftest.sh +++ b/packages/eql/tasks/test/v4_isolation_selftest.sh @@ -5,17 +5,26 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$REPO_ROOT" tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT -gate() { bash tasks/test/verify_no_v3_in_v4.sh "$@" >/dev/null 2>&1; } +# Run the gate on one case and require an exact exit status. The gate exits 1 +# on a leak and 2 on bad input or a fault, so "non-zero" would let a crashed +# gate pass every rejection case. +expect() { + local want=$1 what=$2 file=$3 rc=0 + bash tasks/test/verify_no_v3_in_v4.sh "$file" >/dev/null 2>&1 || rc=$? + if [[ $rc -ne $want ]]; then + echo "FAIL: $what: gate exited $rc, expected $want" + exit 1 + fi + echo "ok: $what (exit $rc)" +} # CLEAN: names eql_v4, checks version 4, names nothing from v3. -printf "CREATE SCHEMA eql_v4;\nCHECK (VALUE->>'v' = '4');\n" > "$tmp/clean.sql" -gate "$tmp/clean.sql" || { echo "FAIL: clean v4 bundle rejected"; exit 1; } -echo "ok: clean v4 bundle accepted" +printf "CREATE SCHEMA eql_v4;\nCHECK (VALUE->>'v' = '4');\nSELECT jsonb_build_object('v', val->'v');\n" > "$tmp/clean.sql" +expect 0 "clean v4 bundle accepted" "$tmp/clean.sql" # ONE stray reference. printf "CREATE SCHEMA eql_v4;\nSELECT eql_v3.eq_term(x);\n" > "$tmp/one.sql" -if gate "$tmp/one.sql"; then echo "FAIL: one eql_v3 reference accepted"; exit 1; fi -echo "ok: one eql_v3 reference rejected" +expect 1 "one eql_v3 reference rejected" "$tmp/one.sql" # WHOLESALE leak: far more matching lines than a pipe buffer holds. A # `grep | head` check under pipefail read this as no leak (SIGPIPE). @@ -23,18 +32,35 @@ echo "ok: one eql_v3 reference rejected" echo "CREATE SCHEMA eql_v4;" for i in $(seq 1 20000); do echo "SELECT eql_v3_internal.f$i(eql_v4.g());"; done } > "$tmp/wholesale.sql" -if gate "$tmp/wholesale.sql"; then echo "FAIL: wholesale eql_v3 leak accepted"; exit 1; fi -echo "ok: wholesale eql_v3 leak rejected" +expect 1 "wholesale eql_v3 leak rejected" "$tmp/wholesale.sql" # The version check, wholesale too. { echo "CREATE SCHEMA eql_v4;" for i in $(seq 1 20000); do echo "CHECK (VALUE->>'v' = '3');"; done } > "$tmp/version.sql" -if gate "$tmp/version.sql"; then echo "FAIL: wholesale version-3 checks accepted"; exit 1; fi -echo "ok: wholesale version-3 checks rejected" +expect 1 "wholesale version-3 checks rejected" "$tmp/version.sql" + +# Other spellings of a version check that is not v4's. The gate accepts only +# `->> 'v' = '4'`, so each of these is refused without being listed there. +n=0 +for check in \ + "VALUE->>'v' IN ('3')" \ + "'3' = VALUE->>'v'" \ + "(VALUE->>'v')::int = 3" \ + "VALUE->>'v' = '40'" \ + "(VALUE->'v') = '3'::jsonb" \ + "'3'::jsonb = VALUE->'v'"; do + n=$((n + 1)) + printf "CREATE SCHEMA eql_v4;\nCHECK (%s);\n" "$check" > "$tmp/spelling$n.sql" + expect 1 "version check rejected: $check" "$tmp/spelling$n.sql" +done # NON-VACUITY: a file that never names eql_v4 is not a v4 render. printf "SELECT 1;\n" > "$tmp/none.sql" -if gate "$tmp/none.sql"; then echo "FAIL: non-v4 render accepted"; exit 1; fi -echo "ok: non-v4 render rejected" +expect 1 "non-v4 render rejected" "$tmp/none.sql" + +# BAD INPUT is a fault, not a leak. +expect 2 "missing file is bad input" "$tmp/does-not-exist.sql" +: > "$tmp/empty.sql" +expect 2 "empty file is bad input" "$tmp/empty.sql" diff --git a/packages/eql/tasks/test/verify_no_v3_in_v4.sh b/packages/eql/tasks/test/verify_no_v3_in_v4.sh index 52cb118af..90b545fea 100755 --- a/packages/eql/tasks/test/verify_no_v3_in_v4.sh +++ b/packages/eql/tasks/test/verify_no_v3_in_v4.sh @@ -33,6 +33,27 @@ report() { fail=1 } +# grep into $hits, telling "no match" (1) from a fault (2 or more). A fault is +# bad input, not a clean file, so it ends the gate with 2. +find_lines() { + local rc=0 + hits=$(grep "$@") || rc=$? + if [[ $rc -gt 1 ]]; then + echo "ERROR: grep exited $rc on ${*: -1}" >&2 + exit 2 + fi + return "$rc" +} + +# A v4 payload-version check, and the only form one may take: the text read +# compared with '4'. Anything else that reads 'v' as text (another version, +# IN (...), a cast, the operands swapped) is refused, so the gate does not +# depend on guessing every way a v3 check could be spelt. +V4_CHECK="->>[[:space:]]*'v'[[:space:]]*=[[:space:]]*'4'([^0-9]|$)" +TEXT_READ="->>[[:space:]]*'v'" +# A jsonb read of 'v' (`->`, not `->>`) on a line that compares something. +JSONB_COMPARE="->[[:space:]]*'v'.*(=|<>|[[:space:]][Ii][Nn][[:space:]]*\\()|(=|<>).*->[[:space:]]*'v'" + fail=0 for f in "$@"; do if [[ ! -s "$f" ]]; then @@ -40,17 +61,22 @@ for f in "$@"; do exit 2 fi # Non-vacuity: a v4 bundle that never names eql_v4 was not rendered as v4. - if ! grep -q 'eql_v4' "$f"; then + if ! find_lines -n 'eql_v4' "$f"; then echo "ERROR: $f never names eql_v4 — not a v4 render" >&2 fail=1 fi - if hits=$(grep -n 'eql_v3' "$f"); then + if find_lines -n 'eql_v3' "$f"; then report "$f" "names eql_v3" "$hits" fi - # The domain CHECKs compare the payload version. Any v3 comparison left in - # the v4 bundle would admit v3 payloads into v4 columns. - if hits=$(grep -nE "'v'[[:space:]]*=[[:space:]]*'3'" "$f"); then - report "$f" "checks for payload version 3" "$hits" + # The domain CHECKs compare the payload version. Any check that is not + # `->> 'v' = '4'` could admit v3 payloads into v4 columns. + if find_lines -nE -e "$TEXT_READ" "$f"; then + if bad=$(grep -vE -e "$V4_CHECK" <<<"$hits"); then + report "$f" "reads the payload version other than as ->> 'v' = '4'" "$bad" + fi + fi + if find_lines -nE -e "$JSONB_COMPARE" "$f"; then + report "$f" "compares the payload version as jsonb" "$hits" fi done From 1de608e81ecc25cf9194986f077da0751e1478f8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 13 Oct 2026 14:31:22 +1100 Subject: [PATCH 4/4] test(eql): pin the cross-version fallback to jsonb = jsonb from the catalog operator_across_versions_returns_no_rows read the planner's choice from EXPLAIN text, whose deparse can change between Postgres majors. It now stores the comparison as a view and reads the operator it bound: an EQL operator from pg_depend, and the built-in jsonb = jsonb, which is pinned and has no dependency row, from the view's stored query tree. A control checks that a same-version comparison binds the EQL operator, so an empty list is a finding rather than a probe that sees nothing. Claude-Session: https://claude.ai/code/session_01VYKAWbd361xDLmqBdLoUcB --- .../sqlx/tests/v3_v4_coexistence_tests.rs | 78 ++++++++++++++++--- 1 file changed, 68 insertions(+), 10 deletions(-) diff --git a/packages/eql/tests/sqlx/tests/v3_v4_coexistence_tests.rs b/packages/eql/tests/sqlx/tests/v3_v4_coexistence_tests.rs index e7878b736..3e93e86d0 100644 --- a/packages/eql/tests/sqlx/tests/v3_v4_coexistence_tests.rs +++ b/packages/eql/tests/sqlx/tests/v3_v4_coexistence_tests.rs @@ -72,6 +72,49 @@ fn query_term(version: u8) -> String { ) } +/// Which equality `{table}.c = ` binds, read from the +/// catalog rather than from EXPLAIN text, whose deparse can change between +/// Postgres majors. The comparison is stored as a view: a user-defined operator +/// it binds is recorded in `pg_depend`, and a built-in one, which is pinned and +/// so has no dependency row, is the `opno` in the view's stored query tree. +/// Returns the EQL (non-built-in) operators bound, and whether `jsonb = jsonb` +/// is. +async fn bound_equality(pool: &PgPool, table: &str, term: u8) -> Result<(Vec, bool)> { + let term_json: String = + sqlx::query_scalar(&format!("SELECT ({})::jsonb::text", query_term(term))) + .fetch_one(pool) + .await?; + let view = format!("{table}_v{term}_probe"); + sqlx::raw_sql(&format!( + "CREATE OR REPLACE VIEW {view} AS SELECT * FROM {table} \ + WHERE c = '{}'::eql_v{term}.query_integer_eq", + term_json.replace('\'', "''") + )) + .execute(pool) + .await?; + let eql_operators: Vec = sqlx::query_scalar( + "SELECT d.refobjid::regoperator::text + FROM pg_depend d + JOIN pg_rewrite r ON r.oid = d.objid + WHERE d.classid = 'pg_rewrite'::regclass + AND d.refclassid = 'pg_operator'::regclass + AND r.ev_class = $1::regclass + ORDER BY 1", + ) + .bind(&view) + .fetch_all(pool) + .await?; + let jsonb_eq: bool = sqlx::query_scalar( + "SELECT ev_action::text ~ (':opno ' || '=(jsonb,jsonb)'::regoperator::oid || ' ') + FROM pg_rewrite + WHERE ev_class = $1::regclass", + ) + .bind(&view) + .fetch_one(pool) + .await?; + Ok((eql_operators, jsonb_eq)) +} + fn sqlstate(err: &sqlx::Error) -> Option { match err { sqlx::Error::Database(e) => e.code().map(|c| c.into_owned()), @@ -205,18 +248,33 @@ async fn operator_across_versions_returns_no_rows(pool: PgPool) -> Result<()> { .await?; assert_eq!(n, 0, "{table}.c = v{term} term runs, and finds nothing"); - // Why: no EQL operator takes this pair, so the planner chose the + // Why: no EQL operator takes this pair, so the parser bound the // built-in jsonb equality on the two domains' base type. - let plan: Vec = sqlx::query_scalar(&format!( - "EXPLAIN (COSTS OFF) SELECT * FROM {table} WHERE c = {}", - query_term(term) - )) - .fetch_all(&pool) - .await?; - let plan = plan.join("\n"); + let (eql_operators, jsonb_eq) = bound_equality(&pool, table, term).await?; + assert!( + eql_operators.is_empty(), + "{table}.c = v{term} term should bind no EQL operator, got {eql_operators:?}" + ); + assert!( + jsonb_eq, + "{table}.c = v{term} term should fall back to jsonb = jsonb" + ); + } + + // Control: the same-version comparison binds the EQL operator, so the + // empty list above is a finding, not a probe that sees nothing. + for (table, version) in [("t3", 3), ("t4", 4)] { + let (eql_operators, jsonb_eq) = bound_equality(&pool, table, version).await?; + assert_eq!( + eql_operators, + [format!( + "=(eql_v{version}_integer_eq,eql_v{version}.query_integer_eq)" + )], + "{table}.c = v{version} term" + ); assert!( - plan.contains("(c)::jsonb = "), - "{table}.c = v{term} term should fall back to jsonb = jsonb:\n{plan}" + !jsonb_eq, + "{table}.c = v{version} term does not use jsonb = jsonb" ); }