fix(cypher): free a partially parsed MATCH pattern

parse_match_pattern allocated the pattern's node and relationship arrays
and returned on a parse error without freeing them. Its callers count a
pattern only after it parsed, so cbm_query_free never saw it: every query
whose pattern failed partway leaked, including every named path
(`MATCH p = (...)`, not supported, fails at the first node). The element
whose parse failed leaked too (its variable, label or properties), and
parse_props freed only the array, not the entries it had already parsed.

The fuzz job found it on its first run, from a checked-in seed:
`MATCH p = (a:Class)-[:INHERITS*1..3]->(b:Class) RETURN a.name, length(p)`
(LeakSanitizer: 288 bytes in 2 allocations).

parse_match_pattern now frees and zeroes the pattern on every failure,
counting the failed element (parse_node and parse_rel zero it first, so
only what they allocated is freed). parse_props frees its parsed entries
on every failure through one helper.

New test cypher_parse_error_frees_partial_pattern drives five failure
points: a named path, a property list after one entry, a cut-off
relationship, a missing label after a relationship, and a second MATCH
clause's properties.

Checked with the fuzz binary (clang 18 ASan/LSan, arm64 container) on the
five queries: before this change the first four leak (288, 306, 339 and
348 bytes); after it all five are clean, and scripts/fuzz.sh all --runs
20000 --seed 1 passes for extract, cypher and config. The fifth goes
through the same parse_props failure as the second. macOS: the cypher
suite passes, 217 tests.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-10-12 06:31:53 +02:00
parent 738cfebb60
commit 70543ba829
2 changed files with 63 additions and 17 deletions
+33 -17
View File
@@ -505,6 +505,15 @@ static const cbm_token_t *expect(parser_t *p, cbm_token_type_t type) {
return NULL;
}
/* Free the first n entries of a property array and the array itself. */
static void free_prop_entries(cbm_prop_filter_t *arr, int n) {
for (int i = 0; i < n; i++) {
safe_str_free(&arr[i].key);
safe_str_free(&arr[i].value);
}
free(arr);
}
/* Parse inline properties: {key: "value", ...} */
static int parse_props(parser_t *p, cbm_prop_filter_t **out, int *count) {
*out = NULL;
@@ -521,18 +530,19 @@ static int parse_props(parser_t *p, cbm_prop_filter_t **out, int *count) {
}
while (!check(p, TOK_RBRACE) && !check(p, TOK_EOF)) {
/* Every failure frees the entries already parsed, not only the array. */
const cbm_token_t *key = expect(p, TOK_IDENT);
if (!key) {
free(arr);
free_prop_entries(arr, n);
return CBM_NOT_FOUND;
}
if (!expect(p, TOK_COLON)) {
free(arr);
free_prop_entries(arr, n);
return CBM_NOT_FOUND;
}
const cbm_token_t *val = expect(p, TOK_STRING);
if (!val) {
free(arr);
free_prop_entries(arr, n);
return CBM_NOT_FOUND;
}
@@ -540,11 +550,7 @@ static int parse_props(parser_t *p, cbm_prop_filter_t **out, int *count) {
int new_cap = cap * PAIR_LEN;
void *tmp = realloc(arr, new_cap * sizeof(cbm_prop_filter_t));
if (!tmp) {
for (int i = 0; i < n; i++) {
safe_str_free(&arr[i].key);
safe_str_free(&arr[i].value);
}
free(arr);
free_prop_entries(arr, n);
return CBM_NOT_FOUND;
}
arr = tmp;
@@ -555,11 +561,7 @@ static int parse_props(parser_t *p, cbm_prop_filter_t **out, int *count) {
if (!new_key || !new_val) {
safe_str_free(&new_key);
safe_str_free(&new_val);
for (int i = 0; i < n; i++) {
safe_str_free(&arr[i].key);
safe_str_free(&arr[i].value);
}
free(arr);
free_prop_entries(arr, n);
return CBM_NOT_FOUND;
}
arr[n].key = new_key;
@@ -1864,7 +1866,11 @@ static int parse_return(parser_t *p, cbm_return_clause_t **out) {
return parse_return_or_with(p, out, false);
}
/* Parse a single MATCH pattern into pat */
static void free_pattern(cbm_pattern_t *pat);
/* Parse a single MATCH pattern into pat. On failure pat is freed and zeroed:
* the caller counts a pattern only after it parsed, so a partial one would
* otherwise leak (a named path "p = (...)" failed this way on every query). */
static int parse_match_pattern(parser_t *p, cbm_pattern_t *pat) {
memset(pat, 0, sizeof(*pat));
int node_cap = CYP_INIT_CAP4;
@@ -1872,8 +1878,11 @@ static int parse_match_pattern(parser_t *p, cbm_pattern_t *pat) {
pat->nodes = malloc(node_cap * sizeof(cbm_node_pattern_t));
pat->rels = calloc(rel_cap, sizeof(cbm_rel_pattern_t));
/* A failed parse_node / parse_rel leaves its element zeroed except for
* what it allocated, so counting that element frees its partial state. */
if (parse_node(p, &pat->nodes[0]) < 0) {
return CBM_NOT_FOUND;
pat->node_count = SKIP_ONE;
goto fail;
}
pat->node_count = SKIP_ONE;
@@ -1883,7 +1892,8 @@ static int parse_match_pattern(parser_t *p, cbm_pattern_t *pat) {
pat->rels = safe_realloc(pat->rels, rel_cap * sizeof(cbm_rel_pattern_t));
}
if (parse_rel(p, &pat->rels[pat->rel_count]) < 0) {
return CBM_NOT_FOUND;
pat->rel_count++;
goto fail;
}
pat->rel_count++;
@@ -1892,11 +1902,17 @@ static int parse_match_pattern(parser_t *p, cbm_pattern_t *pat) {
pat->nodes = safe_realloc(pat->nodes, node_cap * sizeof(cbm_node_pattern_t));
}
if (parse_node(p, &pat->nodes[pat->node_count]) < 0) {
return CBM_NOT_FOUND;
pat->node_count++;
goto fail;
}
pat->node_count++;
}
return 0;
fail:
free_pattern(pat);
memset(pat, 0, sizeof(*pat));
return CBM_NOT_FOUND;
}
/* Parse UNWIND [...] AS var clause into query */
+30
View File
@@ -567,6 +567,35 @@ TEST(cypher_parse_error) {
PASS();
}
/* A pattern that fails partway frees what it already parsed. Each query
* fails at a different point; under LeakSanitizer the first four leaked
* before the fix (the first is the fuzz seed that found it), and the fifth
* reaches the same property-list failure through a second MATCH. */
TEST(cypher_parse_error_frees_partial_pattern) {
static const char *const queries[] = {
/* named path: unsupported, fails at the first node */
"MATCH p = (a:Class)-[:INHERITS*1..3]->(b:Class) RETURN a.name, length(p)",
/* property list fails after one parsed entry */
"MATCH (a:Function {name: \"x\", 7}) RETURN a",
/* relationship cut off after its variable */
"MATCH (a:Function)-[r:CALLS",
/* second node's label missing, after a parsed relationship */
"MATCH (a:Function)-[:CALLS]->(b:",
/* a second MATCH clause's pattern fails inside its properties */
"MATCH (a:Function) MATCH (b:Function {name: \"y\" 5}) RETURN a",
};
for (size_t i = 0; i < sizeof(queries) / sizeof(queries[0]); i++) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse(queries[i], &q, &err);
ASSERT_EQ(rc, -1);
ASSERT_NOT_NULL(err);
free(err);
cbm_query_free(q);
}
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* EXECUTION TESTS (end-to-end against store)
* ══════════════════════════════════════════════════════════════════ */
@@ -5116,6 +5145,7 @@ SUITE(cypher) {
RUN_TEST(cypher_parse_return_distinct);
RUN_TEST(cypher_parse_inline_props);
RUN_TEST(cypher_parse_error);
RUN_TEST(cypher_parse_error_frees_partial_pattern);
/* Execution */
RUN_TEST(cypher_exec_deadline_aborts_runaway_query_issue601);
RUN_TEST(cypher_exec_deadline_allows_normal_query_issue601);