From 2fabaa2b877986bdededb4f4f78e9a31014c6fdf Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 29 Sep 2026 02:28:48 +0200 Subject: [PATCH] fix(cypher): grow the OPTIONAL fallback buffer in the rel cross-join An OPTIONAL MATCH with a relationship, whose start and terminal variables are not bound by an earlier MATCH and whose start node pattern matches no node, is joined in cross_join_with_rels() with an empty candidate list. That path sized its output for a single binding, but the OPTIONAL fallback then kept one row per existing binding with a plain indexed store, so every row after the first was written past the end of the allocation. Route the fallback through binding_out_append(), the growing writer that the expansion branch of the same function and the other OPTIONAL fallbacks already use. The node-only join (cross_join_nodes) was fixed earlier by sizing its buffer for the fallback rows; the relationship variant kept the unchecked store. Add a cypher regression test that runs such a query over four bound functions and asserts one row per function with the optional variables unbound. Before this change the test runner reported a heap-buffer-overflow in cross_join_with_rels under ASan. Signed-off-by: Martin Vogel --- src/cypher/cypher.c | 5 ++++- tests/test_cypher.c | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 234f49087..27fd9eaff 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -4811,9 +4811,12 @@ static void cross_join_with_rels(cbm_store_t *store, cbm_pattern_t *patn, bindin free(tmp); } if (opt && extra_count == 0) { + /* OPTIONAL with no start node: keep the row, pattern vars unbound. + * The buffer was sized for a single row when extra_count == 0, so + * this write must grow it like every other writer here. */ binding_t nb = {0}; binding_copy(&nb, &(*bindings)[bi]); - new_bindings[new_count++] = nb; + (void)binding_out_append(&new_bindings, &new_count, &new_cap, &nb); } } for (int bi = 0; bi < *bind_count; bi++) { diff --git a/tests/test_cypher.c b/tests/test_cypher.c index 8bdee76c9..12b44a751 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -715,6 +715,45 @@ TEST(cypher_exec_optional_empty_label_no_overflow) { PASS(); } +/* Regression: the relationship variant of the test above. An OPTIONAL MATCH + * with a relationship whose start AND terminal are both unbound, and whose + * start matches zero nodes, drove cross_join_with_rels with extra_count == 0. + * That sized its output for a single binding, and the OPTIONAL fallback then + * wrote one row per existing binding without growing the buffer: a heap + * buffer overflow once the first MATCH bound more than one node (ASan: + * heap-buffer-overflow). The query text reaches this from the MCP query tool. */ +TEST(cypher_exec_optional_empty_label_rel_no_overflow) { + cbm_store_t *s = setup_cypher_store(); /* 4 Function nodes */ + cbm_cypher_result_t r = {0}; + + int rc = cbm_cypher_execute(s, + "MATCH (a:Function) OPTIONAL MATCH (x:NoSuchLabel)-[:CALLS]->(y) " + "RETURN a.name, x.name, y.name", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.col_count, 3); + /* One row per Function, each with x and y left unbound (""). */ + ASSERT_EQ(r.row_count, 4); + const char *want[] = {"HandleOrder", "ValidateOrder", "SubmitOrder", "LogError"}; + for (int w = 0; w < 4; w++) { + int seen = 0; + for (int i = 0; i < r.row_count; i++) { + if (strcmp(r.rows[i][0], want[w]) == 0) { + seen++; + } + } + ASSERT_EQ(seen, 1); + } + for (int i = 0; i < r.row_count; i++) { + ASSERT_STR_EQ(r.rows[i][1], ""); + ASSERT_STR_EQ(r.rows[i][2], ""); + } + + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + /* Regression: expand_pattern_rels sized its OPTIONAL-expansion output buffer as * bind_cap*10 + 1 — room for the bounded expansion (max_new = bind_cap*10) plus * a SINGLE OPTIONAL fallback row. When one source saturated the expansion to @@ -4673,6 +4712,7 @@ SUITE(cypher) { RUN_TEST(cypher_deep_nesting_rejected_not_crash); RUN_TEST(cypher_exec_match_all_functions); RUN_TEST(cypher_exec_optional_empty_label_no_overflow); + RUN_TEST(cypher_exec_optional_empty_label_rel_no_overflow); RUN_TEST(cypher_cross_join_alloc_rejects_overflow); RUN_TEST(cypher_exec_optional_rel_saturated_no_overflow); RUN_TEST(cypher_exec_optional_saturated_does_not_fabricate_no_match);