diff --git a/src/pipeline/pass_route_nodes.c b/src/pipeline/pass_route_nodes.c index 071f20c413..23dff81503 100644 --- a/src/pipeline/pass_route_nodes.c +++ b/src/pipeline/pass_route_nodes.c @@ -408,62 +408,59 @@ void cbm_pipeline_emit_def_route(cbm_gbuf_t *gb, int64_t func_id, const CBMDefin snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", rm, cbm_route_canon_path(def->route_path, cpath, sizeof(cpath))); char rprops[CBM_SZ_256]; - snprintf(rprops, sizeof(rprops), "{\"method\":\"%s\",\"source\":\"decorator\"}", rm); + char esc_m[CBM_SZ_128]; + cbm_json_escape(esc_m, sizeof(esc_m), rm); + (void)snprintf(rprops, sizeof(rprops), "{\"method\":\"%s\",\"source\":\"decorator\"}", esc_m); int64_t route_id = cbm_gbuf_upsert_node(gb, "Route", def->route_path, route_qn, def->file_path ? def->file_path : rel, 0, 0, rprops); - char hprops[CBM_SZ_512]; + char hprops[CBM_SZ_1K]; /* must exceed escaped value + wrapper or snprintf cuts the + closing brace */ char esc_h[CBM_SZ_512]; cbm_json_escape(esc_h, sizeof(esc_h), def->qualified_name); - snprintf(hprops, sizeof(hprops), "{\"handler\":\"%s\"}", esc_h); + (void)snprintf(hprops, sizeof(hprops), "{\"handler\":\"%s\"}", esc_h); cbm_gbuf_insert_edge(gb, func_id, route_id, "HANDLES", hprops); } /* Phase 2a: Ensure all functions with route_path properties have Route+HANDLES edges. * During incremental indexing, only changed files get Route nodes from extraction. * This pass scans ALL Function/Method nodes and creates missing Route+HANDLES. */ -/* Extract a JSON string property value into buf. Returns true if found. */ -static bool extract_json_prop(const char *json, const char *key, char *buf, int bufsz) { - if (!json) { +/* Extract the string property `key` of a parsed properties object into buf, + * with its escapes decoded. Returns true if found: a non-empty string with no + * NUL that fits buf. Nothing is copied otherwise. */ +static bool extract_json_prop(yyjson_val *root, const char *key, char *buf, size_t bufsz) { + yyjson_val *val = yyjson_obj_get(root, key); + const char *str = yyjson_get_str(val); + size_t len = yyjson_get_len(val); + if (!str || len == 0 || len >= bufsz || memchr(str, '\0', len)) { return false; } - char pattern[CBM_SZ_64]; - snprintf(pattern, sizeof(pattern), "\"%s\":\"", key); - const char *p = strstr(json, pattern); - if (!p) { - return false; - } - p += strlen(pattern); - const char *end = strchr(p, '"'); - if (!end || end <= p) { - return false; - } - int len = (int)(end - p); - if (len >= bufsz) { - return false; - } - memcpy(buf, p, (size_t)len); - buf[len] = '\0'; + memcpy(buf, str, len + SKIP_ONE); return true; } /* Process a single Function/Method node: create Route+HANDLES if it has route_path. * Returns 1 if a new HANDLES edge was created, 0 otherwise. */ static int ensure_one_decorator_route(cbm_gbuf_t *gb, const cbm_gbuf_node_t *func) { - if (!func->properties_json) { + /* Most nodes carry no route: skip them before parsing their properties. */ + if (!func->properties_json || !strstr(func->properties_json, "\"route_path\"")) { return 0; } + /* Properties hold source text as its bytes, which need not be UTF-8. */ + yyjson_doc *doc = yyjson_read(func->properties_json, strlen(func->properties_json), + YYJSON_READ_ALLOW_INVALID_UNICODE); + yyjson_val *root = yyjson_doc_get_root(doc); char path[CBM_SZ_256]; - if (!extract_json_prop(func->properties_json, "route_path", path, sizeof(path))) { - return 0; + char method[CBM_SZ_16] = "ANY"; + bool routed = extract_json_prop(root, "route_path", path, sizeof(path)) && path[0] == '/'; + if (routed) { + extract_json_prop(root, "route_method", method, sizeof(method)); } - if (path[0] != '/') { + yyjson_doc_free(doc); + if (!routed) { return 0; } - char method[CBM_SZ_16] = "ANY"; - extract_json_prop(func->properties_json, "route_method", method, sizeof(method)); - char route_qn[CBM_ROUTE_QN_SIZE]; char cpath[CBM_SZ_256]; snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", method, @@ -471,7 +468,9 @@ static int ensure_one_decorator_route(cbm_gbuf_t *gb, const cbm_gbuf_node_t *fun const cbm_gbuf_node_t *existing = cbm_gbuf_find_by_qn(gb, route_qn); char rprops[CBM_SZ_256]; - snprintf(rprops, sizeof(rprops), "{\"method\":\"%s\",\"source\":\"decorator\"}", method); + char esc_m[CBM_SZ_128]; + cbm_json_escape(esc_m, sizeof(esc_m), method); + (void)snprintf(rprops, sizeof(rprops), "{\"method\":\"%s\",\"source\":\"decorator\"}", esc_m); int64_t route_id = cbm_gbuf_upsert_node(gb, "Route", path, route_qn, func->file_path ? func->file_path : "", 0, 0, rprops); @@ -486,9 +485,12 @@ static int ensure_one_decorator_route(cbm_gbuf_t *gb, const cbm_gbuf_node_t *fun } } - char hprops[CBM_SZ_512]; - snprintf(hprops, sizeof(hprops), "{\"handler\":\"%s\"}", - func->qualified_name ? func->qualified_name : ""); + /* The same properties cbm_pipeline_emit_def_route gives the edge. */ + char hprops[CBM_SZ_1K]; /* must exceed escaped value + wrapper or snprintf cuts the + closing brace */ + char esc_h[CBM_SZ_512]; + cbm_json_escape(esc_h, sizeof(esc_h), func->qualified_name ? func->qualified_name : ""); + (void)snprintf(hprops, sizeof(hprops), "{\"handler\":\"%s\"}", esc_h); cbm_gbuf_insert_edge(gb, func->id, route_id, "HANDLES", hprops); return SKIP_ONE; } @@ -1180,6 +1182,17 @@ typedef struct { int files_seen; } sveltekit_ctx_t; +/* The properties of a SvelteKit handler's HANDLES edge: its QN, escaped as + * every HANDLES edge's handler is. */ +static void sveltekit_handles_props(char *out, size_t outsz, const cbm_gbuf_node_t *child, + bool is_actions) { + char esc_h[CBM_SZ_512]; + cbm_json_escape(esc_h, sizeof(esc_h), + child->qualified_name ? child->qualified_name : child->name); + (void)snprintf(out, outsz, "{\"handler\":\"%s\",\"framework\":\"sveltekit\"%s}", esc_h, + is_actions ? ",\"via\":\"actions_object\"" : ""); +} + /* Process one File node: if it matches a SvelteKit server-side file, * synthesise a Route node and HANDLES edges from any handler functions * (or actions Variable) defined in the file. */ @@ -1256,10 +1269,9 @@ static void sveltekit_file_visitor(const cbm_gbuf_node_t *node, void *userdata) } ctx->routes_created++; - char hprops[CBM_SZ_256]; - snprintf(hprops, sizeof(hprops), "{\"handler\":\"%s\",\"framework\":\"sveltekit\"%s}", - child->qualified_name ? child->qualified_name : child->name, - is_actions ? ",\"via\":\"actions_object\"" : ""); + char hprops[CBM_SZ_1K]; /* must exceed escaped value + wrapper or snprintf cuts the + closing brace */ + sveltekit_handles_props(hprops, sizeof(hprops), child, is_actions); cbm_gbuf_insert_edge(ctx->gb, child->id, route_id, "HANDLES", hprops); ctx->handles_created++; } diff --git a/tests/test_infrascan.c b/tests/test_infrascan.c index abfa8788c0..3b31f62e1f 100644 --- a/tests/test_infrascan.c +++ b/tests/test_infrascan.c @@ -2,10 +2,12 @@ #include "graph_buffer/graph_buffer.h" #include "pipeline/pipeline_internal.h" #include "service_patterns.h" +#include "yyjson/yyjson.h" #include #include #include +#include static int has_data_flow(cbm_gbuf_t *gb, int64_t source_id, int64_t target_id) { const cbm_gbuf_edge_t **edges = NULL; @@ -385,6 +387,264 @@ TEST(infrascan_http_calls_join_matching_handler_route) { PASS(); } +/* An unchanged handler's Route is rebuilt from its stored route metadata. The + * metadata is JSON, so a route path with a quote or a backslash (a legal POSIX + * directory name) is stored escaped; reading it up to the first quote named a + * second Route "/api/a\" beside the extracted one, and the handler QN went into + * the HANDLES properties unescaped. Running the pass twice proves the rebuild + * finds the Route it made instead of adding another edge. */ +TEST(infrascan_decorator_route_metadata_keeps_its_escapes) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/cbm_infrascan_route_escapes"); + ASSERT_NOT_NULL(gb); + int64_t handler = cbm_gbuf_upsert_node( + gb, "Function", "GET", "test.app.api.a\"b.route.GET", "app/api/a\"b/route.ts", 1, 1, + "{\"route_path\":\"/api/a\\\"b\\\\c\",\"route_method\":\"GET\"}"); + ASSERT_GT(handler, 0); + + cbm_pipeline_create_route_nodes(gb); + cbm_pipeline_create_route_nodes(gb); + + const cbm_gbuf_node_t **routes = NULL; + int route_count = 0; + ASSERT_EQ(cbm_gbuf_find_by_label(gb, "Route", &routes, &route_count), 0); + ASSERT_EQ(route_count, 1); + const cbm_gbuf_node_t *route = cbm_gbuf_find_by_qn(gb, "__route__GET__/api/a\"b\\c"); + ASSERT_NOT_NULL(route); + ASSERT_STR_EQ(route->name, "/api/a\"b\\c"); + ASSERT_STR_EQ(route->file_path, "app/api/a\"b/route.ts"); + const cbm_gbuf_edge_t **edges = NULL; + int edge_count = 0; + ASSERT_EQ(cbm_gbuf_find_edges_by_target_type(gb, route->id, "HANDLES", &edges, &edge_count), 0); + ASSERT_EQ(edge_count, 1); + ASSERT_EQ(edges[0]->source_id, handler); + yyjson_doc *doc = yyjson_read(edges[0]->properties_json, strlen(edges[0]->properties_json), 0); + ASSERT_NOT_NULL(doc); + const char *qn = yyjson_get_str(yyjson_obj_get(yyjson_doc_get_root(doc), "handler")); + bool same_handler = qn && strcmp(qn, "test.app.api.a\"b.route.GET") == 0; + yyjson_doc_free(doc); + ASSERT_TRUE(same_handler); + + cbm_gbuf_free(gb); + PASS(); +} + +/* Does the one HANDLES edge into Route route_qn hold properties that parse to + * {"handler": qn}? */ +static bool handles_names_handler(cbm_gbuf_t *gb, const char *route_qn, const char *qn) { + const cbm_gbuf_node_t *route = cbm_gbuf_find_by_qn(gb, route_qn); + const cbm_gbuf_edge_t **edges = NULL; + int edge_count = 0; + if (!route || + cbm_gbuf_find_edges_by_target_type(gb, route->id, "HANDLES", &edges, &edge_count) != 0 || + edge_count != 1) { + return false; + } + yyjson_doc *doc = yyjson_read(edges[0]->properties_json, strlen(edges[0]->properties_json), 0); + const char *handler = yyjson_get_str(yyjson_obj_get(yyjson_doc_get_root(doc), "handler")); + bool same = handler && strcmp(handler, qn) == 0; + yyjson_doc_free(doc); + return same; +} + +/* The HANDLES properties hold the handler's QN escaped. A QN that fills the + * escape buffer left the closing `"}` off them, and one malformed properties + * row fails every query that reads edge properties. The edge extraction emits + * and the one an unchanged handler's metadata rebuilds must both parse. */ +TEST(infrascan_handles_properties_hold_a_long_handler_qn) { + char qn[CBM_SZ_512]; + memset(qn, 'q', sizeof(qn) - SKIP_ONE); + qn[sizeof(qn) - SKIP_ONE] = '\0'; + + cbm_gbuf_t *emitted = cbm_gbuf_new("test", "/tmp/cbm_infrascan_long_qn_emit"); + ASSERT_NOT_NULL(emitted); + int64_t handler = cbm_gbuf_upsert_node(emitted, "Function", "GET", qn, "server.ts", 1, 1, "{}"); + ASSERT_GT(handler, 0); + CBMDefinition def = {0}; + def.qualified_name = qn; + def.file_path = "server.ts"; + def.route_path = "/long"; + def.route_method = "GET"; + cbm_pipeline_emit_def_route(emitted, handler, &def, "server.ts"); + bool emitted_parses = handles_names_handler(emitted, "__route__GET__/long", qn); + cbm_gbuf_free(emitted); + + cbm_gbuf_t *rebuilt = cbm_gbuf_new("test", "/tmp/cbm_infrascan_long_qn_rebuild"); + ASSERT_NOT_NULL(rebuilt); + ASSERT_GT(cbm_gbuf_upsert_node(rebuilt, "Function", "GET", qn, "server.ts", 1, 1, + "{\"route_path\":\"/long\",\"route_method\":\"GET\"}"), + 0); + cbm_pipeline_create_route_nodes(rebuilt); + bool rebuilt_parses = handles_names_handler(rebuilt, "__route__GET__/long", qn); + cbm_gbuf_free(rebuilt); + + ASSERT_TRUE(emitted_parses); + ASSERT_TRUE(rebuilt_parses); + PASS(); +} + +/* Does Route route_qn hold properties that parse to {"method": method}? */ +static bool route_names_method(cbm_gbuf_t *gb, const char *route_qn, const char *method) { + const cbm_gbuf_node_t *route = cbm_gbuf_find_by_qn(gb, route_qn); + if (!route || !route->properties_json) { + return false; + } + yyjson_doc *doc = yyjson_read(route->properties_json, strlen(route->properties_json), 0); + const char *got = yyjson_get_str(yyjson_obj_get(yyjson_doc_get_root(doc), "method")); + bool same = got && strcmp(got, method) == 0; + yyjson_doc_free(doc); + return same; +} + +/* A route method is source text where a Python DRF action names it + * (`@action(methods=['a"b'])`), so the Route's properties hold it escaped: + * both the Route extraction emits and the one rebuilt from the handler's + * metadata must parse. */ +TEST(infrascan_route_properties_escape_the_method) { + cbm_gbuf_t *emitted = cbm_gbuf_new("test", "/tmp/cbm_infrascan_method_emit"); + ASSERT_NOT_NULL(emitted); + int64_t handler = + cbm_gbuf_upsert_node(emitted, "Function", "act", "test.views.act", "views.py", 1, 1, "{}"); + ASSERT_GT(handler, 0); + CBMDefinition def = {0}; + def.qualified_name = "test.views.act"; + def.file_path = "views.py"; + def.route_path = "/m"; + def.route_method = "A\"B"; + cbm_pipeline_emit_def_route(emitted, handler, &def, "views.py"); + bool emitted_parses = route_names_method(emitted, "__route__A\"B__/m", "A\"B"); + cbm_gbuf_free(emitted); + + cbm_gbuf_t *rebuilt = cbm_gbuf_new("test", "/tmp/cbm_infrascan_method_rebuild"); + ASSERT_NOT_NULL(rebuilt); + ASSERT_GT(cbm_gbuf_upsert_node(rebuilt, "Function", "act", "test.views.act", "views.py", 1, 1, + "{\"route_path\":\"/m\",\"route_method\":\"A\\\"B\"}"), + 0); + cbm_pipeline_create_route_nodes(rebuilt); + bool rebuilt_parses = route_names_method(rebuilt, "__route__A\"B__/m", "A\"B"); + cbm_gbuf_free(rebuilt); + + ASSERT_TRUE(emitted_parses); + ASSERT_TRUE(rebuilt_parses); + PASS(); +} + +/* A SvelteKit +server handler's HANDLES properties hold its QN escaped, as + * every other HANDLES edge's do: a quote in a route directory, or a QN longer + * than the old 256-byte properties buffer, made them malformed. */ +TEST(infrascan_sveltekit_handles_properties_escape_the_handler_qn) { + char dir[CBM_SZ_512]; + snprintf(dir, sizeof(dir), "a\"b/%0300d", 0); + char file[CBM_SZ_1K]; + snprintf(file, sizeof(file), "src/routes/%s/+server.ts", dir); + char qn[CBM_SZ_1K]; + snprintf(qn, sizeof(qn), "test.src.routes.%s.+server.GET", dir); + + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/cbm_infrascan_sveltekit_handles"); + ASSERT_NOT_NULL(gb); + int64_t file_id = + cbm_gbuf_upsert_node(gb, "File", "+server.ts", "test.server", file, 1, 1, "{}"); + int64_t handler = cbm_gbuf_upsert_node(gb, "Function", "GET", qn, file, 1, 1, "{}"); + ASSERT_GT(file_id, 0); + ASSERT_GT(handler, 0); + cbm_gbuf_insert_edge(gb, file_id, handler, "DEFINES", "{}"); + cbm_pipeline_create_route_nodes(gb); + + const cbm_gbuf_edge_t **edges = NULL; + int edge_count = 0; + bool same = false; + if (cbm_gbuf_find_edges_by_source_type(gb, handler, "HANDLES", &edges, &edge_count) == 0 && + edge_count == 1) { + yyjson_doc *doc = + yyjson_read(edges[0]->properties_json, strlen(edges[0]->properties_json), 0); + yyjson_val *root = yyjson_doc_get_root(doc); + const char *got = yyjson_get_str(yyjson_obj_get(root, "handler")); + const char *framework = yyjson_get_str(yyjson_obj_get(root, "framework")); + same = got && strcmp(got, qn) == 0 && framework && strcmp(framework, "sveltekit") == 0; + yyjson_doc_free(doc); + } + cbm_gbuf_free(gb); + ASSERT_TRUE(same); + PASS(); +} + +/* Route metadata that does not parse, is not a top-level string, holds a NUL or + * does not fit the path buffer names no Route at all, rather than whatever text + * happens to sit between two quotes. The 255-byte path is the longest that fits + * and must still route. */ +TEST(infrascan_decorator_route_metadata_rejects_malformed_values) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/cbm_infrascan_route_malformed"); + ASSERT_NOT_NULL(gb); + char longest[CBM_SZ_256 + CBM_SZ_64]; + char too_long[CBM_SZ_256 + CBM_SZ_64]; + char path[CBM_SZ_256 + 1]; + path[0] = '/'; + memset(path + 1, 'x', CBM_SZ_256 - 1); + path[CBM_SZ_256] = '\0'; + snprintf(too_long, sizeof(too_long), "{\"route_path\":\"%s\"}", path); + path[CBM_SZ_256 - 1] = '\0'; + snprintf(longest, sizeof(longest), "{\"route_path\":\"%s\"}", path); + const char *props[] = { + "{\"route_path\":\"/truncated\"", + "{\"meta\":{\"route_path\":\"/nested\"}}", + "{\"route_path\":[\"/array\"]}", + "{\"route_path\":42}", + "{\"route_path\":\"/a\\u0000b\"}", + "{\"route_path\":\"\"}", + "{\"route_path\":\"relative\"}", + "[\"route_path\",\"/not-an-object\"]", + too_long, + }; + const int prop_count = (int)(sizeof(props) / sizeof(props[0])); + for (int i = 0; i < prop_count; i++) { + char qn[CBM_SZ_32]; + snprintf(qn, sizeof(qn), "test.bad%d", i); + ASSERT_GT(cbm_gbuf_upsert_node(gb, "Function", qn, qn, "server.ts", 1, 1, props[i]), 0); + } + cbm_pipeline_create_route_nodes(gb); + ASSERT_EQ(cbm_gbuf_edge_count_by_type(gb, "HANDLES"), 0); + const cbm_gbuf_node_t **routes = NULL; + int route_count = 0; + ASSERT_EQ(cbm_gbuf_find_by_label(gb, "Route", &routes, &route_count), 0); + ASSERT_EQ(route_count, 0); + + int64_t fits = + cbm_gbuf_upsert_node(gb, "Function", "fits", "test.fits", "server.ts", 1, 1, longest); + ASSERT_GT(fits, 0); + cbm_pipeline_create_route_nodes(gb); + ASSERT_EQ(cbm_gbuf_find_by_label(gb, "Route", &routes, &route_count), 0); + ASSERT_EQ(route_count, 1); + ASSERT_STR_EQ(routes[0]->name, path); + ASSERT_EQ(cbm_gbuf_edge_count_by_type(gb, "HANDLES"), 1); + + cbm_gbuf_free(gb); + PASS(); +} + +/* Properties hold source text as its bytes, which need not be UTF-8: a + * Latin-1 docstring beside the route metadata, or a Latin-1 route path. The + * Route extraction emits for them must come back from the stored metadata too, + * as it did before the metadata was parsed. */ +TEST(infrascan_decorator_route_metadata_survives_invalid_utf8) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/cbm_infrascan_route_latin1"); + ASSERT_NOT_NULL(gb); + ASSERT_GT(cbm_gbuf_upsert_node(gb, "Function", "doc", "test.doc", "views.py", 1, 1, + "{\"docstring\":\"caf\xe9\",\"route_path\":\"/doc\"," + "\"route_method\":\"GET\"}"), + 0); + ASSERT_GT(cbm_gbuf_upsert_node(gb, "Function", "GET", "test.app.caf\xe9.route.GET", + "app/caf\xe9/route.ts", 1, 1, + "{\"route_path\":\"/caf\xe9\",\"route_method\":\"GET\"}"), + 0); + cbm_pipeline_create_route_nodes(gb); + const cbm_gbuf_node_t *doc_route = cbm_gbuf_find_by_qn(gb, "__route__GET__/doc"); + const cbm_gbuf_node_t *dir_route = cbm_gbuf_find_by_qn(gb, "__route__GET__/caf\xe9"); + bool both = doc_route && dir_route && strcmp(dir_route->name, "/caf\xe9") == 0 && + cbm_gbuf_edge_count_by_type(gb, "HANDLES") == 2; + cbm_gbuf_free(gb); + ASSERT_TRUE(both); + PASS(); +} + /* FD16: a route's DATA_FLOWS were built from the first 64 callers and the first 32 infra handlers * in edge-insertion order (parallel worker interleaving), so the flow set changed between two * indexes of the same tree and every caller past the 64th lost its flow. */ @@ -557,6 +817,12 @@ SUITE(infrascan) { RUN_TEST(infrascan_service_pattern_match_keeps_real_library_qns); RUN_TEST(infrascan_route_nodes_skip_bad_http_url_paths); RUN_TEST(infrascan_http_calls_join_matching_handler_route); + RUN_TEST(infrascan_decorator_route_metadata_keeps_its_escapes); + RUN_TEST(infrascan_decorator_route_metadata_rejects_malformed_values); + RUN_TEST(infrascan_decorator_route_metadata_survives_invalid_utf8); + RUN_TEST(infrascan_handles_properties_hold_a_long_handler_qn); + RUN_TEST(infrascan_route_properties_escape_the_method); + RUN_TEST(infrascan_sveltekit_handles_properties_escape_the_handler_qn); RUN_TEST(infrascan_data_flows_link_every_caller_of_a_route); RUN_TEST(infrascan_data_flows_do_not_depend_on_caller_insertion_order); RUN_TEST(infrascan_data_flows_reach_every_infra_handler);