Skip to content

Commit 45b9728

Browse files
fix(cypher): refuse a query naming more variables than a binding holds
check_projection_scope models declared names in a fixed 32-entry array and skipped the check entirely when a query declared more. Nothing bounded how many variables a pattern could declare, so the out-of-scope refusal added by #1922 switched itself off on a wide query: the same undeclared name was refused at 10 declared names and quietly accepted at 35, answering a column of empty strings. Skipping was deliberate — a wrong refusal costs the caller a working query, which is worse than the silence. The premise was wrong. A binding holds CYP_MAX_VARS node variables and CYP_MAX_EDGE_VARS edge variables in plain arrays, and binding_set and binding_set_edge drop anything past those without a word, so a query naming more cannot be answered at all. Its extra names bind to nothing and project as blanks, which reads as "the graph holds no such data". The choice was never refuse-or-stay-quiet; it was refuse, or answer wrong. So bound the input. check_pattern_var_capacity counts distinct node and edge variables across every pattern — they share one binding, and an OPTIONAL MATCH pattern sits in the same array — and refuses beyond what a binding holds, naming the limit and how to get under it. Leaving the name off a node frees its slot, because an unnamed node takes none. Bounding the input rather than each consumer also makes three other silent drops unreachable: binding_set past 16, binding_set_edge past 8, and the column collection in execute_default_projection. Those would otherwise need an error path threaded up through every caller, since both binding setters return void. The two `< 0` branches in check_projection_scope stay, with comments saying they can no longer fire. They keep the guard standing if either bound ever moves. Fixes #1995 Signed-off-by: Joshua Richter <jrichter5781@gmail.com>
1 parent 1778637 commit 45b9728

2 files changed

Lines changed: 161 additions & 2 deletions

File tree

‎src/cypher/cypher.c‎

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5006,13 +5006,85 @@ static const char *scope_checkable_var(const cbm_return_item_t *item) {
50065006
return item->variable;
50075007
}
50085008

