Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions src/cypher/cypher.c
Original file line number Diff line number Diff line change
Expand Up @@ -2230,15 +2230,49 @@ static const char *json_extract_prop(const char *json, const char *key, char *bu
p++;
}
if (*p == '"') {
/* String value */
/* String value — honor backslash escapes: without this, an embedded \"
* cuts the value short at the first escaped quote. */
p++;
size_t i = 0;
while (*p && *p != '"' && i < buf_sz - SKIP_ONE) {
if (*p == '\\' && p[SKIP_ONE] && i + SKIP_ONE < buf_sz - SKIP_ONE) {
buf[i++] = *p++; /* keep the escape pair intact */
}
buf[i++] = *p++;
}
buf[i] = '\0';
} else if (*p == '[' || *p == '{') {
/* Array/object value — copy the whole balanced construct. A scan-to-comma
* truncates at the first comma INSIDE the value: e.g. a decorators array
* ["@Roles('OWNER', 'ADMIN')","@Get()"] came back as ["@Roles('OWNER'. */
char open = *p;
char close = (open == '[') ? ']' : '}';
int depth = 0;
int in_str = 0;
size_t i = 0;
while (*p && i < buf_sz - SKIP_ONE) {
char c = *p;
if (in_str) {
if (c == '\\' && p[SKIP_ONE] && i + SKIP_ONE < buf_sz - SKIP_ONE) {
buf[i++] = *p++; /* escape pair stays intact */
} else if (c == '"') {
in_str = 0;
}
} else if (c == '"') {
in_str = 1;
} else if (c == open) {
depth++;
} else if (c == close) {
depth--;
}
buf[i++] = *p++;
if (!in_str && depth == 0) {
break; /* outer bracket closed */
}
}
buf[i] = '\0';
} else {
/* Numeric or other value */
/* Numeric or other scalar value */
size_t i = 0;
while (*p && *p != ',' && *p != '}' && *p != ' ' && i < buf_sz - SKIP_ONE) {
buf[i++] = *p++;
Expand Down
56 changes: 56 additions & 0 deletions tests/test_cypher.c
Original file line number Diff line number Diff line change
Expand Up @@ -2794,6 +2794,59 @@ TEST(cypher_multi_prop_projection_no_alias) {
* forked child so a stack overwrite (ASan abort, or a raw segfault) shows up
* as a killing signal instead of taking down the whole runner; the bounded
* path returns an ordinary error and the child exits cleanly. */
/* Property projection must return the WHOLE value of composite properties.
* json_extract_prop() scanned a non-string value up to the first ',' — so an
* array/object property was truncated at its first INTERNAL comma. Real-world
* hit: a NestJS handler's decorators
* ["@Roles('OWNER', 'ADMIN')","@Get()"]
* projected as ["@Roles('OWNER' — unusable for route/authz queries. */
TEST(cypher_exec_prop_array_with_internal_commas) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n = {.project = "test",
.label = "Method",
.name = "findAll",
.qualified_name = "test.PacienteController.findAll",
.file_path = "paciente.controller.ts",
.properties_json =
"{\"decorators\":[\"@Roles('OWNER', 'ADMIN')\",\"@Get()\"],\"lines\":3}"};
cbm_store_upsert_node(s, &n);

cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (m:Method) RETURN m.decorators, m.lines", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
/* whole array, commas and all — was ["@Roles('OWNER' before the fix */
ASSERT_STR_EQ(r.rows[0][0], "[\"@Roles('OWNER', 'ADMIN')\",\"@Get()\"]");
ASSERT_STR_EQ(r.rows[0][1], "3"); /* scalar sibling still parses */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}

/* A string property must not end at an ESCAPED quote: the scan stopped at the
* first '"' regardless of a preceding backslash, cutting the value short. */
TEST(cypher_exec_prop_string_with_escaped_quote) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n = {.project = "test",
.label = "Function",
.name = "parse",
.qualified_name = "test.parse",
.file_path = "p.ts",
.properties_json = "{\"signature\":\"(sep: \\\"a,b\\\") => void\"}"};
cbm_store_upsert_node(s, &n);

cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.signature", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "(sep: \\\"a,b\\\") => void"); /* was: (sep: \ */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}

TEST(cypher_wide_return_projection_bounded) {
#ifdef _WIN32
SKIP_PLATFORM("fork crash-isolation is POSIX-only; the parse-time bound is platform-agnostic");
Expand Down Expand Up @@ -2994,4 +3047,7 @@ SUITE(cypher) {
RUN_TEST(cypher_parse_unwind);
RUN_TEST(cypher_parse_unwind_var);
RUN_TEST(cypher_wide_return_projection_bounded);
/* Composite property projection (arrays/objects, escaped quotes) */
RUN_TEST(cypher_exec_prop_array_with_internal_commas);
RUN_TEST(cypher_exec_prop_string_with_escaped_quote);
}
Loading