Skip to content

Commit 4bae9bd

Browse files
authored
Merge pull request DeusData#1875 from CaptainMittens/fix/cypher-reject-trailing-tokens
fix(cypher): refuse a query the parser did not fully read
2 parents 9639803 + 25a7aac commit 4bae9bd

2 files changed

Lines changed: 105 additions & 0 deletions

File tree

‎src/cypher/cypher.c‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1992,6 +1992,13 @@ static int parse_post_where(parser_t *p, cbm_query_t *q, // NOLINT(misc-no-recur
19921992
q->union_next = sub.query;
19931993
sub.query = NULL;
19941994
cbm_parse_free(&sub);
1995+
/* The branch after UNION was parsed by a SEPARATE parser over a slice
1996+
* of these tokens, so this parser's cursor never moved past the UNION
1997+
* keyword. That sub-parse now refuses to succeed with anything left
1998+
* over, so everything from here to the end is accounted for. Move the
1999+
* cursor to the end to say so, or cbm_parse's end-of-input check reads
2000+
* a fully parsed UNION query as unfinished. */
2001+
p->pos = p->count;
19952002
}
19962003
return 0;
19972004
}
@@ -2055,6 +2062,36 @@ int cbm_parse(const cbm_token_t *tokens, int token_count, // NOLINT(misc-no-recu
20552062
return CBM_NOT_FOUND;
20562063
}
20572064

2065+
/* Every token must be consumed. The grammar accepts at most one WITH and
2066+
* treats RETURN as optional, so a query with a second WITH stage — or any
2067+
* typo after RETURN — used to stop parsing there and succeed anyway. The
2068+
* dropped tail took the filter and the RETURN with it, and the engine
2069+
* answered from the fragment it had parsed, using its default projection.
2070+
* That reported success and returned wrong rows, which is worse than a
2071+
* refusal because nothing tells the caller to look. Refuse instead. */
2072+
if (peek(&p)->type != TOK_EOF) {
2073+
/* Only mention the one-WITH limit when a standalone WITH really is
2074+
* sitting in the part we could not read. Saying it every time points
2075+
* a reader at WITH when the problem is a typo. A WITH straight after
2076+
* STARTS is the STARTS WITH operator rather than a clause, the same
2077+
* guard parse_post_where uses. */
2078+
const char *hint = "";
2079+
for (int i = p.pos; i < p.count; i++) {
2080+
if (p.tokens[i].type == TOK_WITH &&
2081+
(i == 0 || p.tokens[i - SKIP_ONE].type != TOK_STARTS)) {
2082+
hint = " Note that only one WITH clause is supported.";
2083+
break;
2084+
}
2085+
}
2086+
snprintf(p.error, sizeof(p.error),
2087+
"unexpected input at pos %d ('%s') — the query was not fully "
2088+
"parsed.%s",
2089+
peek(&p)->pos, peek(&p)->text ? peek(&p)->text : "", hint);
2090+
out->error = heap_strdup(p.error);
2091+
cbm_query_free(q);
2092+
return CBM_NOT_FOUND;
2093+
}
2094+
20582095
out->query = q;
20592096
return 0;
20602097
}

‎tests/test_cypher.c‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,71 @@ TEST(cypher_parse_simple_node) {
209209
PASS();
210210
}
211211

212+
/* Trailing input must be an error, never a silent drop. The parser used to
213+
* stop at the first thing it did not understand and report success, so the
214+
* engine answered from the fragment it had parsed. */
215+
TEST(cypher_parse_rejects_trailing_tokens) {
216+
cbm_query_t *q = NULL;
217+
char *err = NULL;
218+
int rc = cbm_cypher_parse("MATCH (f:Function) RETURN f.name AS n BANANA SPLIT 99", &q, &err);
219+
ASSERT_NEQ(rc, 0);
220+
ASSERT_NOT_NULL(err);
221+
ASSERT_NULL(q);
222+
223+
/* The message must name what actually stopped the parse, and must not
224+
* mention WITH: this query has no WITH in it anywhere. */
225+
ASSERT(strstr(err, "BANANA") != NULL);
226+
ASSERT(strstr(err, "WITH") == NULL);
227+
228+
free(err);
229+
PASS();
230+
}
231+
232+
/* Only one WITH is supported. A second one used to take the rest of the
233+
* query with it — the filter and the RETURN both vanished, and every row
234+
* came back unfiltered under the default projection. */
235+
TEST(cypher_parse_rejects_second_with_clause) {
236+
cbm_query_t *q = NULL;
237+
char *err = NULL;
238+
int rc = cbm_cypher_parse("MATCH (f:Function) "
239+
"OPTIONAL MATCH (a)-[:CALLS]->(f) "
240+
"WITH f, count(a) AS calls "
241+
"OPTIONAL MATCH (b)-[:USAGE]->(f) "
242+
"WITH f, calls, count(b) AS usages "
243+
"WHERE calls = 0 AND usages = 0 "
244+
"RETURN f.name AS n",
245+
&q, &err);
246+
ASSERT_NEQ(rc, 0);
247+
ASSERT_NOT_NULL(err);
248+
ASSERT_NULL(q);
249+
250+
/* Here the note earns its place. The parse stops at OPTIONAL, and the
251+
* reason is the second WITH further along, which the reader cannot see
252+
* from the stopping point alone. */
253+
ASSERT(strstr(err, "only one WITH clause is supported") != NULL);
254+
255+
free(err);
256+
PASS();
257+
}
258+
259+
/* The guard must not reject a query that is simply finished. One WITH, a
260+
* WHERE after it and a RETURN is the shape the grammar does support. */
261+
TEST(cypher_parse_accepts_single_with_clause) {
262+
cbm_query_t *q = NULL;
263+
char *err = NULL;
264+
int rc = cbm_cypher_parse("MATCH (f:Function) "
265+
"WITH f, f.name AS n "
266+
"WHERE n = 'buildTree' "
267+
"RETURN n",
268+
&q, &err);
269+
ASSERT_EQ(rc, 0);
270+
ASSERT_NULL(err);
271+
ASSERT_NOT_NULL(q);
272+
273+
cbm_query_free(q);
274+
PASS();
275+
}
276+
212277
TEST(cypher_parse_relationship_outbound) {
213278
cbm_query_t *q = NULL;
214279
char *err = NULL;
@@ -4102,6 +4167,9 @@ SUITE(cypher) {
41024167
RUN_TEST(cypher_lex_full_query);
41034168
/* Parser */
41044169
RUN_TEST(cypher_parse_simple_node);
4170+
RUN_TEST(cypher_parse_rejects_trailing_tokens);
4171+
RUN_TEST(cypher_parse_rejects_second_with_clause);
4172+
RUN_TEST(cypher_parse_accepts_single_with_clause);
41054173
RUN_TEST(cypher_parse_relationship_outbound);
41064174
RUN_TEST(cypher_parse_relationship_inbound);
41074175
RUN_TEST(cypher_parse_relationship_any);

0 commit comments

Comments
 (0)