From 0883fb10a019d6a3ea863c3002b0161d9f33374d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 29 Oct 2026 22:09:20 +1100 Subject: [PATCH] fix(eql): block native jsonb functions on the v4 JSON tree domains public.eql_v4_json_search and public.eql_v4_json_entry are domains over jsonb, and the blockers covered only the operators. The function forms resolved to pg_catalog: jsonb_path_match(col, $1::jsonpath, $2::jsonb) ran over the stored envelope, found none of the user's keys and returned false for every row, with no error. Each blocked function now has an overload in public with the domain as its first argument and the native signature's other arguments, names and defaults, raising feature_not_supported with the EQL query to use instead. PostgreSQL matches argument types exactly before it treats a domain as its base type, which is what lets the overload win; with any untyped argument there is no exact match and the native function still runs, so the limit is documented rather than closed. The bodies raise on their own, not through eql_v4_internal, so they keep guarding the surviving domains after an uninstall, and CREATE OR REPLACE lets a reinstall run over them. jsonb_array_elements, _text and jsonb_array_length are left out: the native function already raises on both domains, and every overload makes an untyped first argument on plain jsonb ambiguous. Every internal caller of these functions passes jsonb (a parameter declared jsonb, or an explicit ::jsonb cast), so none needed a cast. With the v4 bundle installed, a call to any of the 20 functions whose first argument is an untyped literal or parameter is ambiguous on plain jsonb too. Upgrade note U-004 in the 3.1 guide lists them and the one cast that resolves it. The tests run over a document Stack Encrypt seals under the fake key source (json_tree::blockers in the encryption test crate), as every JSON tree test with a stored document does, so the reads they check are over the envelope the index writes. Closes #1439 --- .changeset/eql-json-tree-function-blockers.md | 9 + .../eql/docs/reference/json-tree-leakage.md | 19 + packages/eql/docs/upgrading/v3.1.md | 122 ++++ packages/eql/src/v3/json_tree/blockers.sql | 688 ++++++++++++++++++ .../eql/tests/encryption/tests/json_tree.rs | 4 + .../encryption/tests/json_tree/blockers.rs | 474 ++++++++++++ 6 files changed, 1316 insertions(+) create mode 100644 .changeset/eql-json-tree-function-blockers.md create mode 100644 packages/eql/tests/encryption/tests/json_tree/blockers.rs diff --git a/.changeset/eql-json-tree-function-blockers.md b/.changeset/eql-json-tree-function-blockers.md new file mode 100644 index 000000000..409377370 --- /dev/null +++ b/.changeset/eql-json-tree-function-blockers.md @@ -0,0 +1,9 @@ +--- +"@cipherstash/eql": patch +--- + +EQL v4 `json_tree`: calling a native jsonpath or extraction function on a `public.eql_v4_json_search` column, or on an entry extracted from one, now raises `feature_not_supported` (SQLSTATE `0A000`) instead of returning nothing (#1439). + +The column is a domain over `jsonb`, so `jsonb_path_match(col, $1::jsonpath, $2::jsonb)` used to resolve to PostgreSQL's own function, which ran over the stored ciphertext envelope rather than the document and returned false for every row, with no error. EQL now installs overloads in `public` for `jsonb_path_exists`, `jsonb_path_match`, `jsonb_path_query`, `jsonb_path_query_array` and `jsonb_path_query_first` (and their `_tz` forms), `jsonb_extract_path`, `jsonb_extract_path_text`, `jsonb_each`, `jsonb_each_text`, `jsonb_object_keys`, `jsonb_typeof`, `jsonb_strip_nulls`, `jsonb_set`, `jsonb_insert` and `jsonb_pretty` on both `public.eql_v4_json_search` and `public.eql_v4_json_entry`. Each raises with a message naming the function and what to do instead. On a document, the jsonpath and extraction blockers name the EQL query to use (`@>` with an `eql_v4.query_json` needle, `col -> selector`, or a subtree walk), and `jsonb_typeof`, `jsonb_set`, `jsonb_insert`, `jsonb_strip_nulls` and `jsonb_pretty` direct the caller to decrypt the document in the client. On an entry, `jsonb_path_exists` and `jsonb_path_match` name the comparison with an `eql_v4.query_json_leaf`, and the others direct the caller to decrypt in the client. + +Two limits, documented in `docs/reference/json-tree-leakage.md`. A call is caught only when every argument has a type: a bare literal (`jsonb_path_match(col, '$.a == 1')`) or a parameter sent without a type still reaches the native function, so cast every argument. And with the `eql_v4` bundle installed, a call to any of these 20 functions whose first argument is an untyped literal or an untyped bound parameter now fails with `function … is not unique` (SQLSTATE `42725`), on plain `jsonb` too: `jsonb_typeof('{}')`, `jsonb_typeof($1)`, `jsonb_path_exists($1, '$.a')`. Cast the first argument to `jsonb`; a `jsonb` column or a parameter typed `jsonb` is unaffected. See [U-004](../../docs/upgrading/v3.1.md#u-004-an-untyped-first-argument-to-20-jsonb-functions-is-ambiguous). diff --git a/packages/eql/docs/reference/json-tree-leakage.md b/packages/eql/docs/reference/json-tree-leakage.md index c370946d7..fde7fbf7d 100644 --- a/packages/eql/docs/reference/json-tree-leakage.md +++ b/packages/eql/docs/reference/json-tree-leakage.md @@ -6,6 +6,7 @@ - [What a stored row shows](#what-a-stored-row-shows) - [What a query shows](#what-a-query-shows) +- [Native jsonb operators and functions are blocked](#native-jsonb-operators-and-functions-are-blocked) - [What is authenticated](#what-is-authenticated) - [Compared with EQL v3 (`public.eql_v3_json_search`)](#compared-with-eql-v3-publiceql_v3_json_search) - [The two options that change the picture](#the-two-options-that-change-the-picture) @@ -51,6 +52,24 @@ A query hands the database a term derived from the plaintext it asks about, and - **The root.** A read of `$` sends the root's token, and a read of anything below `$` does not. With the sealed root on (the default) that token covers no document's structure: it finds the root entry, whose ciphertext is the document, and nothing beneath it, since the top-level fields chain from nothing. With it off, the root's token covers every row's whole document. - **`json_tree_children` and `json_tree_child_count`** take one token, of any node a path names, and send nothing else. Under the sealed root the root's token enumerates nothing (its count is NULL), for the same reason. +## Native jsonb operators and functions are blocked + +`public.eql_v4_json_search` and `public.eql_v4_json_entry` are PostgreSQL domains over `jsonb`, so PostgreSQL's own `jsonb` operators and functions accept them. Run on a column, they would see the stored envelope (`{v, i, h, sv, q}`), not the document: `jsonb_path_match(col, '$.score == 3'::jsonpath)` finds no `score` key and returns false for every row, with no error. EQL blocks them so that they raise instead: + +- **Operators.** `?`, `?|`, `?&`, `@?`, `@@`, `#>`, `#>>`, `-`, `#-`, `||`, `->` and `->>` by position, root-document comparisons, and containment against a plain `jsonb` operand raise `operator … is not supported for public.eql_v4_json_search`. +- **Functions.** `jsonb_path_exists`, `jsonb_path_match`, `jsonb_path_query`, `jsonb_path_query_array`, `jsonb_path_query_first` (and their `_tz` forms, with or without `vars` and `silent`), `jsonb_extract_path`, `jsonb_extract_path_text`, `jsonb_each`, `jsonb_each_text`, `jsonb_object_keys`, `jsonb_typeof`, `jsonb_strip_nulls`, `jsonb_set`, `jsonb_insert` and `jsonb_pretty` raise SQLSTATE `0A000` (`feature_not_supported`) on a document or an entry, naming the function and what to use instead: `@>` with an `eql_v4.query_json` needle for equality, `col -> selector` compared with an `eql_v4.query_json_leaf` for ranges, `col -> selector`, `eql_v4.json_tree_path_query` or `eql_v4.json_tree_subtree` to extract, then decryption in the client. `jsonb_array_elements`, `jsonb_array_elements_text` and `jsonb_array_length` are not blocked: they already raise on both domains, because the envelope and an entry are objects. + +The function blockers are overloads in `public` that take the domain as their first argument. PostgreSQL picks one only when **every argument has a type**, so two forms still reach the native function, which reads the stored envelope: a key of the document returns nothing there, but a key of the envelope, such as `v`, returns the envelope's own value (`jsonb_extract_path(col, 'v')` is `4`): + +- a bare literal anywhere in the call, as in `jsonb_path_match(col, '$.a == 1', '{}')`; +- a parameter the driver sends without a type, as in `jsonb_path_match(col, $1)`. + +Cast every argument, `jsonb_path_match(col, $1::jsonpath, $2::jsonb)`, and the call raises. No overload can close the untyped forms. + +The overloads have one cost outside EQL's columns. A call on plain `jsonb` whose first argument is untyped, such as `jsonb_typeof($1)` or `jsonb_each('{"a": 1}')`, now has two candidates, and PostgreSQL refuses it as ambiguous (`function … is not unique`). Cast that argument (`$1::jsonb`). The jsonb operators behave the same way once EQL is installed. [U-004 in the 3.1 upgrade guide](../upgrading/v3.1.md#u-004-an-untyped-first-argument-to-20-jsonb-functions-is-ambiguous) lists the 20 functions. + +To read the stored envelope on purpose, cast the column: `jsonb_pretty(col::jsonb)`. + ## What is authenticated Only the ciphertext `c`. Opening an entry verifies that it was sealed under this keyset, for this column, under the data key its row's header names (a fresh one for every row), with the selector `s` and the slot `j` it is opened with, and over the AEAD tags of exactly the children it is opened with: each entry's AAD binds a digest of its children's tags, in order. A ciphertext moved into another row or column, or opened under another selector, slot or set of children, fails. Nothing in it names a row, so an entry that travels with its own row's header still opens; see below. The selectors, the projection, the parent tags, the self tags, the ordering terms, the header's sealed-root flag, and which entries a row holds are not authenticated, by the same rule every EQL stored value follows (ADR 0010 in `packages/stack-encrypt/docs/adr`). diff --git a/packages/eql/docs/upgrading/v3.1.md b/packages/eql/docs/upgrading/v3.1.md index c26b9bcb6..3fa1a4b79 100644 --- a/packages/eql/docs/upgrading/v3.1.md +++ b/packages/eql/docs/upgrading/v3.1.md @@ -13,6 +13,11 @@ terms this release's `eql_v4` expects. The notes below are for anyone who builds `eql_v4` payloads by hand, or who stored `eql_v4` block ORE terms before this release. +One more change reaches SQL that never touches an EQL column: installing +the `eql_v4` bundle, which carries the new JSON tree family, makes a call to +20 jsonb functions ambiguous when its first argument has no type +([U-004](#u-004-an-untyped-first-argument-to-20-jsonb-functions-is-ambiguous)). + ## TL;DR 1. An `eql_v4` text search query carries only the term its operator reads, @@ -30,6 +35,12 @@ before this release. `eql_v4` bundle must drop its stored order domains before installing this one — [U-003](#u-003-an-order-term-is-one-hex-string-checked-when-it-is-written). +4. With the `eql_v4` bundle installed, a call to one of 20 jsonb functions + (`jsonb_typeof`, `jsonb_path_exists`, `jsonb_extract_path`, …) whose + **first argument is an untyped literal or an untyped bound parameter + fails** with `function … is not unique` (SQLSTATE `42725`), on plain + `jsonb` too. Cast that argument to `jsonb` — + [U-004](#u-004-an-untyped-first-argument-to-20-jsonb-functions-is-ambiguous). ## Compatibility @@ -44,6 +55,7 @@ before this release. | `ob` in every `eql_v4` block ORE domain and range bound | **Wire shape changed.** One hex string, `"ob": "0202…"`, where it was a one-element array. The CHECK requires a string starting `0202` or `0203`. See U-003. | | `op` in every `eql_v4` OPE domain and range bound | **Tightened CHECK.** A string starting `00` or `01`, the term's carry byte. The term itself is unchanged. See U-003. | | `eql_v4` equality (`hm`) terms, ciphertexts (`c`) | **Unchanged** by this release. | +| 20 jsonb functions (`jsonb_typeof`, the `jsonb_path_*` family, `jsonb_extract_path`, …) called with an untyped first argument | **Now ambiguous** (SQLSTATE `42725`) once the `eql_v4` bundle is installed, on plain `jsonb` too. A `jsonb` column, a `::jsonb` cast, or a parameter typed `jsonb` is unaffected. See U-004. | | Postgres 14, 15, 16, 17 | Supported. | | Supabase / managed Postgres (no superuser) | As before: the block ORE domains need the operator class the installer creates, so a non-superuser install disables them ([U-003 in the 3.0 guide](./v3.0.md#u-003-non-superuser-installs-disable-the-ore-backed-domains)). U-002 only concerns installs where they are enabled. | @@ -238,6 +250,89 @@ SELECT '{"v": 4, "i": {"t": "t", "c": "c"}, "c": "x", "ob": ["0202"]}'::jsonb -- ERROR: value for domain eql_v4_integer_ord_ore violates check constraint ``` +### U-004: An untyped first argument to 20 jsonb functions is ambiguous + +**What changed.** The `eql_v4` bundle carries the JSON tree family, whose +stored document `public.eql_v4_json_search` and extracted entry +`public.eql_v4_json_entry` are domains over `jsonb`. So that a native jsonb +function called on them raises instead of reading the stored envelope, the +install adds an overload in `public` of each of these 20 functions on each +of the two domains, 40 in all: + +| Functions | | +| --- | --- | +| `jsonb_path_exists`, `jsonb_path_exists_tz` | `jsonb_path_match`, `jsonb_path_match_tz` | +| `jsonb_path_query`, `jsonb_path_query_tz` | `jsonb_path_query_array`, `jsonb_path_query_array_tz` | +| `jsonb_path_query_first`, `jsonb_path_query_first_tz` | `jsonb_extract_path`, `jsonb_extract_path_text` | +| `jsonb_each`, `jsonb_each_text` | `jsonb_object_keys`, `jsonb_typeof` | +| `jsonb_strip_nulls`, `jsonb_set` | `jsonb_insert`, `jsonb_pretty` | + +With `public` on the search path, a call to any of them whose **first +argument has no type** now has more than one candidate, PostgreSQL's own +function and the overloads, and PostgreSQL refuses it. That holds when the +call has nothing to do with an EQL column: + +```text +SELECT jsonb_typeof('{}'); +ERROR: function jsonb_typeof(unknown) is not unique +SQLSTATE: 42725 (ambiguous_function) +``` + +An untyped first argument is a bare string literal, as in +`jsonb_typeof('{}')` or `jsonb_each('{"a": 1}')`, or a bound parameter the +driver sends without a type and the SQL does not cast, as in +`jsonb_typeof($1)` or `jsonb_path_exists($1, '$.a')`. The statement fails +when it is parsed or prepared, before it reads a row. + +These are **unaffected**: a first argument that is a `jsonb` column or +expression, one cast to `jsonb` (`'{}'::jsonb`, `$1::jsonb`), and a +parameter the driver declares as `jsonb`. Only the first argument decides +this; the others may stay untyped. `jsonb_array_elements`, +`jsonb_array_elements_text` and `jsonb_array_length` have no overload and +are unaffected. + +**Why.** On the two domains, the native functions run over the stored +envelope rather than the document, so `jsonb_path_match(col, $1::jsonpath, +$2::jsonb)` returned false for every row, with no error. The overloads make +those calls raise `feature_not_supported` (`0A000`) instead. A literal of +unknown type can be read as `jsonb` or as either domain, so PostgreSQL has +no single best candidate. The jsonb operators already behave this way with +EQL installed (`'{"a": 1}' -> 'a'` is `operator is not unique`). + +**Who is affected.** Any SQL in a database where the `eql_v4` bundle is +installed that calls one of the 20 functions with an untyped first +argument, whatever table it reads. Drivers commonly prepare parameters +without a type, so look for calls that pass a parameter straight in as the +first argument. A database with only the `eql_v3` bundle is not affected: +the overloads are in the `eql_v4` bundle alone. + +**What to do.** Cast the first argument to `jsonb`, once: + +```sql +SELECT jsonb_typeof($1::jsonb); +SELECT jsonb_path_exists($1::jsonb, '$.a'); +SELECT jsonb_each('{"a": 1}'::jsonb); +``` + +To find the calls, search your SQL for each of the 20 names above followed +by `(` and a parameter or a quoted literal. + +**Verification.** + +```sql +-- 40 overloads, 20 on each domain: +SELECT count(*) +FROM pg_proc p +WHERE p.pronamespace = 'public'::regnamespace + AND p.pronargs >= 1 + AND p.proargtypes[0] IN ('public.eql_v4_json_search'::regtype, + 'public.eql_v4_json_entry'::regtype) + AND starts_with(p.proname::text, 'jsonb_'); + +SELECT jsonb_typeof('{}'); -- ERROR: 42725, not unique +SELECT jsonb_typeof('{}'::jsonb); -- object +``` + ## Verification checklist - [ ] The `eql_v4` install reports `3.1.0` (`SELECT obj_description('eql_v4'::regnamespace, 'pg_namespace')`). @@ -245,6 +340,7 @@ SELECT '{"v": 4, "i": {"t": "t", "c": "c"}, "c": "x", "ob": ["0202"]}'::jsonb - [ ] `eql_v4_internal` lists `ore_block_v2` objects and no `ore_block_256` (U-002). - [ ] Every `_ord_ore` / `text_search_ore` column passes the self-comparison scan (U-002). - [ ] The stored `eql_v4` order domains require a hex string with a scheme prefix (U-003). +- [ ] No call to one of the 20 jsonb functions passes an untyped first argument (U-004). - [ ] Grants and functional indexes have been re-applied after the install ([U-002 in the 3.0.5 guide](./v3.0.5.md#u-002-grants-and-dependent-objects-do-not-survive-an-install)). @@ -264,6 +360,32 @@ reversal misreads data: block ORE domains and order through the `_ord` / `_ord_ope` domains, whose terms this release does not change. +U-004 is not reversed by installing an earlier bundle, nor by the `eql_v4` +uninstaller. The overloads live in `public` beside the JSON tree domains, +which survive both by design, and they keep raising on those domains. To +remove them, drop them; nothing else depends on them: + +```sql +DO $$ +DECLARE r record; +BEGIN + FOR r IN + SELECT p.oid::regprocedure AS f + FROM pg_proc p + WHERE p.pronamespace = 'public'::regnamespace + AND p.pronargs >= 1 + AND p.proargtypes[0] IN ('public.eql_v4_json_search'::regtype, + 'public.eql_v4_json_entry'::regtype) + AND starts_with(p.proname::text, 'jsonb_') + LOOP + EXECUTE 'DROP FUNCTION ' || r.f; + END LOOP; +END $$; +``` + +A native jsonb function called on a JSON tree column then reads the stored +envelope again, without an error. + Any install, in either direction, re-runs `DROP SCHEMA … CASCADE`, so grants and dependent objects need re-applying afterwards. diff --git a/packages/eql/src/v3/json_tree/blockers.sql b/packages/eql/src/v3/json_tree/blockers.sql index 57615112a..d996a287f 100644 --- a/packages/eql/src/v3/json_tree/blockers.sql +++ b/packages/eql/src/v3/json_tree/blockers.sql @@ -2,6 +2,7 @@ -- REQUIRE: src/v3/schema.sql -- REQUIRE: src/v3/json_tree/types.sql -- REQUIRE: src/v3/scalars/functions.sql +-- REQUIRE: src/v3/json_tree/functions.sql --! @file v3/json_tree/blockers.sql --! @brief Native-jsonb firewall for public.{{prefix}}_json_search. @@ -26,6 +27,10 @@ --! helpers. Each blocker's RETURNS type matches the native operator it shadows --! so a composed expression resolves and the body raises 'operator not --! supported', rather than failing earlier on a boolean intermediate. +--! +--! The native jsonb FUNCTIONS that would run over the envelope are blocked +--! too, by overloads in `public` at the end of this file: see "Function-form +--! blockers" below. --! @brief Blocker: ? (key/element exists). --! @param a public.{{prefix}}_json_search Left operand (encrypted payload). @@ -1116,3 +1121,686 @@ CREATE OPERATOR <@ ( LEFTARG = jsonb, RIGHTARG = public.{{prefix}}_json_search ); + +------------------------------------------------------------------------------ +-- Function-form blockers +------------------------------------------------------------------------------ + +--! Function-form blockers: native jsonb functions on the document and entry domains. +--! +--! The operators above leave the FUNCTION forms of the same operations open: +--! `jsonb_path_match(col, $1::jsonpath)` on a domain over jsonb resolves to +--! the native `pg_catalog` function, which runs over the stored envelope +--! (`{v, i, h, sv, q}`), finds none of the user's keys and returns false for +--! every row, with no error. Each overload below takes the domain as its +--! first argument and the native signature's other arguments (names and +--! defaults included, so a short or a named call resolves here too), and +--! raises `feature_not_supported` naming the function and the EQL operation +--! to use instead. +--! +--! They live in `public`, beside the domains: callers name these functions +--! unqualified, so an overload is a candidate only in a schema on the +--! caller's search path, and `public` is the one EQL already puts the +--! domains in. PostgreSQL looks +--! for an exact match on the argument types before it treats a domain as its +--! base type, which is what lets the overload win over `pg_catalog`; that is +--! the rule the operator blockers rely on too. It is also the overload's +--! limit: when ANY argument is untyped (a bare literal such as +--! `jsonb_path_match(col, '$.a == 1')`, or a parameter the driver sends with +--! no type), there is no exact match, the column is treated as jsonb, and +--! the native function runs. Only a call whose every argument has a type is +--! caught. An overload cannot close that, and nothing here tries to. +--! +--! The bodies raise on their own rather than through the +--! `{{prefix}}_internal` helpers: the domains outlive an uninstall of the +--! EQL schemas, and these overloads, which depend on nothing in them, go on +--! guarding the columns. `CREATE OR REPLACE`, like the CHECK validators in +--! `public`, so a reinstall over surviving domains succeeds. +--! +--! Deliberately NOT blocked: `jsonb_array_elements`, +--! `jsonb_array_elements_text` and `jsonb_array_length`. On either domain +--! the native function already raises (the envelope and an entry are +--! objects), so an overload buys only a different message, and costs what +--! every overload here costs: a plain-jsonb call whose first argument is +--! untyped, such as `jsonb_array_elements($1)` from a driver that sends no +--! parameter types, becomes ambiguous ('function ... is not unique') once +--! the overload exists, and must cast (`$1::jsonb`). The jsonb operators +--! above already impose that on untyped operands. + +--! @brief Blocker: jsonb_path_exists(public.{{prefix}}_json_search, ...). +--! @param target public.{{prefix}}_json_search The encrypted document. +--! @param path jsonpath Native argument. +--! @param vars jsonb Native argument. +--! @param silent boolean Native argument. +--! @return boolean Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_path_exists(target public.{{prefix}}_json_search, path jsonpath, vars jsonb DEFAULT '{}'::jsonb, silent boolean DEFAULT false) +RETURNS boolean +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_path_exists is not supported for public.{{prefix}}_json_search: a jsonpath runs over the encrypted envelope, not the document, and matches nothing; query with EQL: col @> needle::{{prefix}}.query_json, or compare col -> selector with an {{prefix}}.query_json_leaf' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_path_exists_tz(public.{{prefix}}_json_search, ...). +--! @param target public.{{prefix}}_json_search The encrypted document. +--! @param path jsonpath Native argument. +--! @param vars jsonb Native argument. +--! @param silent boolean Native argument. +--! @return boolean Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_path_exists_tz(target public.{{prefix}}_json_search, path jsonpath, vars jsonb DEFAULT '{}'::jsonb, silent boolean DEFAULT false) +RETURNS boolean +STABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_path_exists_tz is not supported for public.{{prefix}}_json_search: a jsonpath runs over the encrypted envelope, not the document, and matches nothing; query with EQL: col @> needle::{{prefix}}.query_json, or compare col -> selector with an {{prefix}}.query_json_leaf' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_path_match(public.{{prefix}}_json_search, ...). +--! @param target public.{{prefix}}_json_search The encrypted document. +--! @param path jsonpath Native argument. +--! @param vars jsonb Native argument. +--! @param silent boolean Native argument. +--! @return boolean Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_path_match(target public.{{prefix}}_json_search, path jsonpath, vars jsonb DEFAULT '{}'::jsonb, silent boolean DEFAULT false) +RETURNS boolean +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_path_match is not supported for public.{{prefix}}_json_search: a jsonpath runs over the encrypted envelope, not the document, and matches nothing; query with EQL: col @> needle::{{prefix}}.query_json, or compare col -> selector with an {{prefix}}.query_json_leaf' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_path_match_tz(public.{{prefix}}_json_search, ...). +--! @param target public.{{prefix}}_json_search The encrypted document. +--! @param path jsonpath Native argument. +--! @param vars jsonb Native argument. +--! @param silent boolean Native argument. +--! @return boolean Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_path_match_tz(target public.{{prefix}}_json_search, path jsonpath, vars jsonb DEFAULT '{}'::jsonb, silent boolean DEFAULT false) +RETURNS boolean +STABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_path_match_tz is not supported for public.{{prefix}}_json_search: a jsonpath runs over the encrypted envelope, not the document, and matches nothing; query with EQL: col @> needle::{{prefix}}.query_json, or compare col -> selector with an {{prefix}}.query_json_leaf' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_path_query(public.{{prefix}}_json_search, ...). +--! @param target public.{{prefix}}_json_search The encrypted document. +--! @param path jsonpath Native argument. +--! @param vars jsonb Native argument. +--! @param silent boolean Native argument. +--! @return SETOF jsonb Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_path_query(target public.{{prefix}}_json_search, path jsonpath, vars jsonb DEFAULT '{}'::jsonb, silent boolean DEFAULT false) +RETURNS SETOF jsonb +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_path_query is not supported for public.{{prefix}}_json_search: a jsonpath runs over the encrypted envelope, not the document, and finds nothing; extract with EQL: col -> selector, {{prefix}}.json_tree_path_query(col, selector) or {{prefix}}.json_tree_subtree(col, tokens), and decrypt in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_path_query_tz(public.{{prefix}}_json_search, ...). +--! @param target public.{{prefix}}_json_search The encrypted document. +--! @param path jsonpath Native argument. +--! @param vars jsonb Native argument. +--! @param silent boolean Native argument. +--! @return SETOF jsonb Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_path_query_tz(target public.{{prefix}}_json_search, path jsonpath, vars jsonb DEFAULT '{}'::jsonb, silent boolean DEFAULT false) +RETURNS SETOF jsonb +STABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_path_query_tz is not supported for public.{{prefix}}_json_search: a jsonpath runs over the encrypted envelope, not the document, and finds nothing; extract with EQL: col -> selector, {{prefix}}.json_tree_path_query(col, selector) or {{prefix}}.json_tree_subtree(col, tokens), and decrypt in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_path_query_array(public.{{prefix}}_json_search, ...). +--! @param target public.{{prefix}}_json_search The encrypted document. +--! @param path jsonpath Native argument. +--! @param vars jsonb Native argument. +--! @param silent boolean Native argument. +--! @return jsonb Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_path_query_array(target public.{{prefix}}_json_search, path jsonpath, vars jsonb DEFAULT '{}'::jsonb, silent boolean DEFAULT false) +RETURNS jsonb +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_path_query_array is not supported for public.{{prefix}}_json_search: a jsonpath runs over the encrypted envelope, not the document, and finds nothing; extract with EQL: col -> selector, {{prefix}}.json_tree_path_query(col, selector) or {{prefix}}.json_tree_subtree(col, tokens), and decrypt in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_path_query_array_tz(public.{{prefix}}_json_search, ...). +--! @param target public.{{prefix}}_json_search The encrypted document. +--! @param path jsonpath Native argument. +--! @param vars jsonb Native argument. +--! @param silent boolean Native argument. +--! @return jsonb Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_path_query_array_tz(target public.{{prefix}}_json_search, path jsonpath, vars jsonb DEFAULT '{}'::jsonb, silent boolean DEFAULT false) +RETURNS jsonb +STABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_path_query_array_tz is not supported for public.{{prefix}}_json_search: a jsonpath runs over the encrypted envelope, not the document, and finds nothing; extract with EQL: col -> selector, {{prefix}}.json_tree_path_query(col, selector) or {{prefix}}.json_tree_subtree(col, tokens), and decrypt in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_path_query_first(public.{{prefix}}_json_search, ...). +--! @param target public.{{prefix}}_json_search The encrypted document. +--! @param path jsonpath Native argument. +--! @param vars jsonb Native argument. +--! @param silent boolean Native argument. +--! @return jsonb Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_path_query_first(target public.{{prefix}}_json_search, path jsonpath, vars jsonb DEFAULT '{}'::jsonb, silent boolean DEFAULT false) +RETURNS jsonb +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_path_query_first is not supported for public.{{prefix}}_json_search: a jsonpath runs over the encrypted envelope, not the document, and finds nothing; extract with EQL: col -> selector, {{prefix}}.json_tree_path_query(col, selector) or {{prefix}}.json_tree_subtree(col, tokens), and decrypt in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_path_query_first_tz(public.{{prefix}}_json_search, ...). +--! @param target public.{{prefix}}_json_search The encrypted document. +--! @param path jsonpath Native argument. +--! @param vars jsonb Native argument. +--! @param silent boolean Native argument. +--! @return jsonb Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_path_query_first_tz(target public.{{prefix}}_json_search, path jsonpath, vars jsonb DEFAULT '{}'::jsonb, silent boolean DEFAULT false) +RETURNS jsonb +STABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_path_query_first_tz is not supported for public.{{prefix}}_json_search: a jsonpath runs over the encrypted envelope, not the document, and finds nothing; extract with EQL: col -> selector, {{prefix}}.json_tree_path_query(col, selector) or {{prefix}}.json_tree_subtree(col, tokens), and decrypt in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_extract_path(public.{{prefix}}_json_search, ...). +--! @param from_json public.{{prefix}}_json_search The encrypted document. +--! @param path_elems text[] Native argument. +--! @return jsonb Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_extract_path(from_json public.{{prefix}}_json_search, VARIADIC path_elems text[]) +RETURNS jsonb +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_extract_path is not supported for public.{{prefix}}_json_search: the keys of the document are encrypted, so a key path finds nothing; extract with EQL: col -> selector, and decrypt the entry in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_extract_path_text(public.{{prefix}}_json_search, ...). +--! @param from_json public.{{prefix}}_json_search The encrypted document. +--! @param path_elems text[] Native argument. +--! @return text Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_extract_path_text(from_json public.{{prefix}}_json_search, VARIADIC path_elems text[]) +RETURNS text +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_extract_path_text is not supported for public.{{prefix}}_json_search: the keys of the document are encrypted, so a key path finds nothing; extract with EQL: col -> selector, and decrypt the entry in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_each(public.{{prefix}}_json_search, ...). +--! @param from_json public.{{prefix}}_json_search The encrypted document. +--! @return SETOF record (key text, value jsonb) Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_each(from_json public.{{prefix}}_json_search, OUT key text, OUT value jsonb) +RETURNS SETOF record +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_each is not supported for public.{{prefix}}_json_search: it would list the encrypted envelope, not the document; list the children of a node with {{prefix}}.json_tree_children(col, token), and decrypt in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_each_text(public.{{prefix}}_json_search, ...). +--! @param from_json public.{{prefix}}_json_search The encrypted document. +--! @return SETOF record (key text, value text) Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_each_text(from_json public.{{prefix}}_json_search, OUT key text, OUT value text) +RETURNS SETOF record +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_each_text is not supported for public.{{prefix}}_json_search: it would list the encrypted envelope, not the document; list the children of a node with {{prefix}}.json_tree_children(col, token), and decrypt in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_object_keys(public.{{prefix}}_json_search, ...). +--! @param from_json public.{{prefix}}_json_search The encrypted document. +--! @return SETOF text Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_object_keys(from_json public.{{prefix}}_json_search) +RETURNS SETOF text +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_object_keys is not supported for public.{{prefix}}_json_search: it would list the encrypted envelope, not the document; list the children of a node with {{prefix}}.json_tree_children(col, token), and decrypt in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_typeof(public.{{prefix}}_json_search, ...). +--! @param from_json public.{{prefix}}_json_search The encrypted document. +--! @return text Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_typeof(from_json public.{{prefix}}_json_search) +RETURNS text +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_typeof is not supported for public.{{prefix}}_json_search: the kind of every node is encrypted, so it would answer object for every row; decrypt the document in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_strip_nulls(public.{{prefix}}_json_search, ...). +--! @param from_json public.{{prefix}}_json_search The encrypted document. +--! @param strip_in_arrays boolean Native argument. +--! @return jsonb Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_strip_nulls(from_json public.{{prefix}}_json_search, strip_in_arrays boolean DEFAULT false) +RETURNS jsonb +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_strip_nulls is not supported for public.{{prefix}}_json_search: an encrypted document cannot be edited in the database; decrypt it, change the plaintext and encrypt it again in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_set(public.{{prefix}}_json_search, ...). +--! @param jsonb_in public.{{prefix}}_json_search The encrypted document. +--! @param path text[] Native argument. +--! @param replacement jsonb Native argument. +--! @param create_if_missing boolean Native argument. +--! @return jsonb Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_set(jsonb_in public.{{prefix}}_json_search, path text[], replacement jsonb, create_if_missing boolean DEFAULT true) +RETURNS jsonb +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_set is not supported for public.{{prefix}}_json_search: an encrypted document cannot be edited in the database; decrypt it, change the plaintext and encrypt it again in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_insert(public.{{prefix}}_json_search, ...). +--! @param jsonb_in public.{{prefix}}_json_search The encrypted document. +--! @param path text[] Native argument. +--! @param replacement jsonb Native argument. +--! @param insert_after boolean Native argument. +--! @return jsonb Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_insert(jsonb_in public.{{prefix}}_json_search, path text[], replacement jsonb, insert_after boolean DEFAULT false) +RETURNS jsonb +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_insert is not supported for public.{{prefix}}_json_search: an encrypted document cannot be edited in the database; decrypt it, change the plaintext and encrypt it again in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_pretty(public.{{prefix}}_json_search, ...). +--! @param from_json public.{{prefix}}_json_search The encrypted document. +--! @return text Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_pretty(from_json public.{{prefix}}_json_search) +RETURNS text +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_pretty is not supported for public.{{prefix}}_json_search: it would print the encrypted envelope, not the document; decrypt the document in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_path_exists(public.{{prefix}}_json_entry, ...). +--! @param target public.{{prefix}}_json_entry An entry extracted from an encrypted document. +--! @param path jsonpath Native argument. +--! @param vars jsonb Native argument. +--! @param silent boolean Native argument. +--! @return boolean Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_path_exists(target public.{{prefix}}_json_entry, path jsonpath, vars jsonb DEFAULT '{}'::jsonb, silent boolean DEFAULT false) +RETURNS boolean +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_path_exists is not supported for public.{{prefix}}_json_entry: a jsonpath runs over the encrypted entry, not its value, and matches nothing; compare the entry with an {{prefix}}.query_json_leaf, or test the document with col @> needle::{{prefix}}.query_json' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_path_exists_tz(public.{{prefix}}_json_entry, ...). +--! @param target public.{{prefix}}_json_entry An entry extracted from an encrypted document. +--! @param path jsonpath Native argument. +--! @param vars jsonb Native argument. +--! @param silent boolean Native argument. +--! @return boolean Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_path_exists_tz(target public.{{prefix}}_json_entry, path jsonpath, vars jsonb DEFAULT '{}'::jsonb, silent boolean DEFAULT false) +RETURNS boolean +STABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_path_exists_tz is not supported for public.{{prefix}}_json_entry: a jsonpath runs over the encrypted entry, not its value, and matches nothing; compare the entry with an {{prefix}}.query_json_leaf, or test the document with col @> needle::{{prefix}}.query_json' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_path_match(public.{{prefix}}_json_entry, ...). +--! @param target public.{{prefix}}_json_entry An entry extracted from an encrypted document. +--! @param path jsonpath Native argument. +--! @param vars jsonb Native argument. +--! @param silent boolean Native argument. +--! @return boolean Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_path_match(target public.{{prefix}}_json_entry, path jsonpath, vars jsonb DEFAULT '{}'::jsonb, silent boolean DEFAULT false) +RETURNS boolean +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_path_match is not supported for public.{{prefix}}_json_entry: a jsonpath runs over the encrypted entry, not its value, and matches nothing; compare the entry with an {{prefix}}.query_json_leaf, or test the document with col @> needle::{{prefix}}.query_json' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_path_match_tz(public.{{prefix}}_json_entry, ...). +--! @param target public.{{prefix}}_json_entry An entry extracted from an encrypted document. +--! @param path jsonpath Native argument. +--! @param vars jsonb Native argument. +--! @param silent boolean Native argument. +--! @return boolean Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_path_match_tz(target public.{{prefix}}_json_entry, path jsonpath, vars jsonb DEFAULT '{}'::jsonb, silent boolean DEFAULT false) +RETURNS boolean +STABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_path_match_tz is not supported for public.{{prefix}}_json_entry: a jsonpath runs over the encrypted entry, not its value, and matches nothing; compare the entry with an {{prefix}}.query_json_leaf, or test the document with col @> needle::{{prefix}}.query_json' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_path_query(public.{{prefix}}_json_entry, ...). +--! @param target public.{{prefix}}_json_entry An entry extracted from an encrypted document. +--! @param path jsonpath Native argument. +--! @param vars jsonb Native argument. +--! @param silent boolean Native argument. +--! @return SETOF jsonb Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_path_query(target public.{{prefix}}_json_entry, path jsonpath, vars jsonb DEFAULT '{}'::jsonb, silent boolean DEFAULT false) +RETURNS SETOF jsonb +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_path_query is not supported for public.{{prefix}}_json_entry: a jsonpath runs over the encrypted entry, not its value, and finds nothing; decrypt the entry in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_path_query_tz(public.{{prefix}}_json_entry, ...). +--! @param target public.{{prefix}}_json_entry An entry extracted from an encrypted document. +--! @param path jsonpath Native argument. +--! @param vars jsonb Native argument. +--! @param silent boolean Native argument. +--! @return SETOF jsonb Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_path_query_tz(target public.{{prefix}}_json_entry, path jsonpath, vars jsonb DEFAULT '{}'::jsonb, silent boolean DEFAULT false) +RETURNS SETOF jsonb +STABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_path_query_tz is not supported for public.{{prefix}}_json_entry: a jsonpath runs over the encrypted entry, not its value, and finds nothing; decrypt the entry in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_path_query_array(public.{{prefix}}_json_entry, ...). +--! @param target public.{{prefix}}_json_entry An entry extracted from an encrypted document. +--! @param path jsonpath Native argument. +--! @param vars jsonb Native argument. +--! @param silent boolean Native argument. +--! @return jsonb Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_path_query_array(target public.{{prefix}}_json_entry, path jsonpath, vars jsonb DEFAULT '{}'::jsonb, silent boolean DEFAULT false) +RETURNS jsonb +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_path_query_array is not supported for public.{{prefix}}_json_entry: a jsonpath runs over the encrypted entry, not its value, and finds nothing; decrypt the entry in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_path_query_array_tz(public.{{prefix}}_json_entry, ...). +--! @param target public.{{prefix}}_json_entry An entry extracted from an encrypted document. +--! @param path jsonpath Native argument. +--! @param vars jsonb Native argument. +--! @param silent boolean Native argument. +--! @return jsonb Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_path_query_array_tz(target public.{{prefix}}_json_entry, path jsonpath, vars jsonb DEFAULT '{}'::jsonb, silent boolean DEFAULT false) +RETURNS jsonb +STABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_path_query_array_tz is not supported for public.{{prefix}}_json_entry: a jsonpath runs over the encrypted entry, not its value, and finds nothing; decrypt the entry in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_path_query_first(public.{{prefix}}_json_entry, ...). +--! @param target public.{{prefix}}_json_entry An entry extracted from an encrypted document. +--! @param path jsonpath Native argument. +--! @param vars jsonb Native argument. +--! @param silent boolean Native argument. +--! @return jsonb Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_path_query_first(target public.{{prefix}}_json_entry, path jsonpath, vars jsonb DEFAULT '{}'::jsonb, silent boolean DEFAULT false) +RETURNS jsonb +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_path_query_first is not supported for public.{{prefix}}_json_entry: a jsonpath runs over the encrypted entry, not its value, and finds nothing; decrypt the entry in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_path_query_first_tz(public.{{prefix}}_json_entry, ...). +--! @param target public.{{prefix}}_json_entry An entry extracted from an encrypted document. +--! @param path jsonpath Native argument. +--! @param vars jsonb Native argument. +--! @param silent boolean Native argument. +--! @return jsonb Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_path_query_first_tz(target public.{{prefix}}_json_entry, path jsonpath, vars jsonb DEFAULT '{}'::jsonb, silent boolean DEFAULT false) +RETURNS jsonb +STABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_path_query_first_tz is not supported for public.{{prefix}}_json_entry: a jsonpath runs over the encrypted entry, not its value, and finds nothing; decrypt the entry in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_extract_path(public.{{prefix}}_json_entry, ...). +--! @param from_json public.{{prefix}}_json_entry An entry extracted from an encrypted document. +--! @param path_elems text[] Native argument. +--! @return jsonb Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_extract_path(from_json public.{{prefix}}_json_entry, VARIADIC path_elems text[]) +RETURNS jsonb +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_extract_path is not supported for public.{{prefix}}_json_entry: the value of an entry is encrypted, so a key path finds nothing; decrypt the entry in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_extract_path_text(public.{{prefix}}_json_entry, ...). +--! @param from_json public.{{prefix}}_json_entry An entry extracted from an encrypted document. +--! @param path_elems text[] Native argument. +--! @return text Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_extract_path_text(from_json public.{{prefix}}_json_entry, VARIADIC path_elems text[]) +RETURNS text +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_extract_path_text is not supported for public.{{prefix}}_json_entry: the value of an entry is encrypted, so a key path finds nothing; decrypt the entry in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_each(public.{{prefix}}_json_entry, ...). +--! @param from_json public.{{prefix}}_json_entry An entry extracted from an encrypted document. +--! @return SETOF record (key text, value jsonb) Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_each(from_json public.{{prefix}}_json_entry, OUT key text, OUT value jsonb) +RETURNS SETOF record +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_each is not supported for public.{{prefix}}_json_entry: it would list the fields of the encrypted entry, not its value; decrypt the entry in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_each_text(public.{{prefix}}_json_entry, ...). +--! @param from_json public.{{prefix}}_json_entry An entry extracted from an encrypted document. +--! @return SETOF record (key text, value text) Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_each_text(from_json public.{{prefix}}_json_entry, OUT key text, OUT value text) +RETURNS SETOF record +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_each_text is not supported for public.{{prefix}}_json_entry: it would list the fields of the encrypted entry, not its value; decrypt the entry in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_object_keys(public.{{prefix}}_json_entry, ...). +--! @param from_json public.{{prefix}}_json_entry An entry extracted from an encrypted document. +--! @return SETOF text Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_object_keys(from_json public.{{prefix}}_json_entry) +RETURNS SETOF text +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_object_keys is not supported for public.{{prefix}}_json_entry: it would list the fields of the encrypted entry, not its value; decrypt the entry in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_typeof(public.{{prefix}}_json_entry, ...). +--! @param from_json public.{{prefix}}_json_entry An entry extracted from an encrypted document. +--! @return text Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_typeof(from_json public.{{prefix}}_json_entry) +RETURNS text +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_typeof is not supported for public.{{prefix}}_json_entry: the kind of the node is encrypted, so it would answer object for every entry; decrypt the entry in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_strip_nulls(public.{{prefix}}_json_entry, ...). +--! @param from_json public.{{prefix}}_json_entry An entry extracted from an encrypted document. +--! @param strip_in_arrays boolean Native argument. +--! @return jsonb Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_strip_nulls(from_json public.{{prefix}}_json_entry, strip_in_arrays boolean DEFAULT false) +RETURNS jsonb +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_strip_nulls is not supported for public.{{prefix}}_json_entry: an encrypted entry cannot be edited in the database; decrypt the document, change the plaintext and encrypt it again in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_set(public.{{prefix}}_json_entry, ...). +--! @param jsonb_in public.{{prefix}}_json_entry An entry extracted from an encrypted document. +--! @param path text[] Native argument. +--! @param replacement jsonb Native argument. +--! @param create_if_missing boolean Native argument. +--! @return jsonb Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_set(jsonb_in public.{{prefix}}_json_entry, path text[], replacement jsonb, create_if_missing boolean DEFAULT true) +RETURNS jsonb +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_set is not supported for public.{{prefix}}_json_entry: an encrypted entry cannot be edited in the database; decrypt the document, change the plaintext and encrypt it again in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_insert(public.{{prefix}}_json_entry, ...). +--! @param jsonb_in public.{{prefix}}_json_entry An entry extracted from an encrypted document. +--! @param path text[] Native argument. +--! @param replacement jsonb Native argument. +--! @param insert_after boolean Native argument. +--! @return jsonb Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_insert(jsonb_in public.{{prefix}}_json_entry, path text[], replacement jsonb, insert_after boolean DEFAULT false) +RETURNS jsonb +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_insert is not supported for public.{{prefix}}_json_entry: an encrypted entry cannot be edited in the database; decrypt the document, change the plaintext and encrypt it again in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; + +--! @brief Blocker: jsonb_pretty(public.{{prefix}}_json_entry, ...). +--! @param from_json public.{{prefix}}_json_entry An entry extracted from an encrypted document. +--! @return text Never returns; always raises feature_not_supported. +CREATE OR REPLACE FUNCTION public.jsonb_pretty(from_json public.{{prefix}}_json_entry) +RETURNS text +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'jsonb_pretty is not supported for public.{{prefix}}_json_entry: it would print the encrypted entry, not its value; decrypt the entry in the client' + USING ERRCODE = 'feature_not_supported', HINT = 'Cast the value to jsonb (col::jsonb) only to read the stored envelope itself.'; +END; +$$ LANGUAGE plpgsql; diff --git a/packages/eql/tests/encryption/tests/json_tree.rs b/packages/eql/tests/encryption/tests/json_tree.rs index 72bea82c6..5ada9283b 100644 --- a/packages/eql/tests/encryption/tests/json_tree.rs +++ b/packages/eql/tests/encryption/tests/json_tree.rs @@ -511,6 +511,10 @@ fn postgres_containment_over_the_encrypted_column_is_jsonb_containment() { #[path = "json_tree/generated.rs"] mod generated; +/// Native jsonb functions on a stored document and on an entry raise. +#[path = "json_tree/blockers.rs"] +mod blockers; + /// Needs an EQL v4 installation. Extraction by selector opens to the leaf; /// the subtree walk by a client-derived token returns exactly the nodes /// under it and reassembles; the array functions count and list elements; diff --git a/packages/eql/tests/encryption/tests/json_tree/blockers.rs b/packages/eql/tests/encryption/tests/json_tree/blockers.rs new file mode 100644 index 000000000..c08a5a761 --- /dev/null +++ b/packages/eql/tests/encryption/tests/json_tree/blockers.rs @@ -0,0 +1,474 @@ +//! Native jsonb FUNCTIONS on EQL v4's JSON tree domains raise (#1439). +//! +//! `public.eql_v4_json_search` and `public.eql_v4_json_entry` are domains +//! over `jsonb`, so a native function such as `jsonb_path_match` accepts +//! them and runs over the stored envelope (`{v, i, h, sv, q}`), not the +//! document: it finds none of the user's keys and returns false for every +//! row, with no error. `src/v3/json_tree/blockers.sql` puts an overload of +//! each such function in `public` with the domain as its first argument, +//! and it raises `feature_not_supported` instead. +//! +//! These tests pin: +//! +//! - every blocked function raises `0A000` on a stored document and on an +//! extracted entry, in every arity and with a named argument, and with the +//! arguments bound as parameters (the customer's query in #1439); +//! - the set of overloads is exactly the list here, and each matches its +//! native function's signature, so a blocker added or dropped later fails +//! this file until it is listed; +//! - the documented limits: an untyped argument resolves to the native +//! function, and a plain-jsonb call whose first argument is untyped becomes +//! ambiguous; +//! - what is left alone: plain jsonb, an explicit `::jsonb` cast, EQL's own +//! reads, and the three functions deliberately not blocked because the +//! native function already raises on these domains. +//! +//! The document is the corpus's [`fixture`], sealed by Stack Encrypt's JSON +//! index under the fake key source, and the entry is the one `->` extracts +//! at the selector Stack Encrypt derives for `$.hello`. + +use super::*; + +use postgres::types::Type; + +/// SQLSTATE `feature_not_supported`, which every blocker raises with. +const FEATURE_NOT_SUPPORTED: &str = "0A000"; +/// SQLSTATE `ambiguous_function`. +const AMBIGUOUS_FUNCTION: &str = "42725"; + +const DOCUMENT_DOMAIN: &str = "public.eql_v4_json_search"; +const ENTRY_DOMAIN: &str = "public.eql_v4_json_entry"; + +/// The jsonpath functions, each `(target, path, vars DEFAULT '{}', silent +/// DEFAULT false)`. +const JSONPATH_FUNCTIONS: &[&str] = &[ + "jsonb_path_exists", + "jsonb_path_exists_tz", + "jsonb_path_match", + "jsonb_path_match_tz", + "jsonb_path_query", + "jsonb_path_query_tz", + "jsonb_path_query_array", + "jsonb_path_query_array_tz", + "jsonb_path_query_first", + "jsonb_path_query_first_tz", +]; + +/// The extraction and editing functions. +const OTHER_FUNCTIONS: &[&str] = &[ + "jsonb_extract_path", + "jsonb_extract_path_text", + "jsonb_each", + "jsonb_each_text", + "jsonb_object_keys", + "jsonb_typeof", + "jsonb_strip_nulls", + "jsonb_set", + "jsonb_insert", + "jsonb_pretty", +]; + +/// Not blocked: on either domain the native function already raises, and an +/// overload would make `jsonb_array_elements($1)` on plain jsonb ambiguous. +const NATIVELY_LOUD: &[&str] = &[ + "jsonb_array_elements", + "jsonb_array_elements_text", + "jsonb_array_length", +]; + +/// A connection with the sealed [`fixture`] stored as row 1 of the temp +/// table `docs`, the selector of `$.hello` in it, and the cipher that +/// sealed it. +struct Stored { + runtime: tokio::runtime::Runtime, + cipher: Cipher, + db: postgres::Client, + selector: String, +} + +/// Seal [`fixture`] and store it in `docs.doc`. +fn setup() -> Stored { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (cipher, _) = wait(&runtime, common::cipher()); + let stored = wait(&runtime, seal(&cipher, &fixture())); + let selector = wait( + &runtime, + cipher + .default_keyset() + .json_selector(&JsonPath::parse("$.hello").unwrap(), column()), + ) + .unwrap() + .to_string(); + let mut db = connect(); + db.batch_execute("CREATE TEMP TABLE docs (id integer, doc public.eql_v4_json_search)") + .unwrap(); + db.execute( + "INSERT INTO docs VALUES (1, $1::text::jsonb::public.eql_v4_json_search)", + &[&serde_json::to_string(&stored).unwrap()], + ) + .unwrap(); + Stored { + runtime, + cipher, + db, + selector, + } +} + +/// The two domain values a caller can reach, as SQL over `docs`. +fn values(selector: &str) -> [(&'static str, String); 2] { + [ + (DOCUMENT_DOMAIN, "doc".to_string()), + (ENTRY_DOMAIN, format!("(doc -> '{selector}'::text)")), + ] +} + +/// Every blocked call on `v`, with every argument typed: `(function, SQL)`. +fn blocked_calls(v: &str) -> Vec<(&'static str, String)> { + let mut calls = Vec::new(); + for &f in JSONPATH_FUNCTIONS { + calls.push((f, format!("{f}({v}, '$.a'::jsonpath)"))); + calls.push((f, format!("{f}({v}, '$.a'::jsonpath, '{{}}'::jsonb)"))); + calls.push((f, format!("{f}({v}, '$.a'::jsonpath, '{{}}'::jsonb, true)"))); + calls.push((f, format!("{f}({v}, '$.a'::jsonpath, silent => true)"))); + } + for f in ["jsonb_extract_path", "jsonb_extract_path_text"] { + calls.push((f, format!("{f}({v}, 'a'::text)"))); + calls.push((f, format!("{f}({v}, 'a'::text, 'b'::text)"))); + calls.push((f, format!("{f}({v}, VARIADIC ARRAY['a', 'b'])"))); + } + for f in [ + "jsonb_each", + "jsonb_each_text", + "jsonb_object_keys", + "jsonb_typeof", + "jsonb_strip_nulls", + "jsonb_pretty", + ] { + calls.push((f, format!("{f}({v})"))); + } + calls.push(( + "jsonb_strip_nulls", + format!("jsonb_strip_nulls({v}, strip_in_arrays => true)"), + )); + for f in ["jsonb_set", "jsonb_insert"] { + calls.push((f, format!("{f}({v}, '{{a}}'::text[], '1'::jsonb)"))); + calls.push((f, format!("{f}({v}, '{{a}}'::text[], '1'::jsonb, true)"))); + } + calls +} + +/// The SQLSTATE and message of a statement that must fail. +fn db_error(result: Result, sql: &str) -> (String, String) { + match result { + Ok(rows) => panic!("{sql} must fail, but returned {rows:?}"), + Err(error) => match error.as_db_error() { + Some(e) => (e.code().code().to_owned(), e.message().to_owned()), + None => panic!("{sql}: expected a database error, got {error}"), + }, + } +} + +/// Assert `result` is the blocker's raise for `function` on `domain`. +fn assert_blocked( + result: Result, + function: &str, + domain: &str, + sql: &str, +) { + let (code, message) = db_error(result, sql); + assert_eq!( + code, FEATURE_NOT_SUPPORTED, + "{sql} must raise feature_not_supported: {message}" + ); + assert!( + message.starts_with(&format!("{function} is not supported for {domain}: ")), + "{sql} must name the function and the domain: {message}" + ); +} + +#[test] +#[ignore = "requires EQL_TEST_DATABASE_URL pointing to an EQL v4 database"] +fn postgres_every_typed_call_of_a_blocked_function_raises() { + let Stored { + mut db, selector, .. + } = setup(); + + let mut checked = 0; + for (domain, value) in values(&selector) { + for (function, call) in blocked_calls(&value) { + let sql = format!("SELECT {call} FROM docs"); + let result = db.query(&sql, &[]).map(|r| r.len()); + assert_blocked(result, function, domain, &sql); + checked += 1; + } + } + assert_eq!(checked, 2 * (JSONPATH_FUNCTIONS.len() * 4 + 6 + 6 + 1 + 4)); +} + +/// The query from #1439, with its arguments bound as text parameters and +/// cast in the SQL, as a filter compiler writes it. It returned no rows and +/// no error. +#[test] +#[ignore = "requires EQL_TEST_DATABASE_URL pointing to an EQL v4 database"] +fn postgres_the_jsonpath_filter_from_the_issue_raises_with_bound_parameters() { + let Stored { mut db, .. } = setup(); + + let sql = "SELECT id FROM docs \ + WHERE jsonb_path_match(doc, $1::jsonpath, $2::jsonb)"; + let result = db + .query_typed( + sql, + &[ + (&r#"$."score" == $val"#, Type::TEXT), + (&r#"{"val": 3.14}"#, Type::TEXT), + ], + ) + .map(|r| r.len()); + assert_blocked(result, "jsonb_path_match", DOCUMENT_DOMAIN, sql); + + let sql = "SELECT id FROM docs WHERE jsonb_path_exists(doc, $1::jsonpath)"; + let result = db + .query_typed(sql, &[(&"$.score", Type::TEXT)]) + .map(|r| r.len()); + assert_blocked(result, "jsonb_path_exists", DOCUMENT_DOMAIN, sql); +} + +/// The overloads in `public` on the two domains are exactly the listed +/// functions, on both domains, and none of the natively loud ones. +#[test] +#[ignore = "requires EQL_TEST_DATABASE_URL pointing to an EQL v4 database"] +fn postgres_the_blocked_set_is_exactly_the_listed_functions() { + let mut db = connect(); + + let found: Vec<(String, String)> = db + .query( + "SELECT p.proname::text, p.proargtypes[0]::regtype::text + FROM pg_proc p + WHERE p.pronamespace = 'public'::regnamespace + AND p.pronargs >= 1 + AND p.proargtypes[0] IN ('public.eql_v4_json_search'::regtype, + 'public.eql_v4_json_entry'::regtype) + AND starts_with(p.proname::text, 'jsonb_') + ORDER BY 1, 2", + &[], + ) + .unwrap() + .iter() + .map(|row| (row.get(0), row.get(1))) + .collect(); + + let mut expected: Vec<(String, String)> = JSONPATH_FUNCTIONS + .iter() + .chain(OTHER_FUNCTIONS) + .flat_map(|f| { + ["eql_v4_json_entry", "eql_v4_json_search"] + .into_iter() + .map(move |d| (f.to_string(), d.to_string())) + }) + .collect(); + expected.sort(); + assert_eq!(found, expected); + for f in NATIVELY_LOUD { + assert!( + !found.iter().any(|(name, _)| name == f), + "{f} is deliberately not blocked" + ); + } +} + +/// Each overload shadows a native function of the same name: the native +/// function's arguments after the first are a prefix of the overload's, +/// with the same variadic position, and both return the same type, as a set +/// or not. An overload with a different argument type would never win, and +/// one with another return type would fail a composed expression before it +/// raised. The overload may carry an argument the native function gains in a +/// later PostgreSQL (`jsonb_strip_nulls(..., strip_in_arrays)` in 18). +#[test] +#[ignore = "requires EQL_TEST_DATABASE_URL pointing to an EQL v4 database"] +fn postgres_every_blocker_matches_its_native_signature() { + let mut db = connect(); + + let unmatched: Vec = db + .query( + "SELECT p.oid::regprocedure::text + FROM pg_proc p + WHERE p.pronamespace = 'public'::regnamespace + AND p.pronargs >= 1 + AND p.proargtypes[0] IN ('public.eql_v4_json_search'::regtype, + 'public.eql_v4_json_entry'::regtype) + AND NOT EXISTS ( + SELECT 1 FROM pg_proc n + WHERE n.pronamespace = 'pg_catalog'::regnamespace + AND n.proname = p.proname + AND n.proargtypes[0] = 'jsonb'::regtype + AND n.pronargs <= p.pronargs + AND n.prorettype = p.prorettype + AND n.proretset = p.proretset + AND n.provariadic = p.provariadic + AND NOT EXISTS ( + SELECT 1 FROM generate_series(1, n.pronargs - 1) AS i + WHERE n.proargtypes[i] <> p.proargtypes[i] + ) + ) + ORDER BY 1", + &[], + ) + .unwrap() + .iter() + .map(|row| row.get(0)) + .collect(); + assert!( + unmatched.is_empty(), + "blockers with no native function of a matching signature: {unmatched:?}" + ); +} + +/// The documented limit: an overload wins only on an exact match of every +/// argument's type. With an untyped literal anywhere, PostgreSQL treats the +/// domain as jsonb and the native function runs over the envelope, where +/// a document key finds nothing and an envelope key finds the envelope's +/// own field. Pinned so that it cannot change unnoticed; the docs tell +/// callers to cast. +#[test] +#[ignore = "requires EQL_TEST_DATABASE_URL pointing to an EQL v4 database"] +fn postgres_an_untyped_argument_still_resolves_to_the_native_function() { + let Stored { mut db, .. } = setup(); + + let matched: bool = db + .query_one( + "SELECT jsonb_path_match(doc, '$.a == 1', '{}') FROM docs", + &[], + ) + .unwrap() + .get(0); + assert!( + !matched, + "the native function finds nothing in the envelope" + ); + + let extracted: Option = db + .query_one( + "SELECT jsonb_extract_path(doc, 'score')::text FROM docs", + &[], + ) + .unwrap() + .get(0); + assert_eq!(extracted, None); + + let version: Option = db + .query_one("SELECT jsonb_extract_path(doc, 'v')::text FROM docs", &[]) + .unwrap() + .get(0); + assert_eq!( + version.as_deref(), + Some("4"), + "an envelope key returns the envelope's field" + ); +} + +/// The cost of the overloads, also documented: a plain-jsonb call whose +/// first argument is untyped has two candidates PostgreSQL cannot choose +/// between, and must cast. The jsonb operators already behave this way +/// once EQL is installed. A function left unblocked is unaffected. +#[test] +#[ignore = "requires EQL_TEST_DATABASE_URL pointing to an EQL v4 database"] +fn postgres_an_untyped_first_argument_is_ambiguous_until_cast() { + let mut db = connect(); + + let sql = "SELECT jsonb_typeof('{}')"; + let (code, message) = db_error(db.query_one(sql, &[]), sql); + assert_eq!( + code, AMBIGUOUS_FUNCTION, + "an untyped first argument is ambiguous: {message}" + ); + + let kind: String = db + .query_one("SELECT jsonb_typeof('{}'::jsonb)", &[]) + .unwrap() + .get(0); + assert_eq!(kind, "object"); + + let elements: i64 = db + .query_one("SELECT count(*) FROM jsonb_array_elements('[1, 2]')", &[]) + .unwrap() + .get(0); + assert_eq!(elements, 2); +} + +/// Plain jsonb, an explicit cast of the column, and EQL's own reads are not +/// touched by the overloads. +#[test] +#[ignore = "requires EQL_TEST_DATABASE_URL pointing to an EQL v4 database"] +fn postgres_plain_jsonb_casts_and_eql_reads_are_unaffected() { + let Stored { + runtime, + cipher, + mut db, + selector, + } = setup(); + + let row = db + .query_one( + r#"SELECT jsonb_path_match('{"a": 1}'::jsonb, '$.a == 1'::jsonpath), + jsonb_typeof(doc::jsonb), + (SELECT count(*) FROM jsonb_object_keys(doc::jsonb)) + FROM docs"#, + &[], + ) + .unwrap(); + let (matched, kind, keys): (bool, String, i64) = (row.get(0), row.get(1), row.get(2)); + assert!(matched); + assert_eq!(kind, "object"); + assert_eq!(keys, 5, "v, i, h, sv and q"); + + // A leaf's token: it finds the `$.hello` node, which has no children. + let leaf = wait( + &runtime, + cipher.default_keyset().json_subtree_key( + &JsonPath::parse("$.hello").unwrap(), + &JsonOptions::default(), + column(), + ), + ) + .unwrap(); + let row = db + .query_one( + &format!( + "SELECT eql_v4.json_tree_path_exists(doc, '{selector}'), + (doc -> '{selector}'::text) IS NOT NULL, + (SELECT count(*) FROM eql_v4.json_tree_children(doc, $1::text)) + FROM docs" + ), + &[&leaf.tokens()[0]], + ) + .unwrap(); + let (exists, extracted, children): (bool, bool, i64) = (row.get(0), row.get(1), row.get(2)); + assert!(exists); + assert!(extracted); + assert_eq!(children, 0); +} + +/// The three functions left unblocked already raise on both domains: the +/// envelope and an entry are objects, not arrays. +#[test] +#[ignore = "requires EQL_TEST_DATABASE_URL pointing to an EQL v4 database"] +fn postgres_the_unblocked_array_functions_raise_natively() { + let Stored { + mut db, selector, .. + } = setup(); + + for (_, value) in values(&selector) { + for f in NATIVELY_LOUD { + let sql = format!("SELECT {f}({value}) FROM docs"); + let (_, message) = db_error(db.query(&sql, &[]).map(|r| r.len()), &sql); + assert!( + message.starts_with("cannot "), + "{sql} must raise the native error: {message}" + ); + } + } +}