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/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/mise.toml b/packages/eql/mise.toml index 4eb97643c..e9feeb73d 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,11 +249,23 @@ 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)" +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 c68307f0c..1e87deade 100644 --- a/packages/eql/src/v3/lint/lints.sql +++ b/packages/eql/src/v3/lint/lints.sql @@ -85,7 +85,11 @@ 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. + -- starts_with, not LIKE: the underscores in the prefix are LIKE wildcards. encrypted_domain_types AS ( SELECT dt.oid AS typid @@ -97,7 +101,7 @@ AS $$ AND bt.typname = 'jsonb' AND bn.nspname = 'pg_catalog' AND ( - dn.nspname = 'public' + (dn.nspname = 'public' AND starts_with(dt.typname::text, '{{prefix}}_')) OR dn.nspname = ANY({{prefix}}_internal.owned_schemas()) ) ), diff --git a/packages/eql/tasks/build.sh b/packages/eql/tasks/build.sh index 4d20af73a..df286e9c6 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 @@ -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 @@ -82,9 +84,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 +100,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 +126,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/v4_isolation_selftest.sh b/packages/eql/tasks/test/v4_isolation_selftest.sh new file mode 100755 index 000000000..74e7025ff --- /dev/null +++ b/packages/eql/tasks/test/v4_isolation_selftest.sh @@ -0,0 +1,66 @@ +#!/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 + +# 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');\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" +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). +{ + 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" +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" +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" +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 new file mode 100755 index 000000000..90b545fea --- /dev/null +++ b/packages/eql/tasks/test/verify_no_v3_in_v4.sh @@ -0,0 +1,87 @@ +#!/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 + +# 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 +} + +# 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 + 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 ! find_lines -n 'eql_v4' "$f"; then + echo "ERROR: $f never names eql_v4 — not a v4 render" >&2 + fail=1 + fi + if find_lines -n 'eql_v3' "$f"; then + report "$f" "names eql_v3" "$hits" + fi + # 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 + +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..3e93e86d0 --- /dev/null +++ b/packages/eql/tests/sqlx/tests/v3_v4_coexistence_tests.rs @@ -0,0 +1,324 @@ +//! 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, 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, 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 +//! 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" + ) +} + +/// 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()), + _ => 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"); + + // 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(()) +} + +#[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 parser bound the + // built-in jsonb equality on the two domains' base type. + 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!( + !jsonb_eq, + "{table}.c = v{version} term does not use jsonb = jsonb" + ); + } + + 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(()) +} + +#[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(()) +}