Merge pull request #2738 from petterip/fix/route-properties-json

fix(pipeline): emit and reconstruct route properties as valid JSON
This commit is contained in:
Martin Vogel authored and GitHub committed 2026-10-25 20:30:24 +01:00
commit 31ba586de4
2 files changed
+318 -40

No files matched your search

+52 -40
View File
@@ -408,61 +408,58 @@ 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;
}
if (path[0] != '/') {
return 0;
}
char method[CBM_SZ_16] = "ANY";
extract_json_prop(func->properties_json, "route_method", method, sizeof(method));
bool routed = extract_json_prop(root, "route_path", path, sizeof(path)) && path[0] == '/';
if (routed) {
extract_json_prop(root, "route_method", method, sizeof(method));
}
yyjson_doc_free(doc);
if (!routed) {
return 0;
}
char route_qn[CBM_ROUTE_QN_SIZE];
char cpath[CBM_SZ_256];
@@ -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++;
}
+266
View File
@@ -2,10 +2,12 @@
#include "graph_buffer/graph_buffer.h"
#include "pipeline/pipeline_internal.h"
#include "service_patterns.h"
#include "yyjson/yyjson.h"
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
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);