Skip to content

Commit e4fc642

Browse files
committed
fix(mapper): make the late native fallback for unresolved value nodes 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.
1 parent e2198c7 commit e4fc642

7 files changed

Lines changed: 332 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6464

6565
- **`SELECT … INTO` an encrypted column is now rejected**: the statement copies data into a table the encryption schema has never seen, leaving unreachable ciphertext there. Native-only projections pass through as before.
6666

67+
- **Literals and params that escape type checking now fail closed**: a literal or parameter whose type was never worked out during type checking used to be silently assumed to be plaintext — so a value in a clause the type checker did not cover could skip encryption without any error. Proxy now only makes that assumption where it is provably safe (a value that only flows to the client through a `SELECT` projection, such as `SELECT 'lit'` or `SELECT $1`); anywhere else the statement is rejected with an error naming the value. As part of this, `WHERE`/`HAVING`/join `ON` conditions and `ORDER BY`/`GROUP BY` ordinals are now explicitly typed as plaintext where they appear, and an encrypted column used bare as a boolean condition (for example `WHERE enc_col`) is rejected instead of being forwarded to the database.
68+
6769
## [2.2.4] - 2026-06-18
6870

6971
### Fixed

packages/eql-mapper/src/eql_mapper.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,10 @@ impl<'ast> EqlMapper<'ast> {
153153
.borrow_mut()
154154
.resolve_unresolved_associated_types();
155155

156-
let _ = self.unifier.borrow_mut().resolve_unresolved_value_nodes();
156+
// A failure here is a genuine type-checking failure: it means a value node escaped
157+
// inference entirely, and assuming it is native would be fail-open (the value could
158+
// relate to an encrypted column and silently skip encryption).
159+
self.unifier.borrow_mut().resolve_unresolved_value_nodes()?;
157160

158161
let projection = self.projection_type(statement);
159162
let params = self.param_types(&self.unifier.borrow());

packages/eql-mapper/src/inference/infer_type_impls/query_statement.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@ impl<'ast> InferType<'ast, Query> for TypeInferencer<'ast> {
7171
for order_by_expr in exprs {
7272
let key = resolve_positional_key(select, &order_by_expr.expr);
7373
self.unify_node_with_bound(key, EqlTrait::Ord)?;
74+
75+
// A key written as a literal (`ORDER BY 1`) reaches the
76+
// database as a plain constant — PostgreSQL only accepts
77+
// integer ordinals here — so the literal itself is always
78+
// native, independently of the projected column it selects.
79+
// This also covers ordinals that cannot be resolved against a
80+
// projection, e.g. `ORDER BY 1` after a set operation.
81+
if matches!(&order_by_expr.expr, Expr::Value(_)) {
82+
self.unify_node_with_type(&order_by_expr.expr, Type::native())?;
83+
}
7484
}
7585
}
7686

packages/eql-mapper/src/inference/infer_type_impls/select.rs

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use eql_mapper_macros::trace_infer;
2-
use sqltk::parser::ast::{Distinct, Expr, GroupByExpr, Select, SelectItem};
2+
use sqltk::parser::ast::{
3+
Distinct, Expr, GroupByExpr, JoinConstraint, JoinOperator, Select, SelectItem,
4+
};
35

46
use super::query_statement::resolve_positional_key;
57
use crate::unifier::{Projection, Type, Value};
@@ -31,6 +33,58 @@ impl<'ast> InferType<'ast, Select> for TypeInferencer<'ast> {
3133
}
3234
}
3335

