Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ Each entry that ships in a published release links to the PR that introduced it.

### Fixed

- **`=` / `<>` on `eql_v2.ore_block_u64_8_256` now declare a `COMMUTATOR`, so equality joins over the ORE term no longer raise.** Both operators set `COMMUTATOR` to themselves (equality and inequality are symmetric, so each is its own commutator). Why: without it the planner raised `could not find commutator for operator` the first time an `ore_block_u64_8_256` equality was used as a join / mergejoin qualifier (e.g. via the inlined `eql_v3.int4_ord_ore` equality wrappers, since the operators carry `MERGES`). This only enables previously-erroring join plans — it cannot change which rows match or their ordering. ([#239](https://github.com/cipherstash/encrypt-query-language/pull/239))

- **The `eql_v3` ORE block comparator now orders ciphertexts of any block count, not just 8.** `eql_v3.compare_ore_block_256_term` derives the block count `N` from the term length (`octet_length = 49·N + 16`) instead of hardcoding 8, so encrypted types whose native ORE width exceeds 8 blocks — `numeric` (14) and `timestamptz` (12) — order, range-query, `ORDER BY`, and `MIN`/`MAX` correctly instead of silently mis-ordering. Malformed terms (length not `49·N + 16` for `N ≥ 1`) now raise instead of returning a bogus comparison. The self-contained `eql_v3` SEM type was renamed `eql_v3.ore_block_u64_8_256 → eql_v3.ore_block_256` to reflect that it is width-agnostic (the `eql_v2` type is unchanged). No effect on existing 8-block types (a no-op for `N = 8`). ([#241](https://github.com/cipherstash/encrypt-query-language/issues/241), [#276](https://github.com/cipherstash/encrypt-query-language/pull/276))

## [2.3.1] — 2026-05-21
Expand Down
9 changes: 8 additions & 1 deletion mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@

[tools]
"rust" = { version = "latest", components = "rustc,rust-std,cargo,rustfmt,rust-docs,clippy" }
"cargo:cargo-binstall" = "latest"
# Use the registry (aqua) backend to download a prebuilt cargo-binstall binary
# rather than `cargo:cargo-binstall`, which compiles it from source. A source
# build now fails in CI: the latest cargo-binstall pulls vergen@10.0.0 (MSRV
# rustc 1.95), but the toolchain bootstrapping mise's cargo backend is older,
# so every job that runs mise-action dies before any tools install. The
# prebuilt binary has no compiler/MSRV dependency. Downstream cargo: tools
# below are then fetched as prebuilt binaries via this cargo-binstall.
"cargo-binstall" = "latest"
"cargo:sqlx-cli" = "latest"
# Installed via the already-present cargo-binstall (fast in CI). Drives the
# sharded sqlx suite: `cargo nextest archive` builds the test binaries once and
Expand Down
6 changes: 6 additions & 0 deletions src/v3/sem/ore_cllw/functions.sql
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,12 @@ DECLARE
common_len INT;
cmp_result INT;
BEGIN
-- The `::text` cast is load-bearing, not a stylistic choice. For the
-- single-field `ore_cllw` composite, `ROW(NULL)::ore_cllw IS NULL` is TRUE
-- but `(ROW(NULL)::ore_cllw)::text IS NULL` is FALSE. Casting to text first
-- means a NULL-component composite falls THROUGH to the RAISE below (the
-- extractor-invariant violation) instead of silently returning NULL and
-- masking it. A plain `a IS NULL` would reintroduce that masking bug.
IF a::text IS NULL OR b::text IS NULL THEN
RETURN NULL;
END IF;
Expand Down
7 changes: 7 additions & 0 deletions tests/sqlx/tests/encrypted_domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ mod signed;
#[path = "encrypted_domain/float_special.rs"]
mod float_special;

// Table-level SQL constraint coverage (UNIQUE / NOT NULL / FOREIGN KEY) on
// `eql_v3` encrypted-domain columns — the v3 analogue of v2's
// `constraint_tests.rs`. Outside `scalars::` so the matrix-inventory snapshot
// does not mis-read it as a scalar type (same rationale as `signed`).
#[path = "encrypted_domain/constraints.rs"]
mod constraints;

// SteVec jsonb-entry behaviour matrix (the reduced `jsonb_entry_matrix!`).
// Deliberately NOT under `scalars::` — `JsonbEntryInt4` is not a catalog scalar,
// so its names live under `jsonb_entry::…` and are pinned by the separate
Expand Down
257 changes: 257 additions & 0 deletions tests/sqlx/tests/encrypted_domain/constraints.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
//! Table-level SQL constraint coverage for `eql_v3` encrypted-domain columns.
//!
//! The v2 surface covers UNIQUE / NOT NULL / FOREIGN KEY on `eql_v2_encrypted`
//! columns in `tests/sqlx/tests/constraint_tests.rs`. This is the equivalent
//! coverage for the jsonb-backed `eql_v3.<T>` domains (the reference scalar
//! `int4`). The domains are jsonb under the hood, so a table-level constraint
//! constrains the *raw jsonb payload value*, NOT the semantic plaintext or the
//! `eq_term` / `ord_term` index term — see the documented findings on each test.
//!
//! Like the sibling `signed.rs` suite it lives OUTSIDE the `scalars::`
//! namespace, so the matrix-inventory snapshot (which pins the uniform per-type
//! test set) does not mis-read it as a scalar type.
//!
//! All ciphertext is REAL: every payload comes from the committed/generated
//! `fixtures.eql_v2_int4` table (Proxy-encrypted, HMAC + ORE block terms) via
//! `fetch_fixture_payload::<i32>`. No synthetic / hand-written encrypted blobs.
//!
//! ## What a constraint on a jsonb-backed domain actually constrains
//!
//! The fixture table stores ONE fixed payload per plaintext. Two reads of the
//! same fixture row return byte-identical jsonb, so "insert the same fetched
//! payload twice" deterministically collides on a UNIQUE jsonb domain column,
//! and an FK referencing that exact jsonb value resolves. This is the same
//! deterministic-test-data property the v2 FK test relies on (see its PRODUCTION
//! LIMITATION comment). In production EQL encryption is non-deterministic at the
//! envelope level: two independent encryptions of the same plaintext produce
//! different jsonb (`c` differs), so a UNIQUE/FK over the raw jsonb domain value
//! provides byte-identity integrity, NOT semantic (plaintext-equality)
//! integrity. The hmac `eq_term` is what carries semantic equality; a UNIQUE
//! constraint on the bare domain column does not consult it.

use eql_tests::{assert_db_error, fetch_fixture_payload, sql_string_literal};
use sqlx::PgPool;

/// Fetch the real fixture ciphertext for an `int4` plaintext as an
/// escaped SQL string literal ready to interpolate as `{lit}::jsonb::<domain>`.
async fn int4_payload_literal(pool: &PgPool, plaintext: i32) -> anyhow::Result<String> {
let payload = fetch_fixture_payload::<i32>(pool, plaintext).await?;
Ok(sql_string_literal(&payload))
}

// ===========================================================================
// NOT NULL — on the storage-only `eql_v3.int4` domain column.
// ===========================================================================

/// A `NOT NULL` column attribute on an `eql_v3.int4` (storage) column rejects a
/// NULL insert (SQLSTATE 23502) and accepts a real encrypted value.
#[sqlx::test(fixtures(path = "../../fixtures", scripts("eql_v2_int4")))]
async fn not_null_on_int4_storage_column(pool: PgPool) -> anyhow::Result<()> {
sqlx::query("CREATE TABLE v3_not_null (id bigint PRIMARY KEY, val eql_v3.int4 NOT NULL)")
.execute(&pool)
.await?;

// NULL into the NOT NULL column is rejected. NOT NULL is a column attribute,
// not a named constraint, so `constraint()` is None — pin only the SQLSTATE
// (matches the v2 `not_null_constraint_on_encrypted_column` convention).
let err = sqlx::query("INSERT INTO v3_not_null (id, val) VALUES (1, NULL)")
.execute(&pool)
.await
.expect_err("NOT NULL must reject a NULL eql_v3.int4 value");
assert_db_error(&err, "23502", None);

let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM v3_not_null")
.fetch_one(&pool)
.await?;
assert_eq!(count, 0, "no row after the rejected NULL insert");

// A real encrypted value is accepted.
let lit = int4_payload_literal(&pool, 42).await?;
sqlx::query(&format!(
"INSERT INTO v3_not_null (id, val) VALUES (2, {lit}::jsonb::eql_v3.int4)"
))
.execute(&pool)
.await?;

let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM v3_not_null")
.fetch_one(&pool)
.await?;
assert_eq!(count, 1, "real encrypted value satisfies NOT NULL");

Ok(())
}

// ===========================================================================
// UNIQUE — on the equality `eql_v3.int4_eq` domain column.
//
// `_eq` is the interesting variant: equality routes through `eq_term` (hmac).
// But a bare UNIQUE constraint on the domain column does NOT use `eq_term` — it
// uses the base type's (jsonb) btree equality on the WHOLE payload. The test
// documents this: identical payload bytes collide; distinct payloads do not.
// ===========================================================================

/// A `UNIQUE` constraint on an `eql_v3.int4_eq` column rejects a second row
/// carrying the byte-identical fixture payload (23505) and accepts a different
/// plaintext's payload.
///
/// FINDING — UNIQUE here constrains the RAW JSONB payload value, not the
/// semantic plaintext nor the `eq_term` hmac. The constraint resolves against
/// the domain's base type (`jsonb`) btree equality over the full payload object.
/// Because the fixture returns one fixed payload per plaintext, re-inserting the
/// SAME fetched payload is a byte-identical jsonb and collides. Two DIFFERENT
/// plaintexts have different payloads and are both accepted. In production, two
/// independent (non-deterministic) encryptions of the SAME plaintext would NOT
/// collide on this constraint despite being semantically equal — UNIQUE on a
/// bare encrypted-domain column is byte-identity uniqueness, not
/// plaintext-uniqueness.
#[sqlx::test(fixtures(path = "../../fixtures", scripts("eql_v2_int4")))]
async fn unique_on_int4_eq_column_constrains_raw_payload(pool: PgPool) -> anyhow::Result<()> {
sqlx::query(
"CREATE TABLE v3_unique (id bigint PRIMARY KEY, val eql_v3.int4_eq UNIQUE NOT NULL)",
)
.execute(&pool)
.await?;

let p42 = int4_payload_literal(&pool, 42).await?;
let p100 = int4_payload_literal(&pool, 100).await?;

// First insert of the 42-payload succeeds.
sqlx::query(&format!(
"INSERT INTO v3_unique (id, val) VALUES (1, {p42}::jsonb::eql_v3.int4_eq)"
))
.execute(&pool)
.await?;

let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM v3_unique")
.fetch_one(&pool)
.await?;
assert_eq!(count, 1, "first encrypted value inserted");

// A DIFFERENT plaintext's payload (distinct jsonb) is accepted — UNIQUE does
// not reject distinct payloads.
sqlx::query(&format!(
"INSERT INTO v3_unique (id, val) VALUES (2, {p100}::jsonb::eql_v3.int4_eq)"
))
.execute(&pool)
.await?;

let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM v3_unique")
.fetch_one(&pool)
.await?;
assert_eq!(count, 2, "distinct encrypted value accepted under UNIQUE");

// Re-inserting the BYTE-IDENTICAL 42-payload violates UNIQUE (23505). The
// constraint name is `<table>_<column>_key` per PostgreSQL's auto-naming.
let err = sqlx::query(&format!(
"INSERT INTO v3_unique (id, val) VALUES (3, {p42}::jsonb::eql_v3.int4_eq)"
))
.execute(&pool)
.await
.expect_err("UNIQUE must reject the byte-identical payload");
assert_db_error(&err, "23505", Some("v3_unique_val_key"));

let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM v3_unique")
.fetch_one(&pool)
.await?;
assert_eq!(count, 2, "count unchanged after the rejected duplicate");

Ok(())
}

// ===========================================================================
// FOREIGN KEY — child referencing a parent `eql_v3.int4` PRIMARY KEY column.
//
// FK on a jsonb-backed domain IS feasible: a PRIMARY KEY / UNIQUE on the parent
// column resolves against the base type (`jsonb`) btree opclass (jsonb has a
// default btree opclass), so the referenced column has the unique index FK
// requires. This is distinct from the "no operator class on a domain" footgun,
// which is about adding a *custom* index opclass to a domain — a plain
// PK/UNIQUE uses the inherited jsonb btree opclass and works.
// ===========================================================================

/// A FOREIGN KEY from a child `eql_v3.int4` column to a parent `eql_v3.int4`
/// PRIMARY KEY column: a matching (byte-identical) reference is accepted, a
/// dangling reference is rejected (23503).
///
/// FINDING — FK on a jsonb-backed `eql_v3` domain is FEASIBLE. The parent
/// PRIMARY KEY resolves against the inherited jsonb btree opclass, giving FK the
/// unique index it requires; no custom domain opclass is involved (so the
/// "no operator class on a domain" footgun does not apply to a plain PK). As
/// with v2 and with UNIQUE above, referential integrity is over the RAW JSONB
/// payload (byte identity), not the semantic plaintext: the child reference
/// resolves only because the test reuses the exact fixture payload bytes. Under
/// production non-deterministic encryption, a re-encryption of the same
/// plaintext would be a different jsonb and would NOT satisfy the FK — so FK on
/// a bare encrypted-domain column does not provide plaintext-level referential
/// integrity.
#[sqlx::test(fixtures(path = "../../fixtures", scripts("eql_v2_int4")))]
async fn foreign_key_on_int4_domain_columns(pool: PgPool) -> anyhow::Result<()> {
// Parent with a PRIMARY KEY on an eql_v3.int4 (jsonb-backed domain) column.
sqlx::query("CREATE TABLE v3_parent (ref eql_v3.int4 PRIMARY KEY)")
.execute(&pool)
.await?;

// Child referencing the parent encrypted column.
sqlx::query(
"CREATE TABLE v3_child (
id bigint PRIMARY KEY,
parent_ref eql_v3.int4 REFERENCES v3_parent(ref)
)",
)
.execute(&pool)
.await?;

// Sanity: the FK constraint exists.
let fk_exists: bool = sqlx::query_scalar(
"SELECT EXISTS (
SELECT FROM information_schema.table_constraints
WHERE table_name = 'v3_child' AND constraint_type = 'FOREIGN KEY'
)",
)
.fetch_one(&pool)
.await?;
assert!(fk_exists, "FK constraint must exist on v3_child");

let p42 = int4_payload_literal(&pool, 42).await?;
let p100 = int4_payload_literal(&pool, 100).await?;

// Seed the parent with the 42-payload.
sqlx::query(&format!(
"INSERT INTO v3_parent (ref) VALUES ({p42}::jsonb::eql_v3.int4)"
))
.execute(&pool)
.await?;

// Child row with a byte-identical reference resolves (deterministic fixture
// bytes), so the FK is satisfied.
sqlx::query(&format!(
"INSERT INTO v3_child (id, parent_ref) VALUES (1, {p42}::jsonb::eql_v3.int4)"
))
.execute(&pool)
.await?;

let child_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM v3_child")
.fetch_one(&pool)
.await?;
assert_eq!(child_count, 1, "matching FK reference accepted");

// Child row referencing a payload NOT present in the parent (different
// plaintext → different jsonb) violates the FK (23503).
let err = sqlx::query(&format!(
"INSERT INTO v3_child (id, parent_ref) VALUES (2, {p100}::jsonb::eql_v3.int4)"
))
.execute(&pool)
.await
.expect_err("FK must reject a dangling reference");
assert_db_error(&err, "23503", Some("v3_child_parent_ref_fkey"));

let child_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM v3_child")
.fetch_one(&pool)
.await?;
assert_eq!(
child_count, 1,
"count unchanged after the rejected FK insert"
);

Ok(())
}
Loading