Skip to content

Commit 8483759

Browse files
authored
Merge pull request DeusData#1098 from JhohanBustamante/fix/cypher-composite-props
fix(cypher): return whole value for composite and escaped properties
2 parents b0aead2 + 48fa9cb commit 8483759

2 files changed

Lines changed: 92 additions & 2 deletions

File tree

‎src/cypher/cypher.c‎

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2286,15 +2286,49 @@ static const char *json_extract_prop(const char *json, const char *key, char *bu
22862286
p++;
22872287
}
22882288
if (*p == '"') {
2289-
/* String value */
2289+
/* String value — honor backslash escapes: without this, an embedded \"
2290+
* cuts the value short at the first escaped quote. */
22902291
p++;
22912292
size_t i = 0;
22922293
while (*p && *p != '"' && i < buf_sz - SKIP_ONE) {
2294+
if (*p == '\\' && p[SKIP_ONE] && i + SKIP_ONE < buf_sz - SKIP_ONE) {
2295+
buf[i++] = *p++; /* keep the escape pair intact */
2296+
}
2297+
buf[i++] = *p++;
2298+
}
2299+
buf[i] = '\0';
2300+
} else if (*p == '[' || *p == '{') {
2301+
/* Array/object value — copy the whole balanced construct. A scan-to-comma
2302+
* truncates at the first comma INSIDE the value: e.g. a decorators array
2303+
* ["@Roles('OWNER', 'ADMIN')","@Get()"] came back as ["@Roles('OWNER'. */
2304+
char open = *p;
2305+
char close = (open == '[') ? ']' : '}';
2306+
int depth = 0;
2307+
int in_str = 0;
2308+
size_t i = 0;
2309+
while (*p && i < buf_sz - SKIP_ONE) {
2310+
char c = *p;
2311+
if (in_str) {
2312+
if (c == '\\' && p[SKIP_ONE] && i + SKIP_ONE < buf_sz - SKIP_ONE) {
2313+
buf[i++] = *p++; /* escape pair stays intact */
2314+
} else if (c == '"') {
2315+
in_str = 0;
2316+
}
2317+
} else if (c == '"') {
2318+
in_str = 1;
2319+
} else if (c == open) {
2320+
depth++;
2321+
} else if (c == close) {
2322+
depth--;
2323+
}
22932324
buf[i++] = *p++;
2325+
if (!in_str && depth == 0) {
2326+
break; /* outer bracket closed */
2327+
}
22942328
}
22952329
buf[i] = '\0';
22962330
} else {
2297-
/* Numeric or other value */
2331+
/* Numeric or other scalar value */
22982332
size_t i = 0;
22992333
while (*p && *p != ',' && *p != '}' && *p != ' ' && i < buf_sz - SKIP_ONE) {
23002334
buf[i++] = *p++;

‎tests/test_cypher.c‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2911,6 +2911,59 @@ TEST(cypher_multi_prop_projection_no_alias) {
29112911
* forked child so a stack overwrite (ASan abort, or a raw segfault) shows up
29122912
* as a killing signal instead of taking down the whole runner; the bounded
29132913
* path returns an ordinary error and the child exits cleanly. */
2914+
/* Property projection must return the WHOLE value of composite properties.
2915+
* json_extract_prop() scanned a non-string value up to the first ',' — so an
2916+
* array/object property was truncated at its first INTERNAL comma. Real-world
2917+
* hit: a NestJS handler's decorators
2918+
* ["@Roles('OWNER', 'ADMIN')","@Get()"]
2919+
* projected as ["@Roles('OWNER' — unusable for route/authz queries. */
2920+
TEST(cypher_exec_prop_array_with_internal_commas) {
2921+
cbm_store_t *s = cbm_store_open_memory();
2922+
cbm_store_upsert_project(s, "test", "/tmp/test");
2923+
cbm_node_t n = {.project = "test",
2924+
.label = "Method",
2925+
.name = "findAll",
2926+
.qualified_name = "test.PacienteController.findAll",
2927+
.file_path = "paciente.controller.ts",
2928+
.properties_json =
2929+
"{\"decorators\":[\"@Roles('OWNER', 'ADMIN')\",\"@Get()\"],\"lines\":3}"};
2930+
cbm_store_upsert_node(s, &n);
2931+
2932+
cbm_cypher_result_t r = {0};
2933+
int rc = cbm_cypher_execute(s, "MATCH (m:Method) RETURN m.decorators, m.lines", "test", 0, &r);
2934+
ASSERT_EQ(rc, 0);
2935+
ASSERT_EQ(r.row_count, 1);
2936+
/* whole array, commas and all — was ["@Roles('OWNER' before the fix */
2937+
ASSERT_STR_EQ(r.rows[0][0], "[\"@Roles('OWNER', 'ADMIN')\",\"@Get()\"]");
2938+
ASSERT_STR_EQ(r.rows[0][1], "3"); /* scalar sibling still parses */
2939+
cbm_cypher_result_free(&r);
2940+
cbm_store_close(s);
2941+
PASS();
2942+
}
2943+
2944+
/* A string property must not end at an ESCAPED quote: the scan stopped at the
2945+
* first '"' regardless of a preceding backslash, cutting the value short. */
2946+
TEST(cypher_exec_prop_string_with_escaped_quote) {
2947+
cbm_store_t *s = cbm_store_open_memory();
2948+
cbm_store_upsert_project(s, "test", "/tmp/test");
2949+
cbm_node_t n = {.project = "test",
2950+
.label = "Function",
2951+
.name = "parse",
2952+
.qualified_name = "test.parse",
2953+
.file_path = "p.ts",
2954+
.properties_json = "{\"signature\":\"(sep: \\\"a,b\\\") => void\"}"};
2955+
cbm_store_upsert_node(s, &n);
2956+
2957+
cbm_cypher_result_t r = {0};
2958+
int rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.signature", "test", 0, &r);
2959+
ASSERT_EQ(rc, 0);
2960+
ASSERT_EQ(r.row_count, 1);
2961+
ASSERT_STR_EQ(r.rows[0][0], "(sep: \\\"a,b\\\") => void"); /* was: (sep: \ */
2962+
cbm_cypher_result_free(&r);
2963+
cbm_store_close(s);
2964+
PASS();
2965+
}
2966+
29142967
TEST(cypher_wide_return_projection_bounded) {
29152968
#ifdef _WIN32
29162969
SKIP_PLATFORM("fork crash-isolation is POSIX-only; the parse-time bound is platform-agnostic");
@@ -3116,4 +3169,7 @@ SUITE(cypher) {
31163169
RUN_TEST(cypher_parse_unwind);
31173170
RUN_TEST(cypher_parse_unwind_var);
31183171
RUN_TEST(cypher_wide_return_projection_bounded);
3172+
/* Composite property projection (arrays/objects, escaped quotes) */
3173+
RUN_TEST(cypher_exec_prop_array_with_internal_commas);
3174+
RUN_TEST(cypher_exec_prop_string_with_escaped_quote);
31193175
}

0 commit comments

Comments
 (0)