5009+
/* Says which limit the query passed and how to get under it. An unnamed node
5010+
* takes no slot, so dropping a name the query never uses is the cheap way out.
5011+
* Splitting the MATCH is NOT — every pattern of one query shares one binding,
5012+
* which is why the caller counts across all of them. Separate queries do work,
5013+
* because each gets a binding of its own. */
5014+
static char *var_capacity_error(const char *kind, int limit) {
5015+
char buf[CBM_SZ_256];
5016+
snprintf(buf, sizeof(buf),
5017+
"too many %s variables: a query can name at most %d — "
5018+
"leave the name off the ones you do not use, or run separate queries",
5019+
kind, limit);
5020+
return heap_strdup(buf);
5021+
}
5022+
5023+
/* A binding holds a fixed number of variables: CYP_MAX_VARS node variables and
5024+
* CYP_MAX_EDGE_VARS edge variables, both in plain arrays (see binding_t).
5025+
* binding_set and binding_set_edge drop anything past those without a word, so
5026+
* a query naming more variables than a binding holds cannot be answered — the
5027+
* extra names bind to nothing and project as empty strings, which reads as
5028+
* "the graph holds no such data" rather than "this query is too wide".
5029+
*
5030+
* Refuse such a query instead, before any row is touched. Bounding the input
5031+
* here is also what stops collect_declared_names below overflowing its array:
5032+
* the patterns contribute at most CYP_MAX_VARS + CYP_MAX_EDGE_VARS names, plus
5033+
* one UNWIND alias, which is well inside CYP_SCOPE_MAX_NAMES.
5034+
*
5035+
* Counts DISTINCT variables across every pattern, because they all land in the
5036+
* same binding: a multi-MATCH query shares one, and an OPTIONAL MATCH pattern
5037+
* sits in this same array (q->pattern_optional marks which). A node variable
5038+
* and an edge variable may share a name and each take a slot, because the
5039+
* binding keeps the two in separate arrays. */
5040+
static char *check_pattern_var_capacity(const cbm_query_t *q) {
5041+
const char *node_vars[CYP_MAX_VARS];
5042+
const char *edge_vars[CYP_MAX_EDGE_VARS];
5043+
int node_n = 0;
5044+
int edge_n = 0;
5045+
for (int pi = 0; pi < q->pattern_count; pi++) {
5046+
const cbm_pattern_t *pat = &q->patterns[pi];
5047+
for (int ni = 0; ni < pat->node_count; ni++) {
5048+
const char *var = pat->nodes[ni].variable;
5049+
if (!var || scope_holds(node_vars, node_n, var)) {
5050+
continue;
5051+
}
5052+
if (node_n >= CYP_MAX_VARS) {
5053+
return var_capacity_error("node", CYP_MAX_VARS);
5054+
}
5055+
node_vars[node_n++] = var;
5056+
}
5057+
for (int ri = 0; ri < pat->rel_count; ri++) {
5058+
const char *var = pat->rels[ri].variable;
5059+
if (!var || scope_holds(edge_vars, edge_n, var)) {
5060+
continue;
5061+
}
5062+
if (edge_n >= CYP_MAX_EDGE_VARS) {
5063+
return var_capacity_error("edge", CYP_MAX_EDGE_VARS);
5064+
}
5065+
edge_vars[edge_n++] = var;
5066+
}
5067+
}
5068+
return NULL;
5069+
}
5070+
50095071
/* Answers NULL when the query is fine, or a heap message naming the first
50105072
* variable that is not in scope. Checks one query; the caller walks a UNION. */
50115073
static char *check_projection_scope(const cbm_query_t *q) {
5074+
/* Runs first, so the rest of this function can trust that the query names
5075+
* no more variables than the arrays below can model. */
5076+
char *capacity_err = check_pattern_var_capacity(q);
5077+
if (capacity_err) {
5078+
return capacity_err;
5079+
}
5080+
50125081
const char *declared[CYP_SCOPE_MAX_NAMES];
50135082
int declared_n = collect_declared_names(q, declared, CYP_SCOPE_MAX_NAMES);
50145083
if (declared_n < 0) {
5015-
return NULL; /* too many names to model — stay quiet rather than guess */
5084+
/* Unreachable while the capacity check above holds. Kept so the guard
5085+
* still stands if either bound ever moves. Skipping the check was the
5086+
* old behaviour, and it let an out-of-scope name through in silence. */
5087+
return NULL;
50165088
}
50175089

50185090
/* A WITH still reads the pattern variables. */
@@ -5036,7 +5108,7 @@ static char *check_projection_scope(const cbm_query_t *q) {
50365108
if (q->with_clause) {
50375109
scope_n = collect_with_names(q->with_clause, after_with, CYP_SCOPE_MAX_NAMES);
50385110
if (scope_n < 0) {
5039-
return NULL;
5111+
return NULL; /* unreachable: a WITH holds at most CYP_SCOPE_MAX_NAMES items */
50405112
}
50415113
scope = after_with;
50425114
}

‎tests/test_cypher.c‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2917,6 +2917,90 @@ TEST(cypher_exec_return_star) {
29172917
PASS();
29182918
}
29192919

2920+
/* Build "MATCH (a0:NoSuchLabelXYZ)-[:CALLS]->(a1)-…->(aN-1)" into buf. The label
2921+
* matches nothing, so any query built on it is instant and needs no fixture. */
2922+
static void build_node_chain(char *buf, size_t buf_sz, int nodes) {
2923+
int off = snprintf(buf, buf_sz, "MATCH (a0:NoSuchLabelXYZ)");
2924+
for (int i = 1; i < nodes; i++) {
2925+
off += snprintf(buf + off, buf_sz - (size_t)off, "-[:CALLS]->(a%d)", i);
2926+
}
2927+
}
2928+
2929+
TEST(cypher_wide_pattern_refused) {
2930+
/* A binding holds CYP_MAX_VARS (16) node variables, and binding_set drops
2931+
* the 17th without a word. The query then answers a column of empty strings
2932+
* for every name it could not bind, which reads as "the graph holds no such
2933+
* data". Refuse the query instead of answering it wrong. */
2934+
cbm_store_t *s = setup_cypher_store();
2935+
char query[2048];
2936+
2937+
build_node_chain(query, sizeof(query), 20); /* 20 > CYP_MAX_VARS */
2938+
strncat(query, " RETURN a0.name", sizeof(query) - strlen(query) - 1);
2939+
cbm_cypher_result_t wide = {0};
2940+
ASSERT_TRUE(cbm_cypher_execute(s, query, "test", 0, &wide) != 0);
2941+
ASSERT_NOT_NULL(wide.error);
2942+
ASSERT_TRUE(strstr(wide.error, "node") != NULL); /* says which limit was passed */
2943+
cbm_cypher_result_free(&wide);
2944+
2945+
/* The width right at the bound still runs, so the guard refuses only what a
2946+
* binding genuinely cannot hold. */
2947+
build_node_chain(query, sizeof(query), 16);
2948+
strncat(query, " RETURN a0.name", sizeof(query) - strlen(query) - 1);
2949+
cbm_cypher_result_t ok = {0};
2950+
ASSERT_EQ(cbm_cypher_execute(s, query, "test", 0, &ok), 0);
2951+
cbm_cypher_result_free(&ok);
2952+
2953+
cbm_store_close(s);
2954+
PASS();
2955+
}
2956+
2957+
TEST(cypher_wide_edge_pattern_refused) {
2958+
/* Same shape on the edge table, where binding_set_edge stops at
2959+
* CYP_MAX_EDGE_VARS (8). Only NAMED relationships take a slot. */
2960+
cbm_store_t *s = setup_cypher_store();
2961+
char query[2048];
2962+
int off = snprintf(query, sizeof(query), "MATCH (a0:NoSuchLabelXYZ)");
2963+
for (int i = 1; i <= 9; i++) { /* 9 > CYP_MAX_EDGE_VARS */
2964+
off += snprintf(query + off, sizeof(query) - (size_t)off, "-[r%d:CALLS]->(a%d)", i, i);
2965+
}
2966+
snprintf(query + off, sizeof(query) - (size_t)off, " RETURN a0.name");
2967+
cbm_cypher_result_t wide = {0};
2968+
ASSERT_TRUE(cbm_cypher_execute(s, query, "test", 0, &wide) != 0);
2969+
ASSERT_NOT_NULL(wide.error);
2970+
ASSERT_TRUE(strstr(wide.error, "edge") != NULL);
2971+
cbm_cypher_result_free(&wide);
2972+
cbm_store_close(s);
2973+
PASS();
2974+
}
2975+
2976+
TEST(cypher_scope_check_survives_wide_pattern) {
2977+
/* Regression test for #1995. check_projection_scope models declared names in
2978+
* a fixed array and used to skip the check entirely when a query declared
2979+
* more than it held. So the same out-of-scope name was refused on a narrow
2980+
* query and quietly accepted on a wide one. Both must now be refused. */
2981+
cbm_store_t *s = setup_cypher_store();
2982+
char query[4096];
2983+
2984+
build_node_chain(query, sizeof(query), 10);
2985+
strncat(query, " RETURN zzz.name", sizeof(query) - strlen(query) - 1);
2986+
cbm_cypher_result_t narrow = {0};
2987+
ASSERT_TRUE(cbm_cypher_execute(s, query, "test", 0, &narrow) != 0);
2988+
ASSERT_NOT_NULL(narrow.error);
2989+
ASSERT_TRUE(strstr(narrow.error, "zzz") != NULL);
2990+
cbm_cypher_result_free(&narrow);
2991+
2992+
/* 35 declared names — this one used to answer a zzz.name column of nothing. */
2993+
build_node_chain(query, sizeof(query), 35);
2994+
strncat(query, " RETURN zzz.name", sizeof(query) - strlen(query) - 1);
2995+
cbm_cypher_result_t wide = {0};
2996+
ASSERT_TRUE(cbm_cypher_execute(s, query, "test", 0, &wide) != 0);
2997+
ASSERT_NOT_NULL(wide.error);
2998+
cbm_cypher_result_free(&wide);
2999+
3000+
cbm_store_close(s);
3001+
PASS();
3002+
}
3003+
29203004
TEST(cypher_parse_neq) {
29213005
cbm_query_t *q = NULL;
29223006
char *err = NULL;
@@ -4222,6 +4306,9 @@ SUITE(cypher) {
42224306
RUN_TEST(cypher_exec_where_is_null);
42234307
RUN_TEST(cypher_exec_where_is_not_null);
42244308
RUN_TEST(cypher_exec_return_star);
4309+
RUN_TEST(cypher_wide_pattern_refused);
4310+
RUN_TEST(cypher_wide_edge_pattern_refused);
4311+
RUN_TEST(cypher_scope_check_survives_wide_pattern);
42254312
RUN_TEST(cypher_parse_neq);
42264313
RUN_TEST(cypher_parse_in);
42274314
RUN_TEST(cypher_parse_is_null);

0 commit comments

Comments
 (0)