Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eql-lints-own-domains.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions .github/workflows/test-eql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions packages/eql/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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`) renders `eql_v3` names on the fly. A new tool that parses `src/v3` must do one or the other.

Expand Down
11 changes: 11 additions & 0 deletions packages/eql/mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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"]
Expand Down
7 changes: 5 additions & 2 deletions packages/eql/src/v3/lint/lints.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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())
)
),
Expand Down
34 changes: 25 additions & 9 deletions packages/eql/tasks/build.sh
Original file line number Diff line number Diff line change
@@ -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 <version>" help="Specify release version of EQL" default="DEV"

#!/bin/bash
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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<N>/ (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() {
Expand All @@ -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
Expand All @@ -118,14 +126,22 @@ assemble() {
}

assemble 3 release/cipherstash-encrypt.sql release/cipherstash-encrypt-uninstall.sql

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The v4 installer is covered by few of the v3 checks. verify_installer_complete.sh (mise.toml) runs only against cipherstash-encrypt.sql. sync-lockstep-versions.mjs and prepare-bindings-assets.sh only hash and manifest the v3 installer and uninstaller. The symbol-order check is skipped for v4. A truncated or stale v4 bundle could therefore pass every check. Either extend these checks to v4 or state that v4 is internal-only for now.


Generated by Claude Code

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'
40 changes: 40 additions & 0 deletions packages/eql/tasks/test/v4_isolation_selftest.sh
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The negative cases only check that the gate exits non-zero. The gate exits 2 on bad input (missing or empty file, wrong path) and 1 on a real leak. If the gate crashes, every "rejected" case still passes and only the clean case can fail. Assert exit status 1 specifically.


Generated by Claude Code

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"
61 changes: 61 additions & 0 deletions packages/eql/tasks/test/verify_no_v3_in_v4.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/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
}

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 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 hits=$(grep -nE "'v'[[:space:]]*=[[:space:]]*'3'" "$f"); then
report "$f" "checks for payload version 3" "$hits"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The version check matches only the literal 'v' = '3'. Other spellings of a v3 check slip through, for example VALUE->>'v' IN ('3'), '3' = VALUE->>'v' or (VALUE->>'v')::int = 3. A v4 domain would then admit v3 payloads and the gate would stay green. A positive check would be safer: every CHECK on 'v' in the v4 bundle must compare against '4'. Also, grep exit status 2 is currently treated the same as no match.


Generated by Claude Code

fi
done

if [[ $fail -ne 0 ]]; then
echo "v4 isolation gate FAILED" >&2
exit 1
fi
echo "v4 isolation gate OK ($# files)"
Loading
Loading