Skip to content

Commit 3e11ca2

Browse files
committed
fix(ddl): replace byte-indexed NEW. rewriting with char-safe iteration
The check constraint, constraint validator, RLS, trigger, and materialized view DDL parsers rewrote "NEW.col" references using byte indexing after a to_uppercase() call. This produced incorrect results for non-ASCII identifiers and is unsound for multi-byte UTF-8 sequences. Switch all affected parsers to collect chars and iterate over the char slice, matching the "NEW." prefix with eq_ignore_ascii_case on a 4-char window. Also fix the SQL preprocessor's string literal scanner to advance by 2 on '' escapes instead of continuing without incrementing, which previously caused an infinite loop on inputs with escaped single quotes.
1 parent aea0090 commit 3e11ca2

7 files changed

Lines changed: 168 additions & 73 deletions

File tree

nodedb-sql/src/parser/preprocess.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,12 +270,14 @@ fn value_to_json(value: &nodedb_types::Value) -> String {
270270
fn find_matching_brace(chars: &[char], start: usize) -> Option<usize> {
271271
let mut depth = 0;
272272
let mut in_string = false;
273-
for i in start..chars.len() {
273+
let mut i = start;
274+
while i < chars.len() {
274275
match chars[i] {
275276
'\'' if !in_string => in_string = true,
276277
'\'' if in_string => {
277278
if i + 1 < chars.len() && chars[i + 1] == '\'' {
278-
// Skip escaped quote.
279+
// Skip '' escape — advance past both quotes.
280+
i += 2;
279281
continue;
280282
}
281283
in_string = false;
@@ -289,6 +291,7 @@ fn find_matching_brace(chars: &[char], start: usize) -> Option<usize> {
289291
}
290292
_ => {}
291293
}
294+
i += 1;
292295
}
293296
None
294297
}

nodedb/src/control/server/pgwire/ddl/collection/check_constraint.rs

Lines changed: 35 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -271,24 +271,24 @@ fn restructure_subquery_check(expr: &str) -> RestructuredCheck {
271271
/// Converts `NEW.amount > 0` → `amount > 0` so the expression can be parsed
272272
/// as bare column references by `parse_generated_expr`.
273273
fn strip_new_prefix(sql: &str) -> String {
274+
let chars: Vec<char> = sql.chars().collect();
274275
let mut result = String::with_capacity(sql.len());
275-
let upper = sql.to_uppercase();
276-
let bytes = sql.as_bytes();
277276
let mut i = 0;
278277

279-
while i < bytes.len() {
280-
if i + 4 <= bytes.len() && upper[i..].starts_with("NEW.") {
281-
// Check word boundary before.
282-
if i > 0 && (bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_') {
283-
result.push(bytes[i] as char);
284-
i += 1;
278+
while i < chars.len() {
279+
if i + 4 <= chars.len() {
280+
let window: String = chars[i..i + 4].iter().collect();
281+
if window.eq_ignore_ascii_case("NEW.") {
282+
if i > 0 && (chars[i - 1].is_ascii_alphanumeric() || chars[i - 1] == '_') {
283+
result.push(chars[i]);
284+
i += 1;
285+
continue;
286+
}
287+
i += 4;
285288
continue;
286289
}
287-
// Skip "NEW." (4 chars).
288-
i += 4;
289-
continue;
290290
}
291-
result.push(bytes[i] as char);
291+
result.push(chars[i]);
292292
i += 1;
293293
}
294294
result
@@ -330,33 +330,35 @@ fn substitute_new_refs(sql: &str, fields: &HashMap<String, nodedb_types::Value>)
330330

331331
/// Replace any remaining `NEW.xxx` references (not matched by known fields) with NULL.
332332
fn replace_remaining_new_refs(text: &str) -> String {
333-
let upper = text.to_uppercase();
333+
let chars: Vec<char> = text.chars().collect();
334334
let mut result = String::with_capacity(text.len());
335335
let mut i = 0;
336-
let bytes = text.as_bytes();
337336

338-
while i < bytes.len() {
337+
while i < chars.len() {
339338
// Check for "NEW." prefix (case insensitive).
340-
if i + 4 <= bytes.len() && upper[i..].starts_with("NEW.") {
341-
// Check word boundary before.
342-
if i > 0 && (bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_') {
343-
result.push(bytes[i] as char);
344-
i += 1;
345-
continue;
346-
}
347-
// Find the end of the identifier after "NEW.".
348-
let start = i + 4;
349-
let mut end = start;
350-
while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') {
351-
end += 1;
352-
}
353-
if end > start {
354-
result.push_str("NULL");
355-
i = end;
356-
continue;
339+
if i + 4 <= chars.len() {
340+
let window: String = chars[i..i + 4].iter().collect();
341+
if window.eq_ignore_ascii_case("NEW.") {
342+
if i > 0 && (chars[i - 1].is_ascii_alphanumeric() || chars[i - 1] == '_') {
343+
result.push(chars[i]);
344+
i += 1;
345+
continue;
346+
}
347+
// Find the end of the identifier after "NEW.".
348+
let start = i + 4;
349+
let mut end = start;
350+
while end < chars.len() && (chars[end].is_ascii_alphanumeric() || chars[end] == '_')
351+
{
352+
end += 1;
353+
}
354+
if end > start {
355+
result.push_str("NULL");
356+
i = end;
357+
continue;
358+
}
357359
}
358360
}
359-
result.push(bytes[i] as char);
361+
result.push(chars[i]);
360362
i += 1;
361363
}
362364
result

nodedb/src/control/server/pgwire/ddl/constraint/validate.rs

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,21 +29,23 @@ pub(super) fn validate_subquery_pattern(check_sql: &str) -> PgWireResult<()> {
2929

3030
/// Strip `NEW.` prefix for validation parsing.
3131
pub(super) fn strip_new_prefix_for_validation(sql: &str) -> String {
32-
let upper = sql.to_uppercase();
33-
let bytes = sql.as_bytes();
32+
let chars: Vec<char> = sql.chars().collect();
3433
let mut result = String::with_capacity(sql.len());
3534
let mut i = 0;
36-
while i < bytes.len() {
37-
if i + 4 <= bytes.len() && upper[i..].starts_with("NEW.") {
38-
if i > 0 && (bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_') {
39-
result.push(bytes[i] as char);
40-
i += 1;
35+
while i < chars.len() {
36+
if i + 4 <= chars.len() {
37+
let window: String = chars[i..i + 4].iter().collect();
38+
if window.eq_ignore_ascii_case("NEW.") {
39+
if i > 0 && (chars[i - 1].is_ascii_alphanumeric() || chars[i - 1] == '_') {
40+
result.push(chars[i]);
41+
i += 1;
42+
continue;
43+
}
44+
i += 4;
4145
continue;
4246
}
43-
i += 4;
44-
continue;
4547
}
46-
result.push(bytes[i] as char);
48+
result.push(chars[i]);
4749
i += 1;
4850
}
4951
result

nodedb/src/control/server/pgwire/ddl/materialized_view/parse.rs

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,11 @@ pub fn parse_create_mv(sql: &str) -> PgWireResult<(String, String, String, Strin
5050
.ok_or_else(|| sqlstate_error("42601", "expected AS SELECT ... clause"))?;
5151
let query_start = after_on_start + as_pos + KW_AS.len();
5252

53-
// Find end of query: WITH clause or end of string.
54-
let remaining = &upper[query_start..];
55-
let with_pos = remaining.find(" WITH").or_else(|| {
56-
if remaining.trim_start().starts_with("WITH") {
57-
Some(0)
58-
} else {
59-
None
60-
}
61-
});
62-
let query_end = with_pos.map(|p| query_start + p).unwrap_or(sql.len());
53+
// Find end of query: a trailing `WITH (...)` options clause, or end of string.
54+
// We must not match a CTE `WITH cte AS (...)` inside the SELECT body.
55+
// The options clause is always `WITH (` — a CTE `WITH` is followed by
56+
// an identifier, never `(`.
57+
let query_end = find_trailing_with_options(&upper, query_start).unwrap_or(sql.len());
6358
let query_sql = sql[query_start..query_end].trim().to_string();
6459

6560
if query_sql.is_empty() {
@@ -71,6 +66,35 @@ pub fn parse_create_mv(sql: &str) -> PgWireResult<(String, String, String, Strin
7166
Ok((name, source, query_sql, refresh_mode))
7267
}
7368

69+
/// Find a trailing `WITH (...)` options clause that is NOT a CTE.
70+
///
71+
/// Scans backward from the end of the string for `WITH` followed (after
72+
/// optional whitespace) by `(`. CTE syntax is `WITH <name> AS (...)` — the
73+
/// word after `WITH` is an identifier, not `(`.
74+
fn find_trailing_with_options(upper: &str, query_start: usize) -> Option<usize> {
75+
let region = &upper[query_start..];
76+
// Search backward for the last " WITH" or "WITH" that is followed by "(".
77+
let mut search_end = region.len();
78+
loop {
79+
let pos = region[..search_end].rfind("WITH")?;
80+
// Verify word boundary before WITH.
81+
if pos > 0 {
82+
let before = region.as_bytes()[pos - 1];
83+
if before.is_ascii_alphanumeric() || before == b'_' {
84+
search_end = pos;
85+
continue;
86+
}
87+
}
88+
// Check that WITH is followed by `(` (possibly with whitespace).
89+
let after_with = region[pos + 4..].trim_start();
90+
if after_with.starts_with('(') {
91+
return Some(query_start + pos);
92+
}
93+
// This WITH is a CTE or identifier — keep searching backward.
94+
search_end = pos;
95+
}
96+
}
97+
7498
/// Extract refresh mode from WITH clause.
7599
fn extract_refresh_mode(upper: &str, sql: &str) -> String {
76100
let with_pos = match upper.rfind("WITH") {

nodedb/src/control/server/pgwire/ddl/rls/parse.rs

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,7 @@ pub fn parse_create_rls_policy(
7070
.map(|i| using_idx + 1 + i)
7171
.unwrap_or(parts.len());
7272

73-
let predicate_str = parts[using_idx + 1..pred_end]
74-
.join(" ")
75-
.trim_matches(|c: char| c == '(' || c == ')')
76-
.to_string();
73+
let predicate_str = strip_outer_parens(&parts[using_idx + 1..pred_end].join(" "));
7774

7875
let is_restrictive = parts[pred_end..]
7976
.iter()
@@ -114,7 +111,7 @@ pub fn parse_create_rls_policy(
114111

115112
let field = pred_parts[0];
116113
let op = pred_parts[1];
117-
let value_str = pred_parts[2..].join(" ").trim_matches('\'').to_string();
114+
let value_str = strip_single_quotes(&pred_parts[2..].join(" "));
118115

119116
let filter = crate::bridge::scan_filter::ScanFilter {
120117
field: field.to_string(),
@@ -161,3 +158,48 @@ pub fn parse_create_rls_policy(
161158
on_deny,
162159
})
163160
}
161+
162+
/// Strip at most one matched pair of outer single quotes.
163+
///
164+
/// `"'hello'"` → `"hello"`, `"no_quotes"` → `"no_quotes"`.
165+
fn strip_single_quotes(s: &str) -> String {
166+
let trimmed = s.trim();
167+
if trimmed.len() >= 2 && trimmed.starts_with('\'') && trimmed.ends_with('\'') {
168+
trimmed[1..trimmed.len() - 1].to_string()
169+
} else {
170+
trimmed.to_string()
171+
}
172+
}
173+
174+
/// Strip at most one matched pair of outer parentheses. Unlike
175+
/// `trim_matches('(' | ')')`, this preserves balanced inner parens.
176+
///
177+
/// `"((x > 0) AND (y = 1))"` → `"(x > 0) AND (y = 1)"`
178+
/// `"(x > 0)"` → `"x > 0"`
179+
/// `"x > 0"` → `"x > 0"` (no outer parens)
180+
fn strip_outer_parens(s: &str) -> String {
181+
let trimmed = s.trim();
182+
if trimmed.starts_with('(') && trimmed.ends_with(')') {
183+
// Verify the outer parens are actually matched (not two separate groups).
184+
let inner = &trimmed[1..trimmed.len() - 1];
185+
let mut depth = 0i32;
186+
let mut balanced = true;
187+
for ch in inner.chars() {
188+
match ch {
189+
'(' => depth += 1,
190+
')' => {
191+
depth -= 1;
192+
if depth < 0 {
193+
balanced = false;
194+
break;
195+
}
196+
}
197+
_ => {}
198+
}
199+
}
200+
if balanced && depth == 0 {
201+
return inner.trim().to_string();
202+
}
203+
}
204+
trimmed.to_string()
205+
}

nodedb/src/control/server/pgwire/ddl/trigger/parse.rs

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -320,21 +320,42 @@ fn extract_dollar_quoted_body(s: &str) -> Option<(&str, String)> {
320320

321321
fn find_begin_pos(s: &str) -> Option<usize> {
322322
let upper = s.to_uppercase();
323-
let mut search_from = 0;
324-
loop {
325-
let pos = upper[search_from..].find("BEGIN")?;
326-
let abs_pos = search_from + pos;
327-
let before_ok = abs_pos == 0
328-
|| !s.as_bytes()[abs_pos - 1].is_ascii_alphanumeric()
329-
&& s.as_bytes()[abs_pos - 1] != b'_';
330-
let after_pos = abs_pos + 5;
331-
let after_ok = after_pos >= s.len()
332-
|| !s.as_bytes()[after_pos].is_ascii_alphanumeric() && s.as_bytes()[after_pos] != b'_';
333-
if before_ok && after_ok {
334-
return Some(abs_pos);
323+
let bytes = s.as_bytes();
324+
let mut i = 0;
325+
326+
while i < bytes.len() {
327+
// Skip single-quoted string literals so that 'BEGIN' inside a
328+
// string (e.g. in a WHEN clause) is not mistaken for the body start.
329+
if bytes[i] == b'\'' {
330+
i += 1;
331+
while i < bytes.len() {
332+
if bytes[i] == b'\'' {
333+
i += 1;
334+
// Handle '' escape.
335+
if i < bytes.len() && bytes[i] == b'\'' {
336+
i += 1;
337+
continue;
338+
}
339+
break;
340+
}
341+
i += 1;
342+
}
343+
continue;
344+
}
345+
346+
// Check for "BEGIN" keyword at this position.
347+
if i + 5 <= bytes.len() && &upper[i..i + 5] == "BEGIN" {
348+
let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric() && bytes[i - 1] != b'_';
349+
let after_pos = i + 5;
350+
let after_ok = after_pos >= bytes.len()
351+
|| !bytes[after_pos].is_ascii_alphanumeric() && bytes[after_pos] != b'_';
352+
if before_ok && after_ok {
353+
return Some(i);
354+
}
335355
}
336-
search_from = abs_pos + 5;
356+
i += 1;
337357
}
358+
None
338359
}
339360

340361
#[cfg(test)]

nodedb/src/control/server/pgwire/handler/plan.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ pub(super) fn describe_plan(plan: &PhysicalPlan) -> PlanKind {
124124
| PhysicalPlan::Query(QueryOp::FacetCounts { .. })
125125
| PhysicalPlan::Query(QueryOp::HashJoin { .. })
126126
| PhysicalPlan::Query(QueryOp::InlineHashJoin { .. })
127+
| PhysicalPlan::Query(QueryOp::RecursiveScan { .. })
127128
| PhysicalPlan::Graph(GraphOp::Algo { .. })
128129
| PhysicalPlan::Graph(GraphOp::Match { .. }) => PlanKind::MultiRow,
129130

0 commit comments

Comments
 (0)