diff --git a/CHANGELOG.md b/CHANGELOG.md index a7cde151..1eeb6bd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/mise.toml b/mise.toml index 3b5600c0..45db8c82 100644 --- a/mise.toml +++ b/mise.toml @@ -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 diff --git a/src/v3/sem/ore_cllw/functions.sql b/src/v3/sem/ore_cllw/functions.sql index bd34ac8c..51ed535c 100644 --- a/src/v3/sem/ore_cllw/functions.sql +++ b/src/v3/sem/ore_cllw/functions.sql @@ -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; diff --git a/tests/sqlx/tests/encrypted_domain.rs b/tests/sqlx/tests/encrypted_domain.rs index 58589585..e365155c 100644 --- a/tests/sqlx/tests/encrypted_domain.rs +++ b/tests/sqlx/tests/encrypted_domain.rs @@ -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 diff --git a/tests/sqlx/tests/encrypted_domain/constraints.rs b/tests/sqlx/tests/encrypted_domain/constraints.rs new file mode 100644 index 00000000..ef466513 --- /dev/null +++ b/tests/sqlx/tests/encrypted_domain/constraints.rs @@ -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.` 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::`. 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::`. +async fn int4_payload_literal(pool: &PgPool, plaintext: i32) -> anyhow::Result { + let payload = fetch_fixture_payload::(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 `__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(()) +}