36+
// `WHERE`, `HAVING` and join `ON` conditions are boolean expressions,
37+
// and booleans are always native — every EQL comparison produces a
38+
// native result. Pin the condition to `Native` so that a bare literal
39+
// or placeholder condition (`WHERE true`, `ON true`, `WHERE $1`) is
40+
// typed where the clause is inferred instead of relying on the late
41+
// unresolved-value fallback, and so that an encrypted value can never
42+
// itself be the condition.
43+
if let Some(selection) = &select.selection {
44+
self.unify_node_with_type(selection, Type::native())?;
45+
}
46+
47+
if let Some(having) = &select.having {
48+
self.unify_node_with_type(having, Type::native())?;
49+
}
50+
51+
for table_with_joins in &select.from {
52+
for join in &table_with_joins.joins {
53+
let constraint = match &join.join_operator {
54+
JoinOperator::Join(constraint)
55+
| JoinOperator::Inner(constraint)
56+
| JoinOperator::Left(constraint)
57+
| JoinOperator::LeftOuter(constraint)
58+
| JoinOperator::Right(constraint)
59+
| JoinOperator::RightOuter(constraint)
60+
| JoinOperator::FullOuter(constraint)
61+
| JoinOperator::Semi(constraint)
62+
| JoinOperator::LeftSemi(constraint)
63+
| JoinOperator::RightSemi(constraint)
64+
| JoinOperator::Anti(constraint)
65+
| JoinOperator::LeftAnti(constraint)
66+
| JoinOperator::RightAnti(constraint)
67+
| JoinOperator::StraightJoin(constraint) => Some(constraint),
68+
69+
JoinOperator::AsOf {
70+
match_condition,
71+
constraint,
72+
} => {
73+
self.unify_node_with_type(match_condition, Type::native())?;
74+
Some(constraint)
75+
}
76+
77+
JoinOperator::CrossJoin
78+
| JoinOperator::CrossApply
79+
| JoinOperator::OuterApply => None,
80+
};
81+
82+
if let Some(JoinConstraint::On(condition)) = constraint {
83+
self.unify_node_with_type(condition, Type::native())?;
84+
}
85+
}
86+
}
87+
3488
// Deduplication is equality, so every expression `DISTINCT` dedupes on
3589
// must support it. For an encrypted column that means its domain has to
3690
// carry an equality term — `eql_v3_boolean`, for instance, is
@@ -75,6 +129,14 @@ impl<'ast> InferType<'ast, Select> for TypeInferencer<'ast> {
75129
for expr in exprs {
76130
let key = resolve_positional_key(Some(select), expr);
77131
self.unify_node_with_bound(key, EqlTrait::Eq)?;
132+
133+
// A key written as a literal (`GROUP BY 1`) reaches the
134+
// database as a plain constant — PostgreSQL only accepts
135+
// integer ordinals here — so the literal itself is always
136+
// native, independently of the projected column it selects.
137+
if matches!(expr, Expr::Value(_)) {
138+
self.unify_node_with_type(expr, Type::native())?;
139+
}
78140
}
79141
}
80142

packages/eql-mapper/src/inference/type_error.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ pub enum TypeError {
2121
#[error("unified type contains unresolved type variable: {}", _0)]
2222
Incomplete(String),
2323

24+
#[error(
25+
"the type of value `{}` was never constrained during type inference; \
26+
refusing to assume it is native",
27+
_0
28+
)]
29+
UnresolvedValue(String),
30+
2431
#[error("{}", _0)]
2532
Expected(String),
2633

packages/eql-mapper/src/inference/unifier/mod.rs

Lines changed: 111 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::Arc};
1+
use std::{
2+
cell::RefCell,
3+
collections::{HashMap, HashSet},
4+
rc::Rc,
5+
sync::Arc,
6+
};
27

38
mod eql_traits;
49
mod instantiated_type_env;
@@ -71,13 +76,20 @@ impl<'ast> Unifier<'ast> {
7176
self.registry.borrow_mut().get_param_type(param)
7277
}
7378

