Skip to content

Commit d21bb13

Browse files
fix(cypher): refuse a query the parser did not fully read
cbm_parse built a query and returned success without checking that it had read every token. The grammar accepts at most one WITH and treats RETURN as optional, so the parser stopped at the first thing it did not understand and reported success anyway. The dropped tail took the filter and the RETURN with it. The engine then answered from the fragment it had parsed, using its default projection. It reported success and returned wrong rows, which is worse than a refusal, because nothing tells the caller to look. Two shapes hit this: MATCH (f:Function) WHERE f.name = 'x' RETURN f.name AS n BANANA SPLIT 99 -> one row, no error, the trailing words silently dropped MATCH (f:Function) OPTIONAL MATCH (a)-[:CALLS]->(f) WITH f, count(a) AS calls OPTIONAL MATCH (b)-[:USAGE]->(f) WITH f, calls, count(b) AS usages WHERE calls = 0 AND usages = 0 RETURN f.name AS n -> every function, unfiltered, under the default column names cbm_parse now checks that the cursor sits on the end-of-input token before it returns success, and names the leftover token when it does not. The message adds a note about the one-WITH limit, but only when a standalone WITH really sits in the part that went unread. Appending it every time pointed the reader at WITH when the problem was a typo -- the BANANA query above holds no WITH anywhere. The test cannot simply look at the token the parse stopped on either: on the second-WITH query above it stops at OPTIONAL rather than at WITH, because parse_post_where consumes the first WITH and then has no way to take another MATCH stage. So it scans the unread tail, and skips a WITH straight after STARTS, which is the STARTS WITH operator rather than a clause. The check exposed a second defect. parse_post_where parses the branch after UNION by calling cbm_parse on a slice of the same tokens, using a separate parser. The outer parser's cursor never moved past the UNION keyword, so a valid UNION query looked unfinished. That cursor was already wrong; nothing caught it, because nothing checked where the cursor ended up. The UNION branch now moves the cursor to the end after the sub-parse succeeds, which is sound because that sub-parse no longer returns success with tokens left over. Three tests cover both directions. Removing only the guard turns the two rejection tests red with rc == 0 -- the parser reporting success on input it never finished reading -- while the acceptance test stays green, so the guard is load-bearing for exactly these two behaviours. The two rejection tests also pin the message. The BANANA case must name BANANA and must not mention WITH; that assertion was seen red first, at tests/test_cypher.c:226. The second-WITH case must still carry the note; that one passed before and after, and is the control that stops the note being suppressed everywhere instead of only where it misleads. Verified on macOS with Apple clang: make -f Makefile.cbm test-focused TEST_SUITES=cypher -> 186 passed guard removed, tests kept -> 184 passed, 2 failed make -f Makefile.cbm cbm -> exit 0, no warnings The full suite reports 7623 passed, 2 failed. Both failures are in tests/test_cli.c (lines 1748 and 6723) and reproduce on a clean tree with this change stashed out. They depend on the coding agents installed on the machine, not on this change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Joshua Richter <jrichter5781@gmail.com>
1 parent 3de05cd commit d21bb13

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;
@@ -4044,6 +4109,9 @@ SUITE(cypher) {
40444109
RUN_TEST(cypher_lex_full_query);
40454110
/* Parser */
40464111
RUN_TEST(cypher_parse_simple_node);
4112+
RUN_TEST(cypher_parse_rejects_trailing_tokens);
4113+
RUN_TEST(cypher_parse_rejects_second_with_clause);
4114+
RUN_TEST(cypher_parse_accepts_single_with_clause);
40474115
RUN_TEST(cypher_parse_relationship_outbound);
40484116
RUN_TEST(cypher_parse_relationship_inbound);
40494117
RUN_TEST(cypher_parse_relationship_any);

0 commit comments

Comments
 (0)