diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 84feae3c..95e28726 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -1,11 +1,41 @@ -# CI: `test-eql.yml` +# CI workflows + +This directory holds every GitHub Actions workflow for EQL. This README is the +authoritative **inventory** (what workflows exist and when they fire) and +**coverage map** (which job runs which checks, and where each test suite +actually runs). + +- [Workflow inventory](#workflow-inventory) +- [`test-eql.yml` — the merge gate](#test-eqlyml--the-merge-gate) +- [Coverage map: job → task → what it checks](#coverage-map-job--task--what-it-checks) +- [Where each test suite runs](#where-each-test-suite-runs) +- [Known gaps](#known-gaps) + +--- + +## Workflow inventory + +| Workflow | Triggers | What it does | Gates merge? | +|---|---|---|---| +| **test-eql.yml** | `pull_request`, `merge_group`, `workflow_dispatch` | Full test/lint/validate matrix; the one required check | **Yes** — `ci-required` | +| **release-eql.yml** | `release: published`, `pull_request` (paths), `workflow_dispatch` | Build release SQL + docs; PR runs everything **but** the publish step | No | +| **release-postgres-eql-image.yml** | `release: published`, `workflow_dispatch` | Build & push the Postgres+EQL Docker image to GHCR | No | +| **bench-eql.yml** | `push: main` (paths), `schedule` 02:00 UTC daily, `workflow_dispatch` | `test:bench` (bench cargo feature). **Never runs on PRs** | No | +| **macro-expand-eql.yml** | `schedule` 03:00 UTC daily, `workflow_dispatch` | Regenerate the int4 `cargo expand` matrix snapshot; needs pinned nightly | **No — explicitly non-blocking** | +| **rebuild-docs.yml** | `push: tags` | Fire a docs-site rebuild webhook | N/A | + +Only **test-eql.yml** gates merges. Bench regressions and stale `cargo expand` +snapshots surface on the nightly schedule, not on the PR that caused them. + +--- + +## `test-eql.yml` — the merge gate Fast PR feedback + a thorough pre-merge gate, using a merge queue and a single aggregated required check. -## Two run shapes +### Two run shapes -`test-eql.yml` triggers on `pull_request`, `merge_group`, and `workflow_dispatch`. `setup` derives the matrix from the event: | Event | Trigger | Matrix | Purpose | @@ -16,47 +46,120 @@ aggregated required check. The PR run is feedback only. The merge-queue run is the gate. -## Relevance skip applies to PRs only +> **No `push` trigger.** Under a required merge queue, push-to-main validation is +> redundant — the queue already validated the exact merge commit, and branch +> protection blocks direct pushes. + +### Relevance skip applies to PRs only -Each job runs when: +Each heavy job runs when: `merge_group || workflow_dispatch || (pull_request && relevant == 'true')`. -So the `changes` relevance filter (`relevant:` paths) **only gates the -`pull_request` event** — a docs-only PR skips the heavy jobs on its PR run. On -`merge_group` (and `workflow_dispatch`) every job runs **unconditionally**: a -queued PR always pays the full gate regardless of which files it touched. +So the `changes` relevance filter only gates the **`pull_request`** event — a +docs-only PR skips the heavy jobs on its PR run. On `merge_group` (and +`workflow_dispatch`) every job runs **unconditionally**: a queued PR always pays +the full gate regardless of which files it touched. -## How the queue works +The relevance filter (`changes` job) marks a PR relevant when any of these +changed: `.github/workflows/test-eql.yml`, `src/**`, `sql/**`, `tests/**`, +`tasks/**`, `crates/**`, `Cargo.toml`, `Cargo.lock`, `mise.toml`. Note `docs/**` +is **not** in the filter — see [Known gaps](#known-gaps). + +### How the queue works 1. Click **Merge when ready** — the PR is queued, not merged. 2. GitHub builds a temporary branch = `main` + this PR (+ any PRs ahead in the - queue) and fires `merge_group`, so CI tests the **post-merge state**, not the - stale PR branch. + queue) and fires `merge_group`, so CI tests the **post-merge state**. 3. The full PG14–17 × 2 matrix (plus the single-run jobs) runs and feeds `ci-required`. -4. `ci-required` green → the PR is **merged into `main` using the queue's - configured merge method**. Red → the PR is **removed from the queue**; `main` - is untouched. +4. `ci-required` green → PR is **merged**. Red → PR is **removed from the + queue**; `main` is untouched. This catches semantic conflicts — two PRs that each pass alone but break together — which PR-only checks never test. -## The `ci-required` aggregator +### The `ci-required` aggregator -Per-event matrices make leaf job names unstable (a `test` job is displayed as -`Shard PG17 1/4`, but the queue produces `Shard PG14 1/2` … `Shard PG17 2/2`), -so leaf names can't be named as required checks. Instead, one aggregator job -(id and display name `ci-required`) `needs:` every job, runs with -`if: always()`, and passes only if each needed result is `success` **or** -`skipped`. Mark **only `ci-required`** as the required status check. +Per-event matrices make leaf job names unstable (`Shard PG17 1/4` on a PR vs. +`Shard PG14 1/2` in the queue), so leaf names can't be named as required checks. +Instead, one aggregator job (id and display name `ci-required`) `needs:` every +job, runs with `if: always()`, and passes only if each needed result is +`success` **or** `skipped`. Mark **only `ci-required`** as the required status +check. - `if: always()` — runs even when dependencies fail/skip, so the check always reports (a never-reported required check leaves the queue stuck *Pending*). - `skipped` counts as pass — a docs-only PR skips the heavy jobs on its PR run but must still report Success so the PR stays eligible to queue. -This is the well-known "aggregate / final gate job" pattern for matrix + -merge-queue workflows. +--- + +## Coverage map: job → task → what it checks + +All jobs run on `blacksmith-16vcpu-ubuntu-2204`. "PG set" follows the event +(PG17 on PR / dispatch, PG14–17 in the queue). + +| Job | mise task(s) | Checks | DB | `CS_*` creds | +|---|---|---|---|---| +| **changes** | — | compute relevance | no | no | +| **setup** | — | compute PG × shard matrix | no | no | +| **build-archive** | `test:sqlx:archive` | Build EQL, run prep, **generate fixtures**, compile every `tests/sqlx` binary (**default features**) into a nextest archive; upload archive + `release/*.sql` | yes (PG17) | **yes (sole holder)** | +| **test** (sharded) | `test:sqlx:partition` | Run the archived sqlx binaries (default features), hash-partitioned across shards | yes (per PG) | no (replays archive) | +| **e2e** | `test:sqlx:e2e` | The `proptest-e2e` fresh-encryption property suite (`e2e_oracle`) — PG17 only, version-independent | yes (PG17) | **yes** | +| **validate** (per PG) | `docs:validate:documented-sql` + `test:clean_install_v3` | DB-backed SQL doc-syntax check; clean-DB `eql_v3` install smoke | yes | no | +| **docs-static** | `docs:validate:source` | SQL doxygen coverage + required-tags (DB-free); **unconditional — runs on every PR incl. docs-only** | no | no | +| **schema** | `test:schema` | v2.2 / v2.3 payload JSON-schema validation | no | no | +| **rust-crates** | `test:crates` + `types:check` | `cargo fmt --check`, clippy + `cargo test` for `eql-scalars` / `eql-codegen` / `eql-tests-macros` / `eql-types`; verify TS bindings + JSON schemas are fresh | no | no | +| **codegen** | `codegen:parity` | Generated encrypted-domain SQL matches the golden output | no | no | +| **self-contained-v3** | `test:self_contained_v3` | `eql_v3` surface has no `eql_v2` dependency | no | no | +| **matrix-coverage** | `test:matrix:inventory` (+`:jsonb_entry`, `:v3-jsonb`) + `test:matrix:catalog-coverage` | Scalar-matrix test-name snapshots are not silently dropped; catalog surface is covered | no | no | +| **splinter** | `test:splinter` | Supabase/Splinter lints over the installed EQL | yes (PG17) | no | +| **ci-required** | — | aggregator: every needed job is `success`/`skipped` | no | no | + +--- + +## Where each test suite runs + +The `eql_v3` property-test suites (see +`tests/sqlx/tests/encrypted_domain/property/README.md`) land in three different +CI jobs: + +| Suite | Job | Trigger coverage | DB | `CS_*` | Notes | +|---|---|---|---|---|---| +| **catalog** (`eql-scalars` `proptest_invariants`) | **rust-crates** (`cargo test -p eql-scalars`; proptest is a dev-dep) | relevant PR + queue | no | no | pure-Rust catalog invariants; shrinking enabled | +| **fixture** (function-double oracles, extractor identity, `match_smoke`, `edge_cases`) | **test** shards (default features) | relevant PR (PG17×4) + queue (PG14–17×2) | yes | no | oracle over the **committed** real-ciphertext fixtures | +| **e2e** (`e2e_oracle`, `#[cfg(feature = "proptest-e2e")]`) | **e2e** job (`test:sqlx:e2e`) | relevant PR (PG17) + queue (PG17) | yes | yes | oracle over **fresh** ZeroKMS encryption; PG-version-independent, so one PG17 run | + +The wider sqlx suite (everything under `tests/sqlx/tests/`) runs in the **test** +shards, which replay the default-feature archive — so any +`#[cfg(feature = …)]`-gated test that isn't in the default feature set does not +run there. The `proptest-e2e` suite is the one such gate, and it has its own +**e2e** job (it can't reuse the credential-free archive: it both compiles with a +non-default feature and needs `CS_*` at run time). + +--- + +## Known gaps + +1. **bench + macro-expand are nightly / non-blocking** — a bench regression or a + stale `cargo expand` snapshot surfaces on the daily schedule, not on the PR + that introduced it. Accepted trade-off. + +2. **`docs/**` markdown is not content-validated.** The `docs-static` job + guarantees the SQL `--!` doxygen comments under `src/**` are always checked, + but nothing lints the prose/links in `docs/**` itself. A docs-only PR now runs + `docs-static` (so it is no longer un-gated), but that job validates *source* + documentation, not the markdown the PR changed. Adding a markdown + linter/link-checker is a separate, unfilled capability. + +### Recently closed + +- *The e2e (fresh-encryption) suite never ran in CI.* Now covered by the **e2e** + job (`test:sqlx:e2e`), PG17, on relevant PRs + the queue. +- *Docs-only PRs ran no doc validation.* The **docs-static** job now runs the + source-only doc checks unconditionally on every PR. + +--- ## Operator setup (one-time, GitHub UI) @@ -68,13 +171,13 @@ Settings → Branches → rule for `main`: Then verify (see `docs/plans/2026-06-09-ci-pr-feedback-sharding-rollout.md`): -- **Queue a relevant PR** → `merge_group` runs the full gate — 8 `Shard …` jobs - + 4 `Validate …` jobs + `build-archive`, `schema`, `rust-crates`, `codegen`, - `self-contained-v3`, `matrix-coverage`, `splinter` — all green → `ci-required` - green → PR merges. -- **Open a docs-only PR** → on its `pull_request` run the heavy jobs skip and - `ci-required` reports **Success** (not stuck *Pending*), so the PR can be - queued. +- **Queue a relevant PR** → `merge_group` runs the full gate (8 `Shard …` jobs + + 4 `Validate …` jobs + `build-archive`, `e2e`, `docs-static`, `schema`, + `rust-crates`, `codegen`, `self-contained-v3`, `matrix-coverage`, `splinter`) → + `ci-required` green → PR merges. +- **Open a docs-only PR** → on its `pull_request` run the relevance-gated heavy + jobs skip, but `docs-static` still runs; `ci-required` reports **Success** (not + stuck *Pending*), so the PR can be queued. ## References diff --git a/.github/workflows/test-eql.yml b/.github/workflows/test-eql.yml index 5e6a40c8..4150ba6e 100644 --- a/.github/workflows/test-eql.yml +++ b/.github/workflows/test-eql.yml @@ -268,9 +268,13 @@ jobs: run: | mise run postgres:up postgres-${POSTGRES_VERSION} --extra-args "--detach --wait" - - name: Validate SQL documentation (Postgres ${{ matrix.postgres-version }}) + # Source-only doc checks (coverage + required-tags) moved to the + # unconditional `docs-static` job so they run on every PR (incl. docs-only) + # and exactly once, not per-Postgres. This step keeps only the DB-backed + # SQL-syntax validation, which genuinely needs the per-version Postgres. + - name: Validate documented SQL syntax (Postgres ${{ matrix.postgres-version }}) run: | - mise run docs:validate + mise run docs:validate:documented-sql - name: Clean-DB v3 install smoke (Postgres ${{ matrix.postgres-version }}) run: | @@ -460,6 +464,75 @@ jobs: run: | mise run --output prefix test:splinter --postgres ${POSTGRES_VERSION} + # Source-only SQL documentation validation (coverage + required Doxygen tags). + # Deliberately NOT relevance-gated: it runs on EVERY pull_request — including + # docs-only PRs that skip the heavy jobs — so documentation is always + # validated. DB-free and creds-free (the psql-backed syntax check stays in the + # per-version `validate` job). + docs-static: + name: "SQL doc validation" + runs-on: blacksmith-16vcpu-ubuntu-2204 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + cache: true + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: . + shared-key: sqlx-tests + save-if: false + - name: Validate SQL doc coverage + required tags + run: | + mise run docs:validate:source + + # The e2e (fresh-encryption) property suite. Encrypts random values through + # ZeroKMS at run time, so it needs CS_* creds and is PG-version-independent — + # one PG17 run, never the matrix. Compiles the `proptest-e2e`-gated binaries + # (which the default-feature sharded archive excludes) and runs only the + # e2e oracle. Like build-archive, it holds CS_* and so carries the same + # fork-PR guard to keep the secrets off fork runs. + e2e: + name: "e2e property suite (fresh encryption)" + needs: [changes, setup] + if: >- + (github.event_name == 'merge_group' + || github.event_name == 'workflow_dispatch' + || (github.event_name == 'pull_request' && needs.changes.outputs.relevant == 'true')) + && (github.event_name != 'pull_request' + || github.event.pull_request.head.repo.full_name == github.repository) + runs-on: blacksmith-16vcpu-ubuntu-2204 + env: + POSTGRES_VERSION: "17" + CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }} + CS_WORKSPACE_CRN: ${{ secrets.CS_WORKSPACE_CRN }} + CS_CLIENT_ID: ${{ secrets.CS_CLIENT_ID }} + CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + cache: true + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: . + shared-key: sqlx-tests + save-if: false + - name: Setup database (Postgres 17) + run: | + mise run postgres:up postgres-${POSTGRES_VERSION} --extra-args "--detach --wait" + - name: Run e2e property suite + run: | + mise run test:sqlx:e2e + # The ONE required status check. Stable name on every event, so branch # protection never references an event-dependent leaf name (which would # deadlock). Passes iff every needed job is success or skipped. Treating @@ -469,7 +542,7 @@ jobs: ci-required: name: "ci-required" needs: [changes, setup, build-archive, test, validate, schema, rust-crates, - codegen, self-contained-v3, matrix-coverage, splinter] + codegen, self-contained-v3, matrix-coverage, splinter, docs-static, e2e] if: always() runs-on: blacksmith-16vcpu-ubuntu-2204 steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 56cb547a..7d04e1cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,11 +33,11 @@ Each entry that ships in a published release links to the PR that introduced it. - **`eql_v3.text` encrypted-domain family (`text`, `text_eq`, `text_match`, `text_ord`, `text_ord_ore`, `text_search`).** Adds equality (`=` / `<>` via HMAC), match (`@>` / `<@` via a new self-contained `eql_v3.bloom_filter` SEM index term), and ORE ordering (`<` `<=` `>` `>=`, `min` / `max`) for encrypted text, at parity with EQL v2 text — generated from the `text` row in `eql-scalars::CATALOG` by the same materializer as the `eql_v3.int4` reference. `text` is the first scalar to add a new index `Term` (`Bloom`) and the first non-integer, unbounded ordered kind (lexicographic pivots, hand-written `impl ScalarType`). The combined **`text_search`** domain carries all three capabilities in one type — `=` / `<>` via HMAC, `<` `<=` `>` `>=` / `min` / `max` via ORE, and `@>` / `<@` via bloom filter. Index via a functional index on the `eql_v3.eq_term` / `eql_v3.ord_term` / `eql_v3.match_term` extractors, not an operator class on the domain. Why: brings searchable encrypted text to the namespaced, `eql_v2`-free `eql_v3` surface. Match is exposed as bloom-filter containment on the `text_match` / `text_search` domains — deliberately *not* SQL `LIKE` (no wildcard/anchoring; probabilistic ngram containment) — and never backs equality. **Equality on the ordered text domains (`text_ord`, `text_ord_ore`) and on `text_search` always routes `=` / `<>` through `hm` (exact HMAC), never the ORE term — ORE is not exact-equality for text** (integer ordered domains keep exact ORE equality, which is lossless for them). ([#260](https://github.com/cipherstash/encrypt-query-language/pull/260)) - **Self-contained `eql_v3` schema + standalone `release/cipherstash-encrypt-v3.sql` installer.** The `eql_v3` encrypted-domain surface no longer depends on `eql_v2` at runtime: it now owns its own copies of the searchable-encrypted-metadata (SEM) index-term types — `eql_v3.hmac_256` and `eql_v3.ore_block_256` (with its btree operator class) — so the `eql_v3.eq_term` / `eql_v3.ord_term` extractors return `eql_v3` types and no `eql_v2.` appears anywhere in the v3 SQL. The whole v3 surface relocated under a single `src/v3/` tree (`src/v3/sem/` for the hand-written SEM types, `src/v3/scalars/` for the generated domain families). A new build variant ships the `eql_v3` schema on its own as `release/cipherstash-encrypt-v3.sql`, installable into a database with no `eql_v2` present; a CI gate greps that artifact and its dependency closure to keep it `eql_v2`-free. Why: a clean foundation for the per-scalar encrypted-domain model to stand alone, ahead of it replacing the `eql_v2_encrypted` composite column type. This is additive — a new schema and a new artifact — and leaves `eql_v2` byte-for-byte unchanged. ([#255](https://github.com/cipherstash/encrypt-query-language/pull/255)) - **`eql_v3.text` encrypted-domain family (`text`, `text_eq`, `text_match`, `text_ord`, `text_ord_ore`).** Adds equality (`=` / `<>` via HMAC), match (`@>` / `<@` via a new self-contained `eql_v3.bloom_filter` SEM index term), and ORE ordering (`<` `<=` `>` `>=`, `min` / `max`) for encrypted text, at parity with EQL v2 text — generated from the `text` row in `eql-scalars::CATALOG` by the same materializer as the `eql_v3.int4` reference. `text` is the first scalar to add a new index `Term` (`Bloom`) and the first non-integer, unbounded ordered kind (lexicographic pivots, hand-written `impl ScalarType`). Index via a functional index on the `eql_v3.eq_term` / `eql_v3.ord_term` / `eql_v3.match_term` extractors, not an operator class on the domain. Why: brings searchable encrypted text to the namespaced, `eql_v2`-free `eql_v3` surface. Match is exposed as bloom-filter containment on the `text_match` domain — deliberately *not* SQL `LIKE` (no wildcard/anchoring; probabilistic ngram containment) — and never backs equality (which always routes through `Hm`). ([#260](https://github.com/cipherstash/encrypt-query-language/pull/260)) -- **Property-based tests for the `eql_v3` encrypted scalar domains.** A harness of three suites asserts SQL operator results agree with a plaintext oracle across a generated input space: a pure-Rust **catalog** suite (no database) over the term/scalar catalog, a **fixture** suite that samples the committed fixture corpus (real ciphertext) and checks all ordered pairs in each sampled corpus, and an **e2e** suite (gated behind the `proptest-e2e` cargo feature) that batch-encrypts freshly generated plaintexts end-to-end through ZeroKMS each run. Covers the equality (`=`/`<>`) and ordering (`<`/`<=`/`>`/`>=`, `ord_term` sort order) oracles plus NULL/blocker/CHECK edge cases. Why: the prior matrix exercised fixed pivots only; property tests catch operator/oracle disagreements across the whole value space. ([#275](https://github.com/cipherstash/encrypt-query-language/pull/275)) +- **Property-based tests for the `eql_v3` encrypted scalar domains.** A harness of three suites asserts SQL operator results agree with a plaintext oracle across a generated input space: a pure-Rust **catalog** suite (no database) over the term/scalar catalog, a **fixture** suite that runs an all-pairs oracle over the committed real-ciphertext fixtures (the curated catalog values per type), and an **e2e** suite (gated behind the `proptest-e2e` cargo feature) that batch-encrypts freshly generated plaintexts end-to-end through ZeroKMS each run. Beyond the operator oracles, the fixture suite drives **function-double** oracles — the generated `eql_v3.eq`/`neq`/`lt`/`lte`/`gt`/`gte` functions across all three overloads (domain–domain, domain–jsonb, jsonb–domain) — plus **term-extractor identity** (`eq_term`==`hm`, `ord_term`==`ob`) and an example-based bloom **match** smoke for the text `_match` domain. Covers the equality (`=`/`<>`) and ordering (`<`/`<=`/`>`/`>=`, `ord_term` sort order) operator and function oracles plus NULL/blocker/CHECK edge cases, across every fixtured scalar (`int2`/`int4`/`int8`/`date`/`timestamptz`/`numeric`/`text`). The e2e suite — which appends fresh duplicate plaintexts each run — is the one that exercises equality across two independent encryptions of one value. Why: the prior matrix exercised fixed pivots only; property tests over the whole fixture set catch operator/oracle disagreements across the value space, and the e2e suite adds defence in depth by re-encrypting every run rather than pinning a frozen ciphertext snapshot. ([#293](https://github.com/cipherstash/encrypt-query-language/pull/293)) - **Self-contained `eql_v3` schema + standalone `release/cipherstash-encrypt-v3.sql` installer.** The `eql_v3` encrypted-domain surface no longer depends on `eql_v2` at runtime: it now owns its own copies of the searchable-encrypted-metadata (SEM) index-term types — `eql_v3.hmac_256` and `eql_v3.ore_block_u64_8_256` (with its btree operator class) — so the `eql_v3.eq_term` / `eql_v3.ord_term` extractors return `eql_v3` types and no `eql_v2.` appears anywhere in the v3 SQL. The whole v3 surface relocated under a single `src/v3/` tree (`src/v3/sem/` for the hand-written SEM types, `src/v3/scalars/` for the generated domain families). A new build variant ships the `eql_v3` schema on its own as `release/cipherstash-encrypt-v3.sql`, installable into a database with no `eql_v2` present; a CI gate greps that artifact and its dependency closure to keep it `eql_v2`-free. Why: a clean foundation for the per-scalar encrypted-domain model to stand alone, ahead of it replacing the `eql_v2_encrypted` composite column type. This is additive — a new schema and a new artifact — and leaves `eql_v2` byte-for-byte unchanged. ([#255](https://github.com/cipherstash/encrypt-query-language/pull/255)) - **`eql_v3.min` / `eql_v3.max` aggregates over `eql_v3.ste_vec_entry`.** SteVec document entries extracted at a selector (`doc -> 'sel'`) can now be aggregated like ordered scalars: `eql_v3.min(doc -> 'sel')` / `eql_v3.max(...)` return the entry with the smallest / largest ordered leaf. Ordering routes through the entry's `oc` (CLLW ORE) term via `eql_v3.ore_cllw` — the same comparator the entry `<` / `<=` / `>` / `>=` operators use, not the scalar Block-ORE `ord_term`. Only `oc`-carrying entries are orderable: an entry without an `oc` term (`eql_v3.ore_cllw` returns NULL) is non-orderable and is ignored by the aggregate — the same way the `eql_v3.ore_cllw` btree NULL-filters such rows — so a mix of `oc`-carrying and `oc`-less entries yields the extremum of the orderable subset rather than a corrupted result. Declared `PARALLEL = SAFE` with a combine function (the state function itself), so partial / parallel aggregation is available on large `GROUP BY` workloads. Why: brings encrypted-JSONB entry ordering to parity with the scalar encrypted-domain families' `MIN` / `MAX`, and lets the shared scalar behaviour matrix cover entry aggregation. Additive — the document and entry comparison surface is otherwise unchanged. ([#267](https://github.com/cipherstash/encrypt-query-language/pull/267)) - **`eql_v3.bool` encrypted-domain type family (storage-only / encryption-only).** A single jsonb-backed domain for encrypted `bool` columns — `eql_v3.bool` — generated from the `bool` row in `eql-scalars::CATALOG`. Unlike every other scalar family, `bool` is **encryption-only**: it carries no SEM index term and exposes **no** `_eq` / `_ord` domains, so the value is encrypted at rest and decrypted by the proxy but is **not searchable server-side**. This is deliberate — a two-value column has so little cardinality that any searchable index (even HMAC equality) would trivially leak the plaintext distribution. Every comparison / containment / path operator reachable through domain fallback (`=`, `<>`, `<`, `<=`, `>`, `>=`, `@>`, `<@`, `->`, `->>`, …) is blocked (raises rather than silently routing to plaintext-`jsonb` semantics); the domain `CHECK` still requires the EQL envelope (`v`, `i`), the ciphertext (`c`), and pins the payload version (`VALUE->>'v' = '2'`). The encrypted payload is `{v,i,c}` only — no `hm` / `ob` / `bf` term. Why: lets callers encrypt a low-cardinality boolean column at rest without offering a server-side search surface that would leak it; the first **storage-only** member of the generated scalar encrypted-domain family. ([#295](https://github.com/cipherstash/encrypt-query-language/pull/295)) -- **`eql_v3.float4` / `eql_v3.float8` encrypted-domain type families (ordered).** Four jsonb-backed domains each for encrypted `real` / `double precision` columns — `eql_v3.float4` / `eql_v3.float8` (storage-only), `eql_v3._eq` (`=` / `<>` via HMAC), and `eql_v3._ord` / `eql_v3._ord_ore` (also `<` `<=` `>` `>=`, `MIN` / `MAX` via 8-block ORE) — generated from the `float4` / `float8` rows in `eql-scalars::CATALOG` by the same materializer as the `eql_v3.int4` reference. Both widths encrypt through a single f64 crypto path (`Plaintext::Float`): a `real` is widened to f64 before encryption (exact and monotonic), so `float4` vs `float8` is purely a Postgres-surface distinction and the ciphertext / ORE term are byte-identical. Ordering is correct for all non-NaN values via the standard monotonic IEEE-754 byte mapping (`f64::ENCODED_LEN == 8`, same as `int8`); `-0.0` canonicalizes to `+0.0` and `±Inf` order correctly. NaN is unordered and unspecified in the encoder — it can be encrypted and stored but is not given a meaningful comparison guarantee (any NaN rejection is client-side). Index via a functional index on the `eql_v3.eq_term` / `eql_v3.ord_term` extractors, not an operator class on the domain. Why: a type-safe, per-capability encrypted IEEE-754 float column, closing the gap for `real` / `double` columns that had no v3 equivalent (the v3 `numeric` family is arbitrary-precision decimal, not binary float). ([#299](https://github.com/cipherstash/encrypt-query-language/pull/299)) +- **`eql_v3.float4` / `eql_v3.float8` encrypted-domain type families (ordered).** Four jsonb-backed domains each for encrypted `real` / `double precision` columns — `eql_v3.float4` / `eql_v3.float8` (storage-only), `eql_v3._eq` (`=` / `<>` via HMAC), and `eql_v3._ord` / `eql_v3._ord_ore` (also `<` `<=` `>` `>=`, `MIN` / `MAX` via 8-block ORE) — generated from the `float4` / `float8` rows in `eql-scalars::CATALOG` by the same materializer as the `eql_v3.int4` reference. Both widths encrypt through a single f64 crypto path (`Plaintext::Float`): a `real` is widened to f64 before encryption (exact and monotonic), so `float4` vs `float8` is purely a Postgres-surface distinction — the `hm` equality term is byte-identical and the ORE terms compare equal under the `eql_v3.ore_block_256` operator (the ORE term itself is probabilistic — a fresh per-ciphertext nonce — so it is never byte-identical, even same-width; ordering is decided by the ORE comparator, not by raw bytes). Ordering is correct for all non-NaN values via the standard monotonic IEEE-754 byte mapping (`f64::ENCODED_LEN == 8`, same as `int8`); `-0.0` canonicalizes to `+0.0` and `±Inf` order correctly. NaN is unordered and unspecified in the encoder — it can be encrypted and stored but is not given a meaningful comparison guarantee (any NaN rejection is client-side). Index via a functional index on the `eql_v3.eq_term` / `eql_v3.ord_term` extractors, not an operator class on the domain. Why: a type-safe, per-capability encrypted IEEE-754 float column, closing the gap for `real` / `double` columns that had no v3 equivalent (the v3 `numeric` family is arbitrary-precision decimal, not binary float). ([#299](https://github.com/cipherstash/encrypt-query-language/pull/299)) ### Changed diff --git a/crates/eql-tests-macros/src/lib.rs b/crates/eql-tests-macros/src/lib.rs index f0a5edc9..a2cf480a 100644 --- a/crates/eql-tests-macros/src/lib.rs +++ b/crates/eql-tests-macros/src/lib.rs @@ -288,6 +288,8 @@ fn fixture_dispatch_tokens(list: &ScalarList) -> TokenStream2 { let arms = list.entries.iter().map(|e| { let token_str = e.token.to_string(); let mod_ident = format_ident!("eql_v2_{}", e.token); + // Every scalar fixture is generated from its fixed curated catalog + // values via `run()`. quote! { #token_str => ::eql_tests::fixtures::#mod_ident::spec().run().await, } @@ -690,6 +692,20 @@ mod tests { assert!(suites.contains("caps = [storage]")); let dispatch = norm(&fixture_dispatch_tokens(&list)); assert!(dispatch.contains(r#""bool" =>"#)); + // Every scalar fixture is generated from its fixed curated catalog + // values via `run()`. + assert!( + dispatch.contains( + r#""bool" => :: eql_tests :: fixtures :: eql_v2_bool :: spec () . run () . await"# + ), + "bool must dispatch to run(), got: {dispatch}" + ); + assert!( + dispatch.contains( + r#""int4" => :: eql_tests :: fixtures :: eql_v2_int4 :: spec () . run () . await"# + ), + "int4 must dispatch to run(), got: {dispatch}" + ); } #[test] diff --git a/mise.toml b/mise.toml index e046912e..3b5600c0 100644 --- a/mise.toml +++ b/mise.toml @@ -89,6 +89,22 @@ echo "Running Rust tests..." cargo test --features proptest-e2e """ +[tasks."test:sqlx:e2e"] +description = "Run ONLY the e2e (fresh-encryption) property suite — needs ZeroKMS creds" +# Prep builds + migrates + regenerates fixtures (the latter needs CS_* creds, +# which the dedicated CI `e2e` job supplies). The e2e suite is the only one that +# encrypts fresh values through ZeroKMS at run time, so it cannot run from the +# credential-free sharded archive; it gets its own job. The fixture suite (which +# DOES run in the shards) is intentionally excluded here via the `e2e_oracle` +# filter so this job does not duplicate sharded work — it only compiles the +# `proptest-e2e`-gated binaries and runs the fresh-encryption oracle. +depends = ["test:sqlx:prep"] +dir = "{{config_root}}/tests/sqlx" +run = """ +echo "Running e2e property suite (fresh ZeroKMS encryption)..." +cargo test --features proptest-e2e e2e_oracle +""" + [tasks."test:sqlx:watch"] description = "Run SQLx tests in watch mode (rebuild EQL on changes)" # Same prep as test:sqlx so watch mode starts from a migrated DB + fresh diff --git a/tasks/docs/validate/source.sh b/tasks/docs/validate/source.sh new file mode 100755 index 00000000..5829a7f6 --- /dev/null +++ b/tasks/docs/validate/source.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +#MISE description="Source-only SQL doc validation (coverage + required tags, no DB)" +# Build first so generated encrypted-domain SQL exists under src/. +#MISE depends=["build"] +# +# This is the DB-free subset of `docs:validate`: coverage + required-tags read +# the `--!` doxygen comments out of src/**/*.sql and need no Postgres. It exists +# so CI can validate documentation on EVERY PR (including docs-only PRs that skip +# the heavy, relevance-gated jobs) without standing up a database. The +# `documented-sql` syntax check (which needs psql) stays in the per-Postgres +# `validate` job. + +set -e + +echo +echo "Checking documentation coverage..." +mise run --output prefix docs:validate:coverage + +echo +echo "Validating required tags..." +mise run --output prefix docs:validate:required-tags diff --git a/tests/sqlx/README.md b/tests/sqlx/README.md index 6cb40350..346a7dd5 100644 --- a/tests/sqlx/README.md +++ b/tests/sqlx/README.md @@ -277,8 +277,10 @@ Tests connect to PostgreSQL database configured by SQLx: - ✅ ~~Convert remaining SQL tests~~ **COMPLETE!** - Property-based tests: implemented in `tests/encrypted_domain/property/` and `crates/eql-scalars/src/proptest_invariants.rs` (CIP-3141). One unit-level - **catalog** suite (no DB) plus two integration suites — **fixture** (oracle - over the committed fixture corpus) and **e2e** (oracle over fresh end-to-end - encryption, `--features proptest-e2e`). + **catalog** suite (no DB) plus two integration suites — **fixture** (operator + + function-double oracles, term-extractor identity, and bloom match smoke over the + committed real-ciphertext fixtures) and **e2e** (oracle over fresh end-to-end + encryption each run, `--features proptest-e2e`). See + `tests/encrypted_domain/property/README.md` for the full structure. - Performance benchmarks: Measure query performance with encrypted data - Integration tests: Test with CipherStash Proxy diff --git a/tests/sqlx/src/fixtures/driver.rs b/tests/sqlx/src/fixtures/driver.rs index 766b712e..bff0995b 100644 --- a/tests/sqlx/src/fixtures/driver.rs +++ b/tests/sqlx/src/fixtures/driver.rs @@ -120,31 +120,49 @@ where /// working table unconditionally once it has been created, and /// propagate failures in causal order (insert error first). pub async fn run(&self) -> Result<()> { - let config = DriverConfig::from_env()?; + let mut direct = self.connect().await?; + // Encrypt exactly the spec's curated values. + let result = self.render_values(&mut direct, self.values()).await; + let _ = direct.close().await; + let lines = result?; + self.write_script(None, &lines, self.values().len()) + } - let mut direct = config + /// Open the single direct Postgres connection the pipeline uses for + /// schema / insert / render / drop. Encryption happens in Rust + /// (cipherstash-client), so there is no second connection. + async fn connect(&self) -> Result { + let config = DriverConfig::from_env()?; + config .direct .clone() .connect() .await - .context("connecting to Postgres (direct)")?; + .context("connecting to Postgres (direct)") + } + /// The shared generation pipeline: apply the working schema, encrypt + insert + /// `values`, render the committed INSERT lines, then drop the working table. + /// + /// Honours the teardown contract: once the working table exists it is dropped + /// unconditionally (success *or* error), and failures propagate in causal + /// order — insert error first (root cause), then render, then drop. Returns + /// the rendered INSERT lines in `id` order. + async fn render_values(&self, direct: &mut PgConnection, values: &[T]) -> Result> { self.check_complete().context("invalid FixtureSpec")?; sqlx::raw_sql(&self.working_schema_sql()) - .execute(&mut direct) + .execute(&mut *direct) .await .context("applying working-table schema")?; - // Insert directly on the same connection used for schema/render/drop. - // The earlier two-connection design existed because `run_with` borrows - // `direct` mutably across the closure call; production has no such - // need — `insert_direct` is the only caller of cipherstash-client and - // can hold the same `&mut direct` for its duration. - let insert_result = self.insert_direct(&mut direct).await; + // Insert on the same connection used for schema/render/drop. `run_with`'s + // two-connection shape exists only for the test seam; production holds a + // single `&mut direct` for the whole pipeline. + let insert_result = self.insert_values(&mut *direct, values).await; let render_result = if insert_result.is_ok() { sqlx::query(&self.render_rows_sql()) - .fetch_all(&mut direct) + .fetch_all(&mut *direct) .await .context("rendering fixture rows") } else { @@ -153,22 +171,31 @@ where let working = self.working_table(); let drop_result = sqlx::raw_sql(&format!("DROP TABLE IF EXISTS public.{working};")) - .execute(&mut direct) + .execute(&mut *direct) .await; insert_result?; let rows = render_result?; drop_result.context("dropping the working table")?; - let lines: Vec = rows - .iter() + rows.iter() .map(|r| r.try_get::(0).context("reading rendered INSERT")) - .collect::>()?; - - let _ = direct.close().await; + .collect() + } + /// Compose the committed script (preamble + optional extra header + the + /// rendered INSERT lines) and write it to `tests/sqlx/fixtures/.sql`. + fn write_script( + &self, + extra_header: Option<&str>, + lines: &[String], + row_count: usize, + ) -> Result<()> { let mut script = self.fixture_script_preamble(); - for line in &lines { + if let Some(header) = extra_header { + script.push_str(header); + } + for line in lines { script.push_str(line); script.push('\n'); } @@ -176,40 +203,37 @@ where let path = fixture_script_path(&self.script_filename()); std::fs::write(&path, script) .with_context(|| format!("writing fixture script {}", path.display()))?; - println!("wrote {} ({} rows)", path.display(), self.values().len()); + println!("wrote {} ({} rows)", path.display(), row_count); Ok(()) } - /// Encrypt every plaintext value via cipherstash-client in **one - /// batched call**, then INSERT each ciphertext into the working - /// table as plain JSONB. The committed `ColumnConfig` is built once - /// from the spec's indexes + cast — the fixture name is fed as the - /// table identifier so the resulting payload's `i.t` field matches - /// the working table, preserving the shape Proxy used to emit. + /// Encrypt every value in `values` via cipherstash-client in **one batched + /// call**, then INSERT each ciphertext into the working table as plain JSONB. + /// The committed `ColumnConfig` is built once from the spec's indexes + cast + /// — the fixture name is fed as the table identifier so the resulting + /// payload's `i.t` field matches the working table, preserving the shape + /// Proxy used to emit. /// - /// Batching means one ZeroKMS round trip per fixture run regardless - /// of value count; the INSERT loop is per-row because the working - /// table is local Postgres and the per-row execute cost is in - /// microseconds. - async fn insert_direct(&self, direct: &mut PgConnection) -> Result<()> { + /// Batching means one ZeroKMS round trip per run regardless of value count; + /// the INSERT loop is per-row because the working table is local Postgres and + /// the per-row execute cost is in microseconds. A repeated plaintext in + /// `values` is encrypted independently here, so a repeated plaintext lands as + /// a distinct ciphertext row sharing that plaintext. + async fn insert_values(&self, direct: &mut PgConnection, values: &[T]) -> Result<()> { let config = cipherstash::column_config_for(self.indexes(), T::CAST) .context("building ColumnConfig from FixtureSpec indexes")?; let working = self.working_table(); - let payloads = cipherstash::encrypt_store( - &working, - cipherstash::PAYLOAD_COLUMN, - self.values(), - &config, - ) - .await - .context("encrypting fixture values")?; + let payloads = + cipherstash::encrypt_store(&working, cipherstash::PAYLOAD_COLUMN, values, &config) + .await + .context("encrypting fixture values")?; let insert = format!( "INSERT INTO public.{working} (id, plaintext, {col}) VALUES ($1, $2, $3)", col = cipherstash::PAYLOAD_COLUMN ); - for (i, (value, payload)) in self.values().iter().zip(payloads).enumerate() { + for (i, (value, payload)) in values.iter().zip(payloads).enumerate() { let id = (i as i64) + 1; sqlx::query(&insert) .bind(id) diff --git a/tests/sqlx/src/fixtures/scalar_fixture.rs b/tests/sqlx/src/fixtures/scalar_fixture.rs index 788b54fa..29ced09a 100644 --- a/tests/sqlx/src/fixtures/scalar_fixture.rs +++ b/tests/sqlx/src/fixtures/scalar_fixture.rs @@ -271,7 +271,8 @@ macro_rules! scalar_fixture { /// The generator. Gated by `fixture-gen` so `cargo test` never compiles /// it; `#[ignore]` is a second guard. Run via - /// `mise run fixture:generate`. + /// `mise run fixture:generate`. Generates the fixed curated catalog + /// values via `run()`. #[cfg(feature = "fixture-gen")] #[tokio::test] #[ignore = "generator — run via `mise run fixture:generate`"] diff --git a/tests/sqlx/src/property.rs b/tests/sqlx/src/property.rs index a3603294..cf128640 100644 --- a/tests/sqlx/src/property.rs +++ b/tests/sqlx/src/property.rs @@ -1,12 +1,18 @@ //! Shared substrate for the encrypted-domain property tests (CIP-3141). //! -//! `assert_eq_oracle` / `assert_ord_oracle` take a corpus of +//! `assert_eq_oracle` / `assert_ord_oracle` take a set of //! `(plaintext, payload_json)` rows and check SQL operator results against the //! plaintext oracle over every ordered pair. The fixture suite feeds them rows -//! read from the committed fixture corpus (real ciphertext); the e2e suite feeds +//! read from the committed fixtures (real ciphertext); the e2e suite feeds //! them rows it batch-encrypts from freshly generated plaintexts. The engine is //! identical for both. //! +//! The `*_fn_oracle` helpers complement the operator oracles by calling the +//! generated `eql_v3.*` comparison **functions** by name across all three +//! [`Overload`]s, and `assert_extractor_oracle` checks term-extractor identity +//! (`eq_term` == payload `hm`, `ord_term` == payload `ob`). `assert_match_smoke` +//! is the example-based bloom-containment check for the text `_match` domain. +//! //! Operator evaluation is read-only (`SELECT op `); the fixture suite //! runs each property under `#[sqlx::test]` (its own migrated scratch DB), while //! the e2e suite (single-process, feature-gated) uses a shared pool brought up @@ -14,7 +20,8 @@ use crate::scalar_domains::{ScalarDomainSpec, ScalarType, Variant}; use anyhow::{Context, Result}; -use sqlx::PgPool; +use eql_scalars::Term; +use sqlx::{PgPool, Row as _}; /// Apply the SQLx migrations (the EQL install in `001_install_eql.sql`, plus the /// regression-data migrations) to the DB behind `pool`. @@ -41,7 +48,7 @@ pub async fn ensure_eql_installed(pool: &PgPool, migrator: &sqlx::migrate::Migra Ok(()) } -/// A single corpus entry: a plaintext and its EQL payload rendered as a JSON +/// A single fixture row: a plaintext and its EQL payload rendered as a JSON /// text literal (the `payload::text` form `fetch_fixture_payload` returns, or /// `serde_json::Value::to_string()` for a freshly encrypted value). #[derive(Clone)] @@ -64,6 +71,13 @@ fn cast(payload_json: &str, domain: &str) -> String { format!("'{}'::jsonb::{}", payload_json.replace('\'', "''"), domain) } +/// Render a JSON text literal as a bare `jsonb` value: `''::jsonb`. The +/// jsonb-side operand for the `(domain, jsonb)` / `(jsonb, domain)` overloads, +/// where the generated comparison function casts the raw jsonb itself. +fn jsonb(payload_json: &str) -> String { + format!("'{}'::jsonb", payload_json.replace('\'', "''")) +} + /// Equality oracle: for every ordered pair `(a, b)` in `rows`, /// `a = b` (SQL, on the `_eq` domain) ⇔ `a.plaintext == b.plaintext`, and /// `a <> b` is its negation. @@ -149,6 +163,335 @@ pub async fn assert_ord_oracle( Ok(()) } +/// The three generated overloads of every binary comparison / containment +/// function: both operands cast to the domain, or one side left as raw `jsonb` +/// for the function to cast. Exercising all three covers the overload set — in +/// particular the jsonb-cast convenience paths the `_eq` / `_ord` operator +/// oracles never reach (they always cast both operands). +#[derive(Clone, Copy, Debug)] +pub enum Overload { + DomainDomain, + DomainJsonb, + JsonbDomain, +} + +impl Overload { + /// All three overloads, for the per-pair fan-out. + pub const ALL: [Overload; 3] = [ + Overload::DomainDomain, + Overload::DomainJsonb, + Overload::JsonbDomain, + ]; + + /// The `(left, right)` operand SQL expressions for JSON literals `la`/`lb`, + /// casting the domain side via [`cast`] and leaving the jsonb side bare. + fn operands(self, la: &str, lb: &str, domain: &str) -> (String, String) { + match self { + Overload::DomainDomain => (cast(la, domain), cast(lb, domain)), + Overload::DomainJsonb => (cast(la, domain), jsonb(lb)), + Overload::JsonbDomain => (jsonb(la), cast(lb, domain)), + } + } +} + +/// Shared all-pairs driver for the **named-function** oracles. For every +/// ordered pair `(a, b)` in `rows`, emit a single `SELECT` whose columns are +/// `eql_v3.(...)` for every `func` in `funcs` across every +/// [`Overload`] (a 6-column query for the two eq functions, 12 for the four +/// ord functions), then assert each column against `expected(a, b, func)`. +/// Collapsing each pair to one round trip keeps the only cost this family adds +/// (`SELECT` volume) in check; ZeroKMS cost is unchanged (the rows are already +/// encrypted). Complements — does not replace — the operator oracles. +async fn assert_named_fns( + pool: &PgPool, + domain: &str, + rows: &[Row], + funcs: &[&str], + expected: F, +) -> Result<()> +where + T: ScalarType, + F: Fn(&T, &T, &str) -> bool, +{ + for a in rows { + for b in rows { + // One column per (overload, func); `meta` records the expected bool + // alongside its label so a mismatch reports which overload/func failed. + let mut exprs: Vec = Vec::new(); + let mut meta: Vec<(Overload, &str, bool)> = Vec::new(); + for &overload in &Overload::ALL { + let (l, r) = overload.operands(&a.payload_json, &b.payload_json, domain); + for &func in funcs { + exprs.push(format!("eql_v3.{func}({l}, {r})")); + meta.push((overload, func, expected(&a.plaintext, &b.plaintext, func))); + } + } + let sql = format!("SELECT {}", exprs.join(", ")); + let row = sqlx::query(&sql) + .fetch_one(pool) + .await + .with_context(|| format!("fn-oracle pair query: {sql}"))?; + for (i, (overload, func, want)) in meta.iter().enumerate() { + let got: Option = row + .try_get(i) + .with_context(|| format!("reading column {i} of: {sql}"))?; + anyhow::ensure!( + got == Some(*want), + "fn eql_v3.{func} on {domain} ({overload:?}): plaintext {:?} vs {:?} \ + expected {want}, SQL returned {got:?}", + a.plaintext, + b.plaintext, + ); + } + } + } + Ok(()) +} + +/// Equality **function** oracle: `eql_v3.eq` / `eql_v3.neq` across all three +/// overloads agree with the plaintext (in)equality, for every ordered pair. +/// `variant` is the eq-capable domain to run on (`Eq` normally; `Search` for +/// text's combined `_search` domain). Complements `assert_eq_oracle`'s operator +/// checks by calling the named functions directly across the overload set. +pub async fn assert_eq_fn_oracle( + pool: &PgPool, + variant: Variant, + rows: &[Row], +) -> Result<()> { + let spec = ScalarDomainSpec::new::(variant); + anyhow::ensure!( + spec.supports_eq(), + "assert_eq_fn_oracle needs an eq-capable variant, got {variant:?} for {}", + T::PG_TYPE + ); + let domain = spec.sql_domain; + assert_named_fns( + pool, + &domain, + rows, + &["eq", "neq"], + |a, b, func| match func { + "eq" => a == b, + "neq" => a != b, + other => unreachable!("assert_eq_fn_oracle func {other}"), + }, + ) + .await +} + +/// Ordering **function** oracle: `eql_v3.lt` / `lte` / `gt` / `gte` across all +/// three overloads agree with the plaintext ordering, for every ordered pair. +/// `variant` is an ordered domain (`Ord` / `OrdOre`, or `Search` for text). +pub async fn assert_ord_fn_oracle( + pool: &PgPool, + variant: Variant, + rows: &[Row], +) -> Result<()> { + let spec = ScalarDomainSpec::new::(variant); + anyhow::ensure!( + spec.supports_ord(), + "assert_ord_fn_oracle needs an ordered variant, got {variant:?} for {}", + T::PG_TYPE + ); + let domain = spec.sql_domain; + assert_named_fns( + pool, + &domain, + rows, + &["lt", "lte", "gt", "gte"], + |a, b, func| match func { + "lt" => a < b, + "lte" => a <= b, + "gt" => a > b, + "gte" => a >= b, + other => unreachable!("assert_ord_fn_oracle func {other}"), + }, + ) + .await +} + +/// Term-extractor **identity** oracle: the generated extractor returns the exact +/// term stored in the payload. For `variant`'s domain, drives whichever +/// extractors its catalog terms declare: +/// - an `Hm` term ⇒ `eql_v3.eq_term()::text` equals the payload's `hm` +/// string (`eql_v3.hmac_256` is a domain over `text`, so the hex comes back +/// verbatim — no `encode`/`decode`). +/// - an `Ore` term ⇒ the `ord_term` composite, re-rendered to a hex-block array +/// (`encode((t).bytes,'hex')` per block, ordinal order), equals the payload's +/// `ob` array. +/// +/// `text_ord`/`text_search` carry both terms, so both identities are checked on +/// the one domain. The `hm`/`ob` values are read straight out of `payload_json` +/// with `serde_json` — no typed struct. +pub async fn assert_extractor_oracle( + pool: &PgPool, + variant: Variant, + rows: &[Row], +) -> Result<()> { + let spec = ScalarDomainSpec::new::(variant); + let domain = &spec.sql_domain; + let terms = variant.terms_for(T::PG_TYPE); + let check_eq = terms.contains(&Term::Hm); + let check_ord = terms.iter().any(|t| t.provides_ordering()); + anyhow::ensure!( + check_eq || check_ord, + "assert_extractor_oracle needs an Hm or Ore term, got {variant:?} for {}", + T::PG_TYPE + ); + for row in rows { + let value = cast(&row.payload_json, domain); + let payload: serde_json::Value = serde_json::from_str(&row.payload_json) + .with_context(|| format!("parsing payload_json: {}", row.payload_json))?; + + if check_eq { + let hm = payload + .get("hm") + .and_then(|v| v.as_str()) + .with_context(|| format!("payload missing string `hm`: {}", row.payload_json))?; + let sql = format!("SELECT eql_v3.eq_term({value})::text"); + let got: Option = sqlx::query_scalar(&sql) + .fetch_one(pool) + .await + .with_context(|| format!("eq_term identity query: {sql}"))?; + anyhow::ensure!( + got.as_deref() == Some(hm), + "eq_term identity on {domain}: extractor returned {got:?}, payload hm={hm:?}", + ); + } + + if check_ord { + let ob: Vec = payload + .get("ob") + .and_then(|v| v.as_array()) + .with_context(|| format!("payload missing array `ob`: {}", row.payload_json))? + .iter() + .map(|v| v.as_str().map(str::to_owned)) + .collect::>>() + .with_context(|| { + format!("`ob` is not an array of strings: {}", row.payload_json) + })?; + // Re-render the ORE composite to its stored hex-block array + // (lower-case `encode(...,'hex')`, in array-subscript order, which is + // the order `jsonb_array_to_ore_block_256` built `terms` from the + // payload's `ob`). `eql_v3.ore_block_256_term` is a single-field + // composite `(bytes bytea)`; a `WITH ORDINALITY AS u(t, n)` column- + // alias list expands that single field to `bytea` (so `(t).bytes` + // fails to resolve), so index `terms` with `generate_subscripts` + // instead — that keeps each element a composite and gives explicit + // ordering. `ord_term` is evaluated once. + let sql = format!( + "SELECT array(\ + SELECT encode((ore.terms[i]).bytes, 'hex') \ + FROM generate_subscripts(ore.terms, 1) AS i \ + ORDER BY i) \ + FROM (SELECT (eql_v3.ord_term({value})).terms AS terms) ore" + ); + let got: Vec = sqlx::query_scalar(&sql) + .fetch_one(pool) + .await + .with_context(|| format!("ord_term identity query: {sql}"))?; + anyhow::ensure!( + got == ob, + "ord_term identity on {domain}: extractor returned {got:?}, payload ob={ob:?}", + ); + } + } + Ok(()) +} + +/// Bloom-filter **match** smoke (text only, example-based). Bloom containment +/// admits false positives and the plaintext oracle is substring, not equality, +/// so this is curated rather than a random property: three fixtures with known +/// n-gram relationships (`haystack` ⊇ `needle`, `disjoint` shares none). Asserts +/// `eql_v3.contains` / `contained_by` respect **left-contains-right** `@>` and +/// that `match_term` yields a non-empty `bf` array. Operands are the payload +/// JSON literals cast to `domain` (`eql_v3.text_match`). +pub async fn assert_match_smoke( + pool: &PgPool, + domain: &str, + haystack_json: &str, + needle_json: &str, + disjoint_json: &str, +) -> Result<()> { + let haystack = cast(haystack_json, domain); + let needle = cast(needle_json, domain); + let disjoint = cast(disjoint_json, domain); + + // `contains(a, b)` = `match_term(a) @> match_term(b)` (a's bits ⊇ b's); + // `contained_by` is its mirror. Each row: (label, sql, expected). + let cases: [(&str, String, bool); 6] = [ + ( + "contains(haystack, needle)", + format!("eql_v3.contains({haystack}, {needle})"), + true, + ), + ( + "contains(needle, haystack)", + format!("eql_v3.contains({needle}, {haystack})"), + false, + ), + ( + "contains(haystack, disjoint)", + format!("eql_v3.contains({haystack}, {disjoint})"), + false, + ), + ( + "contained_by(needle, haystack)", + format!("eql_v3.contained_by({needle}, {haystack})"), + true, + ), + ( + "contained_by(haystack, needle)", + format!("eql_v3.contained_by({haystack}, {needle})"), + false, + ), + ( + "contained_by(disjoint, haystack)", + format!("eql_v3.contained_by({disjoint}, {haystack})"), + false, + ), + ]; + let sql = format!( + "SELECT {}", + cases + .iter() + .map(|(_, expr, _)| expr.clone()) + .collect::>() + .join(", ") + ); + let row = sqlx::query(&sql) + .fetch_one(pool) + .await + .with_context(|| format!("match-smoke containment query: {sql}"))?; + for (i, (label, _, want)) in cases.iter().enumerate() { + let got: Option = row + .try_get(i) + .with_context(|| format!("reading column {i} of: {sql}"))?; + anyhow::ensure!( + got == Some(*want), + "match smoke {label} on {domain}: expected {want}, SQL returned {got:?}", + ); + } + + // Each fixture's `match_term` must yield a non-empty bloom (`bf`) array. + for (label, value) in [ + ("haystack", &haystack), + ("needle", &needle), + ("disjoint", &disjoint), + ] { + let sql = format!("SELECT eql_v3.match_term({value})::smallint[]"); + let bf: Vec = sqlx::query_scalar(&sql) + .fetch_one(pool) + .await + .with_context(|| format!("match_term query for {label}: {sql}"))?; + anyhow::ensure!( + !bf.is_empty(), + "match_term({label}) on {domain} returned an empty bloom array", + ); + } + Ok(()) +} + /// Replace any `user:password@` userinfo in a connection URL with `***@` so it /// is safe to put in error context / logs (the password never appears). fn redact_url(url: &str) -> String { diff --git a/tests/sqlx/tests/encrypted_domain/property/README.md b/tests/sqlx/tests/encrypted_domain/property/README.md index 01a5ff2a..bed3ae04 100644 --- a/tests/sqlx/tests/encrypted_domain/property/README.md +++ b/tests/sqlx/tests/encrypted_domain/property/README.md @@ -14,13 +14,19 @@ named for what they operate on, not by an abstract tier letter: | Suite | Location | Kind | Inputs | DB / creds | |-------|----------|------|--------|------------| | **catalog** | [`crates/eql-scalars/src/proptest_invariants.rs`](../../../../../crates/eql-scalars/src/proptest_invariants.rs) | unit (pure Rust) | generated terms / kinds | none — runs in fork CI | -| **fixture** | [`fixture_oracle.rs`](./fixture_oracle.rs) | integration | committed fixture corpus (real ciphertext) | shared test DB | +| **fixture** | [`fixture_oracle.rs`](./fixture_oracle.rs) | integration | committed fixture rows (real ciphertext) | isolated per-test DB (`#[sqlx::test]`) | | **e2e** | [`e2e_oracle.rs`](./e2e_oracle.rs) | integration | freshly generated plaintexts, encrypted each run | shared test DB **+ ZeroKMS creds** | +The `fixture` suite spans two files: [`fixture_oracle.rs`](./fixture_oracle.rs) +(the operator **and** function-double oracles + term-extractor identity) and +[`match_smoke.rs`](./match_smoke.rs) (example-based bloom containment for the text +`_match` domain). Both are un-gated — they read the already-encrypted fixtures, +no fresh ZeroKMS. + Plus [`edge_cases.rs`](./edge_cases.rs): example-based unit tests for NULL propagation, blockers raising on unsupported operators (including the -native-`jsonb` `->`/`@>` domain-fallback paths), `timestamptz` ordering -deferral, and CHECK rejection of malformed payloads. +native-`jsonb` `->`/`@>` domain-fallback paths) and CHECK rejection of malformed +payloads. ### catalog — catalog invariants, no database @@ -32,33 +38,49 @@ only suite where `proptest` shrinking is meaningful and enabled. ### fixture — oracle over committed ciphertext -Runs the shared all-pairs oracle engine over the real, committed fixture corpus +Runs the shared all-pairs oracle engine over the real, committed fixture rows (`fixtures.eql_v2_.sql`, generated by `cipherstash-client` during -`mise run test:sqlx:prep`). `proptest` selects a sub-multiset of fixture rows +`mise run test:sqlx:prep`). The fixtures are the curated catalog values for each +type (`Min`/`Max`/`Zero`/pivots). `proptest` selects a sub-multiset of those rows (with repeats), and the engine checks **every ordered pair**. No new encryption, so it runs whenever the fixtures are present, and it generalises across the whole catalog for free (every fixtured type gets an `eq_oracle`, ordered types also get an `ord_oracle`). +On top of the operator oracles, the fixture suite runs the **function-double +oracles**: it calls the generated `eql_v3.eq`/`neq`/`lt`/`lte`/`gt`/`gte` +**functions** by name across all three [`Overload`s][overload] +(domain–domain, domain–jsonb, jsonb–domain) and asserts **term-extractor +identity** — `eql_v3.eq_term` returns the payload's exact `hm`, `eql_v3.ord_term` +returns its exact `ob`. [`match_smoke.rs`](./match_smoke.rs) adds the +example-based bloom containment (`@>`/`<@`) for the text `_match` domain. + +[overload]: ../../../src/property.rs + ### e2e — oracle over fresh end-to-end encryption Same oracle engine, but each case **generates fresh random plaintexts and encrypts them end-to-end through ZeroKMS** (one batched call per case) before querying. Gated behind the `proptest-e2e` cargo feature — `mise run test:sqlx` -enables it (CI has the secrets); a bare `cargo test` compiles it out. It is the -**only** suite that can exercise "same plaintext, *different* ciphertext" -(equality across independently-encrypted values), because the committed fixture -corpus has no duplicate plaintexts. Covers every ordered scalar -(int2/int4/int8/date/timestamptz/numeric/text) via the +enables it (CI has the secrets); a bare `cargo test` compiles it out. Covers +every ordered scalar (int2/int4/int8/date/timestamptz/numeric/text) via the `ScalarType::arbitrary_value()` strategy seam — integers draw the full `any::()` range, non-integer scalars sample their cast-valid fixture set (their plaintexts have no usable bounded `Arbitrary`). `bool` is storage-only (no ordered domain) and is the only scalar excluded. +Defence in depth over the fixture suite: the fixtures are encrypted once at +`test:sqlx:prep`, so they pin behaviour against a *frozen* ciphertext snapshot; +the e2e suite re-encrypts on **every run**, so it catches a live crypto-path +regression (a `cipherstash-client` / ZeroKMS change) that leaves the committed +fixtures untouched. The e2e suite is also the one that exercises "same plaintext, +*different* ciphertext" (equality across independently-encrypted values), via the +fresh duplicate plaintexts it appends each run. + ## The shared oracle engine `assert_eq_oracle` / `assert_ord_oracle` in -[`../../../src/property.rs`](../../../src/property.rs) take a corpus of +[`../../../src/property.rs`](../../../src/property.rs) take a set of `(plaintext, payload_json)` rows and check, over every ordered pair, that: - `=` / `<>` on the `_eq` domain agree with plaintext `==` / `!=`, and @@ -68,6 +90,13 @@ corpus has no duplicate plaintexts. Covers every ordered scalar The fixture and e2e suites differ only in **where the rows come from**; the engine is identical. +The same file also holds the **function-double** helpers the fixture suite layers +on: `assert_eq_fn_oracle` / `assert_ord_fn_oracle` (the named `eql_v3.*` +functions across every [`Overload`][overload]), `assert_extractor_oracle` +(`eq_term`==`hm` / `ord_term`==`ob` identity), and `assert_match_smoke` (bloom +containment). They take the same `Row` set, so they ride the fixtures at +zero marginal ZeroKMS cost. + ## Why ciphertext can't be `Arbitrary`-derived A valid payload's `hm`/`ob` terms are real ciphertext from `cipherstash-client` @@ -88,15 +117,23 @@ encryption to reach inputs the fixtures can't. - **The e2e suite disables shrinking and failure persistence.** Every shrink attempt would burn another ZeroKMS batch, ciphertext can't be meaningfully shrunk, and fresh-each-run ciphertext can't be replayed. -- **proptest + async bridge.** The `proptest!` body is sync; the DB-backed suites - run their async oracle on a per-case current-thread `tokio` runtime and connect - via `connect_pool()` (they cannot use `#[sqlx::test]`'s injected pool from a - sync body). Operator evaluation is read-only `SELECT`, so no per-test schema - isolation is needed. -- **Equality-true must actually fire.** Random integer pairs almost never - collide, so the e2e corpus injects deliberate duplicate plaintexts (plus signed - extremes and zero) to exercise the `a == b ⇒ eq` branch across distinct - ciphertexts. +- **proptest + async bridge.** proptest's case loop is synchronous and can't + `.await`. The **fixture** suite (including the function-double oracles) runs + under `#[sqlx::test]`, so each test gets its own migrated scratch DB; + `drive_proptest` runs the proptest runner on a dedicated OS thread and ships + each generated case to the test's async runtime — where the injected pool + lives — over a channel, so the pool never crosses runtimes and shrinking is + preserved. The **e2e** suite instead connects via `connect_pool()` to the + shared base DB and drives proptest on a current-thread runtime (it + batch-encrypts via ZeroKMS and is feature-gated, so a long-lived shared pool is + fine). [`match_smoke.rs`](./match_smoke.rs) is a plain `#[sqlx::test]` (not + proptest-driven), loading the fixtures into its own isolated DB. +- **Equality-true must actually fire.** Random distinct plaintexts almost never + collide, so the e2e suite injects deliberate duplicate plaintexts (plus signed + extremes and zero) each run to exercise the `a == b ⇒ eq` branch across + *distinct* ciphertexts. The fixture suite's curated rows have unique plaintexts, + so it exercises the equality-true branch on self-pairs (same ciphertext); the + cross-ciphertext case is the e2e suite's job. ## Running @@ -106,7 +143,8 @@ cargo test -p eql-scalars proptest_invariants # fixture + edge-case suites (needs a prepared DB) mise run test:sqlx:prep -cd tests/sqlx && cargo test --test encrypted_domain property::fixture_oracle property::edge_cases +cd tests/sqlx && cargo test --test encrypted_domain \ + property::fixture_oracle property::match_smoke property::edge_cases # all suites incl. e2e (needs DB + CS_* creds) mise run test:sqlx # enables --features proptest-e2e diff --git a/tests/sqlx/tests/encrypted_domain/property/e2e_oracle.rs b/tests/sqlx/tests/encrypted_domain/property/e2e_oracle.rs index 3fce0980..cad7694f 100644 --- a/tests/sqlx/tests/encrypted_domain/property/e2e_oracle.rs +++ b/tests/sqlx/tests/encrypted_domain/property/e2e_oracle.rs @@ -2,7 +2,7 @@ //! end-to-end through ZeroKMS each run. Gated behind `proptest-e2e` (declared in //! property/mod.rs) — needs CS_* creds, which `mise run test:sqlx` enables for //! CI/local full SQLx runs. -//! Each proptest case generates one corpus of random integers — seeded with +//! Each proptest case generates one batch of random integers — seeded with //! type-specific extremes, zero, and deliberate duplicates so the equality-true //! branch fires across distinct ciphertexts of the same plaintext — encrypts it //! in one batched ZeroKMS call, then runs the all-pairs oracle. @@ -53,7 +53,7 @@ where .collect()) } -/// Drive proptest: each case is a corpus of integers. Generation is in-process; +/// Drive proptest: each case is a batch of integers. Generation is in-process; /// encryption + oracle is async on a current-thread runtime. fn run_e2e_property(table: &str, cases: u32, ordered: bool, seeds: &[T]) -> Result<()> where @@ -209,10 +209,19 @@ e2e_oracle_suite!( /// Both float widths encrypt through the SINGLE f64 crypto path /// (`F4::to_plaintext` widens `self.0 as f64`; `F8::to_plaintext` is the -/// identity), so an f32 value and its exact f64 widening MUST produce identical -/// index terms — this is the byte-identity the CHANGELOG claims. Encrypt the -/// same value both ways (an f32-exact value, so `x as f64` is lossless) and -/// assert the `hm` (HMAC equality) and `ob` (ORE) terms match across widths. +/// identity), so an f32 value and its exact f64 widening are the SAME real +/// number and are equality- and order-interchangeable across widths. The two +/// index terms behave differently and so are checked differently: +/// +/// - `hm` (HMAC equality) is a **deterministic** keyed hash of the value, so the +/// two widths produce a **byte-identical** `hm` — assert that directly. +/// - `ob` (ORE ordering) is **probabilistic**: each encryption draws a fresh +/// per-ciphertext nonce (the random Right half of the BlockORE term), so two +/// encodings of one value are byte-UNEQUAL *by construction* — even same-width, +/// same-value. Ordering is decided by the ORE compare function, never by raw +/// bytes, so the ONLY correct cross-width ORE check is the SQL +/// `eql_v3.ore_block_256` `=` operator over the extracted `ord_term`s. +/// /// Creds/e2e-gated like the rest of this file. #[test] fn float4_and_float8_share_index_terms_for_the_same_value() -> Result<()> { @@ -222,8 +231,8 @@ fn float4_and_float8_share_index_terms_for_the_same_value() -> Result<()> { .enable_all() .build()?; - // f32-exact value: `x as f64` is the same real number, so any term - // difference would be a width artifact, which is exactly what we forbid. + // f32-exact value: `x as f64` is the same real number, so both widths encode + // the identical f64 — any *value* difference would be a width artifact. let x: f32 = 2.25; let f4_payloads = rt.block_on(async { @@ -241,26 +250,42 @@ fn float4_and_float8_share_index_terms_for_the_same_value() -> Result<()> { encrypt_store("xwidth_f8", "payload", &[F8(x as f64)], &cfg).await })?; - // Pull a string index term from the EQL payload JSON (`hm` / `ob`). - let term = |p: &serde_json::Value, key: &str| -> Result { - p.get(key) + // `hm` (deterministic HMAC) is byte-identical across widths — compare directly. + let hm = |p: &serde_json::Value| -> Result { + p.get("hm") .and_then(serde_json::Value::as_str) .map(str::to_string) - .ok_or_else(|| anyhow::anyhow!("payload missing string `{key}`: {p}")) + .ok_or_else(|| anyhow::anyhow!("payload missing string `hm`: {p}")) }; - - // HMAC equality term: identical plaintext + key => identical hm, so the two - // widths are equality-interchangeable at the term level. assert_eq!( - term(&f4_payloads[0], "hm")?, - term(&f8_payloads[0], "hm")?, + hm(&f4_payloads[0])?, + hm(&f8_payloads[0])?, "float4 and float8 of the same value must share the hm equality term" ); - // ORE term: same f64 input => same ORE ciphertext, so ordering is identical. - assert_eq!( - term(&f4_payloads[0], "ob")?, - term(&f8_payloads[0], "ob")?, - "float4 and float8 of the same value must share the ob ORE term" + + // `ob` (probabilistic ORE) is NOT byte-comparable — the only correct check is + // the SQL ORE operator over the extracted `ord_term`s. Cast each payload to + // its width's `_ord_ore` domain, extract the `eql_v3.ore_block_256` term, and + // compare with `=` (eql_v3.ore_block_256_eq => compare_ore_block_256_terms = 0). + let pool: PgPool = rt.block_on(connect_pool())?; + rt.block_on(ensure_eql_installed(&pool, &super::migrator()))?; + + let ord_term = |p: &serde_json::Value, domain: &str| -> String { + let lit = p.to_string().replace('\'', "''"); + format!("eql_v3.ord_term('{lit}'::jsonb::{domain})") + }; + let sql = format!( + "SELECT {} = {}", + ord_term(&f4_payloads[0], "eql_v3.float4_ord_ore"), + ord_term(&f8_payloads[0], "eql_v3.float8_ord_ore"), + ); + let ore_equal: Option = rt + .block_on(sqlx::query_scalar(&sql).fetch_one(&pool)) + .map_err(|e| anyhow::anyhow!("cross-width ORE compare query ({sql}): {e}"))?; + anyhow::ensure!( + ore_equal == Some(true), + "float4 and float8 of the same value must compare equal under the SQL ORE \ + operator (eql_v3.ore_block_256 `=`); got {ore_equal:?}" ); Ok(()) } diff --git a/tests/sqlx/tests/encrypted_domain/property/edge_cases.rs b/tests/sqlx/tests/encrypted_domain/property/edge_cases.rs index 4f2ebd9d..bcb180a4 100644 --- a/tests/sqlx/tests/encrypted_domain/property/edge_cases.rs +++ b/tests/sqlx/tests/encrypted_domain/property/edge_cases.rs @@ -1,8 +1,8 @@ //! Unit edge cases for the eql_v3 scalar domains (CIP-3141): NULL propagation on //! supported operators, blocker functions raising on unsupported operators //! (equality, ordering, path, and containment families — the documented -//! domain-fallback footgun), the timestamptz ordering deferral, and domain -//! CHECK-constraint rejection of malformed payloads. No encryption. +//! domain-fallback footgun), ordering blocked on the equality-only `_eq` domain, +//! and domain CHECK-constraint rejection of malformed payloads. No encryption. use anyhow::Result; use eql_tests::scalar_domains::{ @@ -67,11 +67,12 @@ async fn containment_blocker_raises_on_eq_domain(pool: PgPool) -> Result<()> { } #[sqlx::test] -async fn ordering_is_deferred_on_timestamptz_eq(pool: PgPool) -> Result<()> { - // timestamptz is equality-only: ordering is deferred until a wide-ORE - // comparator lands (CHANGELOG / #241). Lock that in at the SQL boundary — an - // ordering operator on `timestamptz_eq` must RAISE (and be non-STRICT), not - // silently mis-order. There are no `timestamptz_ord` / `_ord_ore` domains. +async fn ordering_blocked_on_timestamptz_eq_domain(pool: PgPool) -> Result<()> { + // timestamptz is an ordered scalar on the `eql_v3` base (its `_ord`/`_ord_ore` + // domains order via the wide-ORE comparator). But the equality-only `_eq` + // domain still must NOT answer ordering: an ordering operator on + // `timestamptz_eq` must RAISE (and be non-STRICT), not silently mis-order — + // exactly as `int4_eq` does. Callers order via the `_ord` twins, not `_eq`. let d = ScalarDomainSpec::new::>(Variant::Eq).sql_domain; let sql = format!("SELECT (NULL::{d}) < (NULL::{d})"); assert_raises(&pool, &sql, &[], &blocker_msg(&d, "<")).await diff --git a/tests/sqlx/tests/encrypted_domain/property/fixture_oracle.rs b/tests/sqlx/tests/encrypted_domain/property/fixture_oracle.rs index cf5d8645..9c3b9c03 100644 --- a/tests/sqlx/tests/encrypted_domain/property/fixture_oracle.rs +++ b/tests/sqlx/tests/encrypted_domain/property/fixture_oracle.rs @@ -1,4 +1,4 @@ -//! fixture suite (CIP-3141): property tests over the real, committed fixture corpus. +//! fixture suite (CIP-3141): property tests over the real, committed fixture rows. //! //! The fixture table `fixtures.eql_v2_` carries `(plaintext, payload)` rows //! encrypted by cipherstash-client during `test:sqlx:prep`. proptest selects a @@ -8,7 +8,7 @@ //! //! Each test uses `#[sqlx::test]`, so it gets its OWN migrated scratch database //! (the `eql_v3` surface is already installed by the embedded migrations) and -//! loads the fixture corpus into that isolated DB. This is what every other test +//! loads the fixture rows into that isolated DB. This is what every other test //! in the suite does; it avoids the shared-base-DB races that bite under //! nextest's process-per-test parallelism (concurrent `CREATE SCHEMA`, and a //! later test re-`DROP`/`CREATE`-ing a fixture table out from under an earlier @@ -18,21 +18,27 @@ //! Generic over `ScalarType`; instantiated per type at the bottom. use anyhow::{Context, Result}; -use eql_tests::property::{assert_eq_oracle, assert_ord_oracle, Row}; +use eql_tests::property::{ + assert_eq_fn_oracle, assert_eq_oracle, assert_extractor_oracle, assert_ord_fn_oracle, + assert_ord_oracle, Row, +}; use eql_tests::scalar_domains::{ScalarType, Variant}; use proptest::prelude::*; use proptest::test_runner::{Config, TestCaseError, TestRunner}; use sqlx::PgPool; use std::sync::Arc; -/// The fixture corpus SQL for `T`, `include_str!`-embedded into this test binary +/// The fixture SQL for `T`, `include_str!`-embedded into this test binary /// at compile time (one arm per catalog token). Embedding rather than reading /// from disk at runtime is what lets the prebuilt nextest archive carry the -/// corpus into CI shards, which do a fresh checkout where the gitignored +/// fixtures into CI shards, which do a fresh checkout where the gitignored /// `tests/sqlx/fixtures/eql_v2_.sql` files are absent. The path resolves /// against the `eql_tests` crate root (`tests/sqlx`). Mirrors the loud catch-all /// of the `generate_for_token` fixture dispatch. -fn embedded_fixture_sql() -> &'static str { +/// +/// `pub(crate)` so the sibling `match_smoke` module shares the one source of +/// truth for which fixture SQL is embedded. +pub(crate) fn embedded_fixture_sql() -> &'static str { match T::PG_TYPE { "int4" => include_str!(concat!( env!("CARGO_MANIFEST_DIR"), @@ -79,15 +85,24 @@ fn embedded_fixture_sql() -> &'static str { } } -/// Load the committed fixture corpus for `T` into this test's isolated scratch -/// DB and read every `(plaintext, payload::text)` row, in id order. The corpus -/// SQL is self-contained (`CREATE SCHEMA IF NOT EXISTS fixtures` / `CREATE` / -/// `INSERT`); since the DB is private to this test there is no concurrency on it. -async fn load_rows(pool: &PgPool) -> Result>>> { +/// Load `T`'s committed fixtures into `pool`'s isolated scratch DB via the +/// `include_str!`-embedded SQL. The fixture SQL is self-contained (`CREATE SCHEMA +/// IF NOT EXISTS fixtures` / `CREATE` / `INSERT`); since each `#[sqlx::test]` DB +/// is private to its test there is no concurrency on it. `pub(crate)` so +/// `match_smoke` (which then fetches specific rows via `fetch_fixture_payload`) +/// shares the one embedded source. +pub(crate) async fn load_fixtures(pool: &PgPool) -> Result<()> { sqlx::raw_sql(embedded_fixture_sql::()) .execute(pool) .await - .with_context(|| format!("loading fixture corpus for {}", T::PG_TYPE))?; + .with_context(|| format!("loading fixtures for {}", T::PG_TYPE))?; + Ok(()) +} + +/// Load the committed fixtures for `T` into this test's isolated scratch +/// DB and read every `(plaintext, payload::text)` row, in id order. +pub(crate) async fn load_rows(pool: &PgPool) -> Result>>> { + load_fixtures::(pool).await?; let sql = format!( "SELECT plaintext, payload::text FROM {} ORDER BY id", T::fixture_table_name() @@ -108,7 +123,7 @@ async fn load_rows(pool: &PgPool) -> Result>>> { Ok(Arc::new(rows)) } -/// Build a corpus by sampling indices (with repeats) into the loaded fixtures. +/// Build a sample by selecting indices (with repeats) into the loaded fixtures. /// `idxs` are already bounded to `0..all.len()` by the proptest strategy. fn pick(all: &[Row], idxs: &[usize]) -> Vec> { idxs.iter().map(|&i| all[i].clone()).collect() @@ -183,7 +198,7 @@ fn config_and_strategy(cases: u32, n: usize) -> (Config, impl Strategy(pool: PgPool, cases: u32) -> Result<()> { let rows = load_rows::(&pool).await?; let (config, strategy) = config_and_strategy(cases, rows.len()); @@ -195,7 +210,7 @@ async fn run_eq_oracle(pool: PgPool, cases: u32) -> Result<()> { .await } -/// Ordering-oracle property over `T`'s fixture corpus (both ordered twins). +/// Ordering-oracle property over `T`'s fixture rows (both ordered twins). async fn run_ord_oracle(pool: PgPool, cases: u32) -> Result<()> { let rows = load_rows::(&pool).await?; let (config, strategy) = config_and_strategy(cases, rows.len()); @@ -203,9 +218,9 @@ async fn run_ord_oracle(pool: PgPool, cases: u32) -> Result<()> { let pool = pool.clone(); let rows = rows.clone(); async move { - let corpus = pick(&rows, &idxs); - assert_ord_oracle::(&pool, Variant::Ord, &corpus).await?; - assert_ord_oracle::(&pool, Variant::OrdOre, &corpus).await + let sample = pick(&rows, &idxs); + assert_ord_oracle::(&pool, Variant::Ord, &sample).await?; + assert_ord_oracle::(&pool, Variant::OrdOre, &sample).await } }) .await @@ -250,3 +265,159 @@ fixture_oracle_suite!(numeric, rust_decimal::Decimal, ordered); fixture_oracle_suite!(text, String, ordered); fixture_oracle_suite!(float4, eql_tests::scalar_domains::F4, ordered); fixture_oracle_suite!(float8, eql_tests::scalar_domains::F8, ordered); + +// --- function-double oracles (CIP-3141) ------------------------------------- +// +// The same fixture rows, but calling the generated `eql_v3.*` comparison +// functions by name across all three overloads and asserting term-extractor +// identity (eq_term==hm / ord_term==ob). Free of fresh encryption — read-only +// SQL over the already-encrypted fixtures. int4 is the reference family with +// explicit tests; the other types go through `fixture_fn_oracle_suite!`. + +/// Function-double property driver: like `run_eq_oracle` / `run_ord_oracle`, but +/// the per-case `body` runs the caller's named-function / extractor oracles +/// against the per-case sample. Shares `load_rows` + `config_and_strategy` + +/// `drive_proptest`, so each fn-oracle test gets the same isolated `#[sqlx::test]` +/// DB and the same synchronous-proptest → async bridge as the operator oracles. +async fn run_fn_property(pool: PgPool, cases: u32, body: F) -> Result<()> +where + T: ScalarType, + F: Fn(PgPool, Vec>) -> Fut, + Fut: std::future::Future>, +{ + let rows = load_rows::(&pool).await?; + let (config, strategy) = config_and_strategy(cases, rows.len()); + drive_proptest(config, strategy, move |idxs| { + let pool = pool.clone(); + let sample = pick(&rows, &idxs); + body(pool, sample) + }) + .await +} + +#[sqlx::test] +async fn prop_int4_eq_fn_oracle_over_fixture(pool: PgPool) -> Result<()> { + run_fn_property::(pool, 32, |pool, sample| async move { + assert_eq_fn_oracle::(&pool, Variant::Eq, &sample).await?; + assert_extractor_oracle::(&pool, Variant::Eq, &sample).await + }) + .await +} + +#[sqlx::test] +async fn prop_int4_ord_fn_oracle_over_fixture(pool: PgPool) -> Result<()> { + run_fn_property::(pool, 32, |pool, sample| async move { + assert_ord_fn_oracle::(&pool, Variant::Ord, &sample).await?; + assert_extractor_oracle::(&pool, Variant::Ord, &sample).await?; + assert_ord_fn_oracle::(&pool, Variant::OrdOre, &sample).await?; + assert_extractor_oracle::(&pool, Variant::OrdOre, &sample).await + }) + .await +} + +/// Function-double counterpart of `fixture_oracle_suite!`: per-family +/// named-function + extractor-identity oracles over the same fixture rows. +/// Parallel (distinct `` from the operator suite) so each family can be +/// added without disturbing the operator arms. `ordered` runs eq on `_eq` plus +/// the four ord functions on both ordered twins; `eq_only` runs eq alone. Each +/// arm is a `#[sqlx::test]` (its own migrated scratch DB), matching the operator +/// suite. +macro_rules! fixture_fn_oracle_suite { + ($modname:ident, $ty:ty, ordered) => { + mod $modname { + use super::*; + #[sqlx::test] + async fn eq_fn_oracle(pool: PgPool) -> Result<()> { + run_fn_property::<$ty, _, _>(pool, 32, |pool, c| async move { + assert_eq_fn_oracle::<$ty>(&pool, Variant::Eq, &c).await?; + assert_extractor_oracle::<$ty>(&pool, Variant::Eq, &c).await + }) + .await + } + #[sqlx::test] + async fn ord_fn_oracle(pool: PgPool) -> Result<()> { + run_fn_property::<$ty, _, _>(pool, 32, |pool, c| async move { + assert_ord_fn_oracle::<$ty>(&pool, Variant::Ord, &c).await?; + assert_extractor_oracle::<$ty>(&pool, Variant::Ord, &c).await?; + assert_ord_fn_oracle::<$ty>(&pool, Variant::OrdOre, &c).await?; + assert_extractor_oracle::<$ty>(&pool, Variant::OrdOre, &c).await + }) + .await + } + } + }; + ($modname:ident, $ty:ty, eq_only) => { + mod $modname { + use super::*; + #[sqlx::test] + async fn eq_fn_oracle(pool: PgPool) -> Result<()> { + run_fn_property::<$ty, _, _>(pool, 32, |pool, c| async move { + assert_eq_fn_oracle::<$ty>(&pool, Variant::Eq, &c).await?; + assert_extractor_oracle::<$ty>(&pool, Variant::Eq, &c).await + }) + .await + } + } + }; +} + +fixture_fn_oracle_suite!(int2_fn, i16, ordered); +fixture_fn_oracle_suite!(int8_fn, i64, ordered); +// date, timestamptz, and numeric are all ordered scalars on the `eql_v3` base, +// so each gets eq/neq functions + eq_term identity plus the four ord functions +// on both ordered twins. The committed fixtures already encrypt the whole +// catalog, so this is full function-level coverage at zero marginal ZeroKMS cost. +fixture_fn_oracle_suite!(date_fn, chrono::NaiveDate, ordered); +fixture_fn_oracle_suite!(timestamptz_fn, chrono::DateTime, ordered); +fixture_fn_oracle_suite!(numeric_fn, rust_decimal::Decimal, ordered); + +// text is bespoke rather than `fixture_fn_oracle_suite!`: its ordered domains +// carry both [Hm, Ore], so they support the FULL six comparisons (eq/neq route +// through `hm`, the four ord ops through ORE) — the generic `ordered` arm only +// runs the four ord ops on the ordered twins. text also declares `_search` +// ([Hm, Ore, Bloom]), which `Variant::Search` reaches but the generic macro +// never instantiates. The committed text fixture is encrypted with +// [Unique, Ore, Match], so its payload carries hm+ob+bf and casts cleanly to +// every text domain. The fixture rows excludes the empty string (issue #262), +// so no generator filtering is needed here. +mod text_fn { + use super::*; + + /// `text_eq` — eq/neq functions + eq_term identity. + #[sqlx::test] + async fn eq_fn_oracle(pool: PgPool) -> Result<()> { + run_fn_property::(pool, 32, |pool, c| async move { + assert_eq_fn_oracle::(&pool, Variant::Eq, &c).await?; + assert_extractor_oracle::(&pool, Variant::Eq, &c).await + }) + .await + } + + /// `text_ord` / `text_ord_ore` — full six comparisons (eq/neq + the four ord + /// ops) plus eq_term(`hm`) + ord_term(`ob`) identity on each ordered twin. + #[sqlx::test] + async fn ord_fn_oracle(pool: PgPool) -> Result<()> { + run_fn_property::(pool, 32, |pool, c| async move { + for variant in [Variant::Ord, Variant::OrdOre] { + assert_eq_fn_oracle::(&pool, variant, &c).await?; + assert_ord_fn_oracle::(&pool, variant, &c).await?; + assert_extractor_oracle::(&pool, variant, &c).await?; + } + Ok(()) + }) + .await + } + + /// `text_search` ([Hm, Ore, Bloom]) — the eq/ord function facets plus + /// eq_term + ord_term identity (the bloom `@>`/`<@` facet is covered by the + /// example-based `match_smoke`, not a random oracle). + #[sqlx::test] + async fn search_fn_oracle(pool: PgPool) -> Result<()> { + run_fn_property::(pool, 32, |pool, c| async move { + assert_eq_fn_oracle::(&pool, Variant::Search, &c).await?; + assert_ord_fn_oracle::(&pool, Variant::Search, &c).await?; + assert_extractor_oracle::(&pool, Variant::Search, &c).await + }) + .await + } +} diff --git a/tests/sqlx/tests/encrypted_domain/property/match_smoke.rs b/tests/sqlx/tests/encrypted_domain/property/match_smoke.rs new file mode 100644 index 00000000..b263fb8e --- /dev/null +++ b/tests/sqlx/tests/encrypted_domain/property/match_smoke.rs @@ -0,0 +1,41 @@ +//! fixture-suite (CIP-3141) bloom-filter **match** smoke for the text `_match` +//! domain. +//! +//! Unlike the eq/ord oracles, bloom containment is not a random property: +//! `@>`/`<@` admit false positives and the plaintext oracle is *substring*, not +//! equality. So this is an example-based smoke over three curated fixtures with +//! known n-gram relationships — `"aardvark"` ⊇ `"aard"`, `"zzzz"` disjoint from +//! both — pinned by the `MatchScalar` trait and the +//! `text_match_pivots_are_in_fixture_values` guard. +//! +//! It reads already-encrypted fixture payloads (no `encrypt_store`, no fresh +//! ZeroKMS), so it lives in the `fixture` suite — un-gated, running wherever the +//! fixtures load, exactly like `fixture_oracle.rs`. It is a `#[sqlx::test]` +//! (its own migrated scratch DB), so the fixtures load into an isolated database. +//! The `Variant` enum models no `_match` member, so the domain +//! (`eql_v3.text_match`) is named directly. + +use super::fixture_oracle::load_fixtures; +use anyhow::Result; +use eql_tests::property::assert_match_smoke; +use eql_tests::scalar_domains::{fetch_fixture_payload, MatchScalar}; +use sqlx::PgPool; + +/// `eql_v3.text_match` — the bloom-filter (`bf`) domain (`@>`/`<@`). +const TEXT_MATCH_DOMAIN: &str = "eql_v3.text_match"; + +#[sqlx::test] +async fn text_match_smoke(pool: PgPool) -> Result<()> { + // Match payloads come from the committed text fixture (encrypted with + // [Unique, Ore, Match], so each carries a `bf`); load it into this test's + // isolated DB on demand. + load_fixtures::(&pool).await?; + + let haystack = + fetch_fixture_payload::(&pool, ::haystack()).await?; + let needle = fetch_fixture_payload::(&pool, ::needle()).await?; + let disjoint = + fetch_fixture_payload::(&pool, ::disjoint()).await?; + + assert_match_smoke(&pool, TEXT_MATCH_DOMAIN, &haystack, &needle, &disjoint).await +} diff --git a/tests/sqlx/tests/encrypted_domain/property/mod.rs b/tests/sqlx/tests/encrypted_domain/property/mod.rs index be3f0ad5..e52b891c 100644 --- a/tests/sqlx/tests/encrypted_domain/property/mod.rs +++ b/tests/sqlx/tests/encrypted_domain/property/mod.rs @@ -22,8 +22,11 @@ pub(crate) fn migrator() -> sqlx::migrate::Migrator { // NULL / blocker / CHECK-constraint unit tests. mod edge_cases; -// fixture suite: oracle over the committed fixture corpus (real ciphertext). +// fixture suite: operator + function-double oracles over the committed fixture +// rows (real ciphertext), plus term-extractor identity. mod fixture_oracle; +// fixture suite: example-based bloom match smoke over the text `_match` fixtures. +mod match_smoke; // e2e suite: oracle over freshly generated + batch-encrypted values. #[cfg(feature = "proptest-e2e")] mod e2e_oracle;