74-
/// [`sqltk::parser::ast::Value`] nodes with type `Type::Var(_)` after the inference phase is complete will be unified
75-
/// with [`NativeValue`].
79+
/// Resolves [`sqltk::parser::ast::Value`] nodes (literals and params) whose type is still an
80+
/// unresolved `Type::Var(_)` after the inference phase is complete.
7681
///
77-
/// This can happen when a literal or param is never used in an expression that would constrain its type.
82+
/// A value's type can legitimately remain unconstrained in exactly one situation: it escapes
83+
/// the statement only through a projection. Either it is selected straight to the client
84+
/// (`SELECT 'lit'`, `SELECT $1`) or it belongs to a projection that nothing consumes (an
85+
/// unreferenced derived-table column, the projection of an `EXISTS (...)` subquery). Such a
86+
/// value cannot be an EQL type — every EQL-typed position is constrained during inference —
87+
/// so it is resolved to [`NativeValue`].
7888
///
79-
/// In that case, it is safe to resolve its type as native because it cannot possibly be an EQL type, which are
80-
/// always correctly inferred.
89+
/// Any other unresolved value node means an inference rule failed to constrain the position
90+
/// that owns it. Falling back to native there would be fail-open: a value in an unvisited
91+
/// clause could relate to an encrypted column and silently skip encryption. Those nodes fail
92+
/// closed with [`TypeError::UnresolvedValue`].
8193
pub(crate) fn resolve_unresolved_value_nodes(&mut self) -> Result<(), TypeError> {
8294
let unresolved_value_nodes: Vec<_> = self
8395
.registry
@@ -88,13 +100,83 @@ impl<'ast> Unifier<'ast> {
88100
.filter(|(_, ty)| matches!(&**ty, Type::Var(_)))
89101
.collect();
90102

91-
for (_, ty) in unresolved_value_nodes {
92-
self.unify(ty, Type::native().into())?;
103+
if unresolved_value_nodes.is_empty() {
104+
return Ok(());
105+
}
106+
107+
let projection_reachable = self.projection_reachable_tvars();
108+
109+
for (node, ty) in unresolved_value_nodes {
110+
match &*ty {
111+
Type::Var(Var(tvar, _)) if projection_reachable.contains(tvar) => {
112+
self.unify(ty.clone(), Type::native().into())?;
113+
}
114+
_ => return Err(TypeError::UnresolvedValue(node.to_string())),
115+
}
93116
}
94117

95118
Ok(())
96119
}
97120

121+
/// The set of type variables reachable from the type of any [`sqltk::parser::ast::Query`] or
122+
/// [`sqltk::parser::ast::Statement`] node — i.e. from some projection in the statement.
123+
///
124+
/// These are the positions whose types escape to the client (or are discarded, as with the
125+
/// projection of an `EXISTS` subquery or an unreferenced derived-table column) rather than
126+
/// relating to any other expression, which is what makes defaulting them to native sound.
127+
fn projection_reachable_tvars(&self) -> HashSet<TypeVar> {
128+
let mut types: Vec<Arc<Type>> = Vec::new();
129+
130+
{
131+
let registry = self.registry.borrow();
132+
types.extend(
133+
registry
134+
.get_nodes_and_types::<sqltk::parser::ast::Query>()
135+
.into_iter()
136+
.map(|(_, ty)| ty),
137+
);
138+
types.extend(
139+
registry
140+
.get_nodes_and_types::<sqltk::parser::ast::Statement>()
141+
.into_iter()
142+
.map(|(_, ty)| ty),
143+
);
144+
}
145+
146+
let mut tvars = HashSet::new();
147+
for ty in types {
148+
Self::collect_tvars(&ty.follow_tvars(self), &mut tvars);
149+
}
150+
151+
tvars
152+
}
153+
154+
/// Collects every [`TypeVar`] occurring in `ty` (which must already have had its type
155+
/// variables followed via [`Type::follow_tvars`]) into `tvars`.
156+
fn collect_tvars(ty: &Type, tvars: &mut HashSet<TypeVar>) {
157+
match ty {
158+
Type::Var(Var(tvar, _)) => {
159+
tvars.insert(*tvar);
160+
}
161+
Type::Value(Value::Projection(projection)) => {
162+
for column in projection.columns() {
163+
Self::collect_tvars(&column.ty, tvars);
164+
}
165+
}
166+
Type::Value(Value::Array(Array(element_ty))) => {
167+
Self::collect_tvars(element_ty, tvars);
168+
}
169+
Type::Value(Value::SetOf(set_of)) => {
170+
Self::collect_tvars(&set_of.inner_ty(), tvars);
171+
}
172+
Type::Value(Value::Eql(_) | Value::Native(_)) => {}
173+
Type::Associated(associated) => {
174+
Self::collect_tvars(&associated.impl_ty, tvars);
175+
Self::collect_tvars(&associated.resolved_ty, tvars);
176+
}
177+
}
178+
}
179+
98180
pub(crate) fn resolve_unresolved_associated_types(&mut self) -> Result<(), TypeError> {
99181
let unresolved_associated_types: Vec<_> = self
100182
.registry
@@ -375,10 +457,31 @@ pub(crate) mod test_util {
375457
mod test {
376458
use eql_mapper_macros::shallow_init_types;
377459

460+
use crate::inference::TypeError;
378461
use crate::unifier::Unifier;
379462
use crate::unifier::{EqlTraits, InstantiateType};
380463
use crate::{DepMut, TypeRegistry};
381464

465+
/// A value node whose type variable is not reachable from any projection has escaped
466+
/// inference: no rule constrained the position that owns it. Resolving it to native would
467+
/// be fail-open (the value could relate to an encrypted column and silently skip
468+
/// encryption), so it must be a type error instead.
469+
#[test]
470+
fn unresolved_value_node_not_reachable_from_a_projection_fails_closed() {
471+
let value = sqltk::parser::ast::Value::Boolean(true);
472+
473+
let mut unifier = Unifier::new(DepMut::new(TypeRegistry::new()));
474+
475+
// Registers the node with a fresh, unconstrained type variable — simulating a value
476+
// node that was traversed but never constrained by any inference rule.
477+
let _ = unifier.get_node_type(&value);
478+
479+
assert_eq!(
480+
unifier.resolve_unresolved_value_nodes(),
481+
Err(TypeError::UnresolvedValue("true".to_string()))
482+
);
483+
}
484+
382485
#[test]
383486
fn eq_native() {
384487
let mut unifier = Unifier::new(DepMut::new(TypeRegistry::new()));

0 commit comments

Comments
 (0)