Repository navigation
Conversation
The walk for a top-level field, `eql_v4.json_tree_subtree`, returned the
field's descendants and never the field. A walk finds a node by its
parent's token, and a top-level field has none, so `{}`, `[]`, a scalar
and a missing field all came back as no rows, and the client could not
tell them apart where `jsonb` answers `{}`, `[]`, the scalar and NULL
(#1356).
No stored field is added. A top-level field (and, with the root not
sealed, the root) is the one entry with no parent tag that carries its
selector, so `json_tree_subtree(doc, token, selector, budget)` returns
that entry at `d = 0` before its descendants, and nothing when the row
has no such entry. Both walks now run one internal function,
`eql_v4_internal.json_tree_walk`. A subtree key for a path that ends in
an array position no longer chains to the element's token, from which no
walk can return the element: the last position is a gate from the
array's token, which the gated walk already returns at `d = 0`. The
client then opens what the rows reached: the node's own value, `None` as
an `Option<Value>` when there is none, and the same answers from the
sealed root past the budget.
The selector sent beside a head's token is the one `->` sends; a path
ending in a position now sends its array's token. The leakage page and
the design record say so.
Closes #1356
Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
🦋 Changeset detectedLatest commit: 121e1f4 The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Comment |
The design record names #1359 as the pull request that closed the "descendants and not the node itself" gap, and two doc comments still described a walk with no gates as returning only the descendants. Refs #1356 Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Mutation testing (cargo-mutants,
|
| caught | missed | unviable | timeout |
|---|---|---|---|
| 13 | 0 | 15 | 0 |
Every mutant in the changed lines was caught by a test.
|
This PR's mechanism (a top-level field found by its selector, a path's last array position sent as a gate from the array's token) is superseded by #1360, which makes every path's token derivable and adds a self tag per entry, so no read has to send an array's or a field's token to reach a node below it. Its semantics carry over: the four answers, |
|
Superseded by #1363, which lands the same semantics (the node itself at depth 0, |
Summary
EQL v4's
json_treecolumn stores an encrypted JSON document as one entry per JSON node. To read part of a document, the client derives a "subtree key" for a path and the database walks the stored entries and returns the ones at that path. For a top-level field ($.profile), and for a path ending in an array position right after one ($.tags[2]), the walk returned the node's descendants and never the node itself. So{},[], a scalar and a missing field all came back as no rows, and the client could not tell them apart. PostgreSQL's ownjsonbanswers{},[], the scalar and NULL. Now every subtree read returns the node it names, with everything beneath it, so the client gets the same four answersjsonbdoes. No stored field is added.Stacked on #1358, which adds the client-side checks these rows go through.
Changes
SQL (
packages/eql/src/v3/json_tree/functions.sql, EQL v4 only)eql_v4.json_tree_subtree(doc, token, budget)becomesjson_tree_subtree(doc, token, selector, budget DEFAULT 64). This is a deliberate signature change to an unreleased function. A top-level field is the one entry with no parent tag that carries its selector (with the root not sealed, the root is). The walk returns that entry at depth0, then its descendants. When the row has no such entry it returns nothing, which is "no node at the path".eql_v4_internal.json_tree_walk(doc, token, gates, budget, selector).json_tree_subtree_atcalls it with no selector, so its behaviour is unchanged.Stack Encrypt (
packages/stack-encrypt)#n:<selector>), and the gated walk already returns the element at depth0. Positions before the last step still chain.JsonSubtreeKey::selector()is whatjson_tree_subtreetakes for a key with no gates. The key's serialized form carries it ass.JsonSubtreeopens to the node's own value ({},[]and scalars included). As anOption<Value>, it opens toNonewhere the row has no node at the path. That matches->, which returns SQL NULL for a path the row does not have. As aValue, no node is still refused asOpenedType. As a list, it opens to the reached nodes for a gated key, and to the node's children for a top-level field, as before.{}and[]select as themselves, and a missing path selects nothing.EQL bindings
JsonTreeSubtreegainsDecryptInto<Option<Value>>, and its docs say which SQL function to call for which key.Docs
@cipherstash/eql(the SQL signature change).What the database learns
json_tree_subtreenow sends the top-level field's selector beside its token. It is the selector->sends for the same path, so it is nothing new on its own. Together with the token, it tells the database which field the token is for. The stored entry order already showed that the first time the token reached one of the field's members: the entry stored right before a subtree's first member is its parent. What is new is the pairing for a field that has no members in any row the token reaches.$.tags[*], or of anything under a key below$.tags, already sends the same token.Both are on the leakage page.
Verification
Run from the worktree root unless noted. PostgreSQL 17 was a disposable container on port 55437, stopped afterwards.
mise x --env test -- cargo nextest run -p stack-encrypt --all-features:Summary [ 41.859s] 837 tests run: 837 passed, 1 skippedcargo test -p stack-encrypt --doc --all-features:test result: ok. 50 passed; 0 failedcargo clippy -p stack-encrypt --all-features --all-targets -- -D warnings: cleancargo fmt --all --checkat the root and inpackages/eql: cleanRUSTDOCFLAGS=-D warnings cargo doc -p stack-encrypt --all-features --no-deps: cleanpackages/eql:cargo test -p eql-bindings --all-features: all pass.mise run types:check: passes.mise run test:encryption: exit 0.packages/eql:mise run test:encryption:postgres: exit 0. Thejson_treesuite:test result: ok. 8 passed.postgres_every_subtree_read_answers_as_jsonb_does. That test holds every subtree read tojsonb_path_query_first(plain, 'strict <path>')over the plaintexts. A strict path is used because#>reads a position as an object key. It covers{},[], scalars (nullincluded), missing nodes and non-empty containers. Each is read at a top-level field, at array positions after one (chained and not), and behind a gate, with the root sealed and not sealed, walked out (budget 0) and past a budget of 1, where reads of more than one entry fall back to the sealed root.packages/eql:test:self_contained_v3,test:installer_complete,test:symbol_order_v3,test:v4_isolation,test:build_ordering_helpers,docs:validate:sourceandtest:docs_v3_grepall pass.docs:validate:documented-sqlagainst the container:Validated: 290, Errors: 0.test:splinterwas not run: it lints theeql_v3schemas only, and this SQL is v4-only.packages/stack-encrypt/tests/json_read_checks.rs, modulegiven_a_node_with_nothing_beneath_it. They cover each of{},[],"text",0,null,falseand a non-empty object at a top-level field, at array positions, behind a gate, with the root open and not, walked and past the budget. They also cover no node (None, and no key retrieved), lists of a head's children, and a wildcard over such nodes.mise x --env test -- cargo mutants -p stack-encrypt --in-diff <(git diff fix/json-tree-extracted-entry-check...HEAD): 28 mutants, 13 caught, 15 unviable, 0 missed.Related
Closes #1356
Refs #1358 (base of this PR), #1355, #1357.
Review notes
eql_v4_internal.json_tree_walkinfunctions.sql, which is the oldjson_tree_subtree_atbody plus thestartCTE and thefoundgate. Then readsubtree_key_for,placeandopen_reachedinpackages/stack-encrypt/src/json/derive.rs.json_tree_subtree(doc, key.token(), key.selector())whenkey.gates()is empty, otherwisejson_tree_subtree_at(doc, key.token(), key.gates()). The oracle'ssubtree_rowshelper shows it.packages/stack-encrypt/tests/json_index.rs(keys for positional paths, and walks that now include the head), which test: hold json_tree containment to jsonb with proptest, and restructure the JSON index tests #1345 rewrites.https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu