fix(cypher): return whole value for composite and escaped properties

json_extract_prop() scanned a non-string property up to the first ',' and a
string property up to the first '"', ignoring nesting and backslash escapes.
Any array/object property was therefore truncated at its first INTERNAL comma,
and any string containing an escaped quote was cut short.

    decorators: ["@Roles('OWNER', 'ADMIN')","@Get()"]
    projected as: ["@Roles('OWNER'

This makes decorator/route/authz queries unusable on frameworks whose
decorators take multiple arguments (NestJS, Angular, Spring). Values are now
copied as balanced constructs, honoring string state and escape pairs; scalar
and plain-string paths are unchanged.

Signed-off-by: KolisCode <jhohantma@gmail.com>
This commit is contained in:
KolisCode
2026-07-14 22:31:44 -05:00
parent feadbe1955
commit 48fa9cbc03
2 changed files with 92 additions and 2 deletions
+36 -2
View File
@@ -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++;
+56
View File
@@ -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");
@@ -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);
}