fix(mapper): make the late native fallback for unresolved value nodes fail closed (CIP-3715) - #442
Conversation
|
Warning Review limit reached
Next review available in: 19 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (13)
📝 WalkthroughWalkthroughThe EQL mapper now applies native typing to predicates and ordinal expressions, defers comparison operand grounding, and rejects unresolved values outside projections. Resolution errors propagate to callers. Regression tests cover literals, parameters, joins, ordering, projections, and encrypted boolean conditions. ChangesEQL type inference
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
freshtonic
left a comment
There was a problem hiding this comment.
Comment-only review.\n\nI did not find a defect in the mapper changes themselves. The projection-reachability defaulting is scoped conservatively, the previously implicit boolean/ordinal cases are now constrained explicitly, and the new error is propagated.\n\nOne current-head issue remains: all four PostgreSQL CI jobs are failing in with a error (the server expects , while tokio-postgres is given a Rust ). This appears inherited from the stacked base rather than introduced by this diff, but the stack needs to be updated or the failure otherwise resolved before merge.
freshtonic
left a comment
There was a problem hiding this comment.
Correction to my preceding review (shell formatting stripped identifiers): the four failing jobs fail in update_with_param_reused_for_storage_and_query with WrongType; PostgreSQL expects eql_v3_text_search while tokio-postgres is given a Rust String.
f52244d to
561e9c6
Compare
f37a87e to
e4fc642
Compare
…efault to native The fail-closed fallback rejected WHERE 1=1: the literals unify with each other through the comparison but reach no projection, so they stayed unresolved and the statement was refused — which the proxy turns into a silent passthrough, returning raw ciphertext (caught by the Python integration test test_disable_mapping). A comparison's result is native regardless of its operand type, so the operand pair is now marked as safe to default to native at the end of inference. The grounding stays late rather than eager: a param shared with a later encrypted comparison (WHERE $1 = 'x' AND enc = $1) must still resolve to the column's EQL type, and a marked variable that grounded concretely in the meantime is simply skipped. Positions no rule ever constrained still fail closed. Fixes the CI failures on #442.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/eql-mapper/src/inference/infer_type_impls/expr.rs`:
- Around line 238-251: Extend the native-default eligibility marking beyond the
generic BinaryOp branch to every comparison path in the expression inference
implementation, including Expr::InList, Expr::Between, Expr::IsDistinctFrom,
Expr::AnyOp/Expr::AllOp, and simple CASE comparisons. Reuse
mark_natively_groundable for each operand type unified by Eq or Ord constraints,
preserving encrypted-type grounding behavior, and add regression tests covering
the listed literal comparison forms.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 731c150d-aeca-4b8e-82df-eb1d44a04876
📒 Files selected for processing (8)
CHANGELOG.mdpackages/eql-mapper/src/eql_mapper.rspackages/eql-mapper/src/inference/infer_type_impls/expr.rspackages/eql-mapper/src/inference/infer_type_impls/query_statement.rspackages/eql-mapper/src/inference/infer_type_impls/select.rspackages/eql-mapper/src/inference/type_error.rspackages/eql-mapper/src/inference/unifier/mod.rspackages/eql-mapper/src/lib.rs
… just BinaryOp IN, BETWEEN, IS DISTINCT FROM, ANY/ALL and the simple-CASE operand all unify their operands with each other (or a fresh bounded tvar) without grounding them, so a literal-only condition in any of these forms was rejected by the fail-closed fallback exactly as WHERE 1=1 was. Each now gets the same mark as the generic comparison. ANY/ALL with an array literal remains rejected for an unrelated, pre-existing reason: the operand unifies with the whole array type, which the Eq/Ord bound then refuses. The cast-param spelling (= ANY($1::int[])) grounds through the cast and already works. Addresses review feedback on #442.
… fail closed (CIP-3715) Unifier::resolve_unresolved_value_nodes resolved *any* ast::Value node still untyped after inference to Native — fail-open one layer below the CIP-3699/CIP-3700 inference gaps: a literal or param in a clause that inference never constrained was silently typed Native and could skip encryption. (Its Result was also discarded at the call site.) Audit of every shape reaching the fallback (instrumented run of the mapper suite), and where each is now typed: - WHERE / HAVING / join ON conditions (`WHERE true`, `ON true`, `WHERE $1`): boolean contexts are always native — pinned to Native in InferType<Select>. A bare encrypted column as a condition (`WHERE enc_col`) is now rejected. - ORDER BY / GROUP BY literal keys (`ORDER BY 1`, `GROUP BY 1`): the literal reaches the database as a plain constant regardless of which projected column the ordinal selects — pinned to Native where the clause is inferred (also covers ordinals after set operations, which resolve against no single projection). - Values whose type escapes only through a projection (`SELECT 'lit'`, `SELECT $1`, CASE results and ARRAY elements in a projection, `SELECT 1` inside EXISTS, unreferenced derived-table columns): these relate to nothing and cannot be EQL — still defaulted to Native at resolve time, but now scoped to type variables reachable from a Query/Statement node's type instead of applying to any value node. Anything else unresolved at resolve time is now TypeError::UnresolvedValue naming the value, and the error propagates instead of being swallowed. Known shapes that now fail closed instead of silently passing: window frame bounds (`ROWS BETWEEN 1 PRECEDING …`) and aggregate FILTER clauses — both inference gaps owned by CIP-3699.
…efault to native The fail-closed fallback rejected WHERE 1=1: the literals unify with each other through the comparison but reach no projection, so they stayed unresolved and the statement was refused — which the proxy turns into a silent passthrough, returning raw ciphertext (caught by the Python integration test test_disable_mapping). A comparison's result is native regardless of its operand type, so the operand pair is now marked as safe to default to native at the end of inference. The grounding stays late rather than eager: a param shared with a later encrypted comparison (WHERE $1 = 'x' AND enc = $1) must still resolve to the column's EQL type, and a marked variable that grounded concretely in the meantime is simply skipped. Positions no rule ever constrained still fail closed. Fixes the CI failures on #442.
… just BinaryOp IN, BETWEEN, IS DISTINCT FROM, ANY/ALL and the simple-CASE operand all unify their operands with each other (or a fresh bounded tvar) without grounding them, so a literal-only condition in any of these forms was rejected by the fail-closed fallback exactly as WHERE 1=1 was. Each now gets the same mark as the generic comparison. ANY/ALL with an array literal remains rejected for an unrelated, pre-existing reason: the operand unifies with the whole array type, which the Eq/Ord bound then refuses. The cast-param spelling (= ANY($1::int[])) grounds through the cast and already works. Addresses review feedback on #442.
x <op> ANY/ALL(rhs) compares x with each ELEMENT of rhs, so when rhs is an array its element type is what x unifies with — unifying with the array itself typed 1 = ANY(ARRAY[1, 2]) as an array (and made the encrypted case a bounds conflict). The operator now decides the capability, as for a binary comparison: = ANY needs Eq, < ALL needs Ord. The new RewriteEqlAnyAllOps distributes the scalar comparison rewrite over the array literal: eq_term(col) = ANY(ARRAY[eq_term(elem), ...]), each element cast to the term-only query twin and encrypted like any other comparison operand. The encrypted spellings with no elementwise rewrite are refused loudly: an encrypted subquery projection (previously forwarded unrewritten, comparing randomised payloads that silently matched nothing) and a bare array param. Also rebases onto main so the changelog no longer collides with the entries #435 merged, and moves this PR's changelog entry out of the since-released 3.0.0 section. Addresses review feedback on #442.
efaf370 to
f3f7702
Compare
Closes CIP-3715.
Important
Stacked on #439 (CIP-3700, base branch
james/cip-3700-eql-mapper-inference-hardening-unconstrained-limitfetch). It must land after #439, or be retargeted tomainonce #439 merges. The dependency is real: without #439's LIMIT/OFFSET/FETCH Native pins, removing the fallback would breakLIMIT $1and friends.The problem
Unifier::resolve_unresolved_value_nodesresolved anyast::Valuenode still untyped after inference toNative— fail-open one layer below the CIP-3699/CIP-3700 inference gaps. A literal or param in a clause that inference never visited was silently typed Native and could skip encryption; a future missed AST field over an encrypted column would sail through the same way. ItsResultwas also discarded (let _ =) at the call site inEqlMapper::resolve, so even unification failures there were swallowed.The audit
Instrumented the fallback and ran the full mapper suite. Every shape that reached it, and what owns it now:
WHERE true,WHERE $1Nativepin onSelect::selection(boolean contexts are always native)HAVING <literal>Nativepin onSelect::havingJOIN … ON true(incl.ASOFmatch conditions)Nativepin on joinONconstraintsORDER BY 1,GROUP BY 1ordinals (incl. underDISTINCT, and after set operations where no single projection resolves them)Nativepin on literal sort/group keys — the literal reaches the database as a plain constant regardless of which projected column the ordinal selects; the redirectedOrd/Eqbound on the projected column is unchangedSELECT 'lit',SELECT $1, CASE results in a projection,SELECT ARRAY[1,2,3],SELECT 1insideEXISTS, unreferenced derived-table columnsNativeat resolve time, but the defaulting is now scoped: only type variables reachable from aQuery/Statementnode's type (i.e. from some projection) qualify. These values relate to nothing — they flow to the client or are discarded — so they cannot be EQL, which is what makes the default sound.Fail closed
Anything else unresolved at resolve time is now
TypeError::UnresolvedValue, naming the value, and the error propagates out ofEqlMapper::resolveinstead of being swallowed.Shapes verified (by probe) to now fail closed instead of silently passing — left for CIP-3699, which owns these inference gaps; do not expect them to type-check until it lands:
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW)FILTER (WHERE …)clauses(ON CONFLICT — also CIP-3699's — already errors earlier, on scope resolution.)
One behavioural edge outside the test suite:
DISTINCT ON (<literal>)previously slipped through via the fallback and now errors. PostgreSQL has no ordinal resolution there, so nothing meaningful is lost; flagging it in case CIP-3699 wants to own it alongside the other clause pins.Changes
packages/eql-mapper/src/inference/unifier/mod.rs— fallback replaced with projection-reachability-scoped defaulting + fail-closed error; unit test for the fail-closed path.packages/eql-mapper/src/inference/infer_type_impls/select.rs—Nativepins for WHERE/HAVING/join ON and GROUP BY literal keys.packages/eql-mapper/src/inference/infer_type_impls/query_statement.rs—Nativepin for ORDER BY literal keys.packages/eql-mapper/src/inference/type_error.rs— newUnresolvedValuevariant.packages/eql-mapper/src/eql_mapper.rs— propagate the error instead oflet _ =.packages/eql-mapper/src/lib.rs— six new tests: WHERE-placeholder-is-native, HAVING/ON constant conditions, encrypted column as bare WHERE condition rejected,UNION ALL … ORDER BY 1, unreferenced derived-table literal column.CHANGELOG.md— Fixed entry under Unreleased.As a side effect of the WHERE pin,
WHERE enc_col(a bare encrypted column as a boolean condition) is now rejected by the mapper instead of being forwarded for PostgreSQL to reject.Verification
mise run check— clean (fmt, clippy, compile).cargo test -p eql-mapper— 146 passed (139 pre-existing + 7 new), 0 failed.cargo test -p cipherstash-proxy -- --test-threads=1— 121 passed; the 3 doc-test failures are pre-existing on clean main.Summary by CodeRabbit
Bug Fixes
Tests