From 3faecc78144175a81acf76721c4f695cf80b5676 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 25 Sep 2026 18:49:37 +0200 Subject: [PATCH] fix(cross-repo): skip CROSS_HTTP_CALLS for calls the caller serves itself (#1459) match_http_routes linked every outbound HTTP_CALLS to any other indexed project exposing the same method/path, without first asking whether the caller's own project serves that route. A repo whose own frontend calls its own API (the common case) got a cross-service edge to every other project that happens to expose the same path, e.g. /health or /api/users. Add a caller-local floor: before looking at the target, the pass checks whether the call is served in the caller's own project, and skips it if so. "Served" is a HANDLES edge on the Route node the HTTP_CALLS points at, or the same exact-method / ANY / template resolution already used for the target, applied to the caller's own store. The second check is needed on real input: a method-less fetch('/api/users') points at the __route__ANY__ node, while the local app.get handler sits on the __route__GET__ node. The target lookup is factored into resolve_route_handler() so both sides use one resolver. Since match_http_routes also runs in the provider-side (reverse) direction, the floor applies there too. Calls with no local handler still cross as before. Proof (dev binary, three small Express fixtures, api/mono both serving GET /api/users, mono and client both fetching it): mono -> [api] cross_http_calls 1 -> 0 client -> [api] cross_http_calls 1 -> 1 (control) api -> [mono, client] cross_http_calls 2 -> 1 Refs #1459 (the per-edge evidence/ambiguity shape and the opt-in gateway binding discussed there remain open). Signed-off-by: Martin Vogel --- src/pipeline/pass_cross_repo.c | 118 ++++++++++++++++------ tests/test_cross_repo.c | 172 +++++++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+), 27 deletions(-) diff --git a/src/pipeline/pass_cross_repo.c b/src/pipeline/pass_cross_repo.c index 9ce0e50fb..a4f12425f 100644 --- a/src/pipeline/pass_cross_repo.c +++ b/src/pipeline/pass_cross_repo.c @@ -592,6 +592,80 @@ static bool emit_cross_route_bidirectional( return insert_cross_edge(tgt_store, tgt_project, handler_id, tgt_route_id, edge_type, rev, ctx); } +/* A resolved route: the matched Route QN plus its handler's name and file. */ +typedef struct { + char qn[CR_QN_BUF]; + char name[CBM_SZ_256]; + char file[CBM_SZ_512]; +} cr_route_hit_t; + +/* Resolve a client call (method + canonical path) to a handled Route in + * store: exact method QN, then ANY, then segment-wise template matching, + * since a concrete client path ("/v2/orders/123") never exact-matches a + * templated route ("/v2/orders/{}") (#523). Returns the handler id, or 0. */ +static int64_t resolve_route_handler(cbm_store_t *store, const char *method, const char *curl, + cr_route_hit_t *hit, bool *failed, cr_run_context_t *ctx) { + snprintf(hit->qn, sizeof(hit->qn), "__route__%s__%s", method[0] ? method : "ANY", curl); + int64_t handler_id = find_route_handler(store, hit->qn, hit->name, sizeof(hit->name), hit->file, + sizeof(hit->file), failed); + if (!*failed && handler_id == 0) { + snprintf(hit->qn, sizeof(hit->qn), "__route__ANY__%s", curl); + handler_id = find_route_handler(store, hit->qn, hit->name, sizeof(hit->name), hit->file, + sizeof(hit->file), failed); + } + if (!*failed && handler_id == 0) { + handler_id = find_route_handler_fuzzy(store, curl, method[0] ? method : NULL, hit->qn, + sizeof(hit->qn), hit->name, sizeof(hit->name), + hit->file, sizeof(hit->file), failed, ctx); + } + return *failed ? 0 : handler_id; +} + +/* True when the caller's own project has a HANDLES edge into the local Route + * node its HTTP_CALLS points at. Sets *failed on a query error. (#1459) */ +static bool route_node_handled(struct sqlite3 *src_db, const char *src_project, int64_t route_id, + bool *failed) { + *failed = false; + sqlite3_stmt *s = NULL; + if (sqlite3_prepare_v2(src_db, + "SELECT 1 FROM edges WHERE project = ?1 AND target_id = ?2 " + "AND type = 'HANDLES' LIMIT 1", + CBM_NOT_FOUND, &s, NULL) != SQLITE_OK) { + *failed = true; + return false; + } + if (sqlite3_bind_text(s, SKIP_ONE, src_project, CBM_NOT_FOUND, SQLITE_STATIC) != SQLITE_OK || + sqlite3_bind_int64(s, PAIR_LEN, route_id) != SQLITE_OK) { + sqlite3_finalize(s); + *failed = true; + return false; + } + int step_rc = sqlite3_step(s); + if (step_rc != SQLITE_ROW && step_rc != SQLITE_DONE) { + *failed = true; + } + if (sqlite3_finalize(s) != SQLITE_OK) { + *failed = true; + } + return !*failed && step_rc == SQLITE_ROW; +} + +/* Caller-local floor (#1459): a call the caller's own project serves is an + * in-project call — another project exposing the same path is no evidence of + * a cross-service call. "Served" is the HANDLES edge on the Route node the + * HTTP_CALLS points at, or the same method/ANY/template resolution the target + * lookup uses, applied to the caller's own store: a method-less fetch() points + * at the ANY node while the local handler sits on the GET node. */ +static bool call_served_locally(cbm_store_t *src_store, struct sqlite3 *src_db, + const char *src_project, int64_t route_id, const char *method, + const char *curl, bool *failed, cr_run_context_t *ctx) { + if (route_node_handled(src_db, src_project, route_id, failed) || *failed) { + return !*failed; + } + cr_route_hit_t local; + return resolve_route_handler(src_store, method, curl, &local, failed, ctx) != 0; +} + static cr_match_result_t match_http_routes(cbm_store_t *src_store, const char *src_project, cbm_store_t *tgt_store, const char *tgt_project, cr_run_context_t *ctx) { @@ -632,35 +706,26 @@ static cr_match_result_t match_http_routes(cbm_store_t *src_store, const char *s continue; } - /* Build the expected Route QN in the target project (authority-stripped - * and param-canonicalized so client url_path matches the server handler - * regardless of base URL and framework placeholder syntax). */ - char route_qn[CR_QN_BUF]; + /* Canonical client path: authority-stripped and param-canonicalized so + * it matches a server handler regardless of base URL and framework + * placeholder syntax. */ char cpath[CBM_SZ_256]; const char *curl = cbm_route_canon_path(cr_url_path(url_path), cpath, sizeof(cpath)); - snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", method[0] ? method : "ANY", curl); - char handler_name[CBM_SZ_256] = {0}; - char handler_file[CBM_SZ_512] = {0}; bool query_failed = false; - int64_t handler_id = - find_route_handler(tgt_store, route_qn, handler_name, sizeof(handler_name), - handler_file, sizeof(handler_file), &query_failed); - if (!query_failed && handler_id == 0) { - /* Try without method (ANY) */ - snprintf(route_qn, sizeof(route_qn), "__route__ANY__%s", curl); - handler_id = find_route_handler(tgt_store, route_qn, handler_name, sizeof(handler_name), - handler_file, sizeof(handler_file), &query_failed); - } - if (!query_failed && handler_id == 0) { - /* Exact QN lookup missed. A concrete client path ("/v2/orders/123") - * never exact-matches a templated route ("/v2/orders/{}"), so fall - * back to segment-wise template matching. (#523) */ - handler_id = - find_route_handler_fuzzy(tgt_store, curl, method[0] ? method : NULL, route_qn, - sizeof(route_qn), handler_name, sizeof(handler_name), - handler_file, sizeof(handler_file), &query_failed, ctx); + bool served_locally = call_served_locally(src_store, src_db, src_project, route_id, method, + curl, &query_failed, ctx); + if (query_failed) { + failed = true; + break; } + if (served_locally) { + continue; + } + + cr_route_hit_t hit; + int64_t handler_id = + resolve_route_handler(tgt_store, method, curl, &hit, &query_failed, ctx); if (query_failed) { failed = true; break; @@ -674,9 +739,8 @@ static cr_match_result_t match_http_routes(cbm_store_t *src_store, const char *s } if (!emit_cross_route_bidirectional(src_store, src_project, src_db, caller_id, route_id, - tgt_store, tgt_project, handler_id, route_qn, - handler_name, handler_file, url_path, method, - "CROSS_HTTP_CALLS", ctx)) { + tgt_store, tgt_project, handler_id, hit.qn, hit.name, + hit.file, url_path, method, "CROSS_HTTP_CALLS", ctx)) { failed = !cr_cancel_requested(ctx); break; } diff --git a/tests/test_cross_repo.c b/tests/test_cross_repo.c index 7b8af9bbd..3dde0ca24 100644 --- a/tests/test_cross_repo.c +++ b/tests/test_cross_repo.c @@ -147,6 +147,84 @@ static bool cross_repo_seed_http_pair(const cross_repo_fixture_t *fixture, return ok; } +/* Give the source project its own handler for the local Route the seeded + * HTTP_CALLS edge points at (caller-local HANDLES), as when a monorepo's + * client code calls the API the same repo serves. (#1459) */ +static bool cross_repo_seed_local_handler(const cross_repo_fixture_t *fixture, + const char *source_project, const char *suffix) { + char source_path[512]; + if (!cross_repo_project_path(fixture, source_project, source_path, sizeof(source_path))) { + return false; + } + cbm_store_t *source = cbm_store_open_path(source_path); + if (!source) { + return false; + } + char local_route_qn[256]; + char handler_qn[256]; + snprintf(local_route_qn, sizeof(local_route_qn), "%s.local-route.%s", source_project, suffix); + snprintf(handler_qn, sizeof(handler_qn), "%s.local-handle.%s", source_project, suffix); + cbm_node_t route = {0}; + bool ok = + cbm_store_find_node_by_qn(source, source_project, local_route_qn, &route) == CBM_STORE_OK; + int64_t route_id = ok ? route.id : 0; + if (ok) { + cbm_node_free_fields(&route); + } + cbm_node_t handler = {.project = source_project, + .label = "Function", + .name = "handle_local", + .qualified_name = handler_qn, + .file_path = "server.c"}; + int64_t handler_id = ok ? cbm_store_upsert_node(source, &handler) : 0; + cbm_edge_t handles = {.project = source_project, + .source_id = handler_id, + .target_id = route_id, + .type = "HANDLES"}; + ok = ok && route_id > 0 && handler_id > 0 && cbm_store_insert_edge(source, &handles) > 0; + cbm_store_close(source); + return ok; +} + +/* Give the source project a canonical "__route__GET__" Route with its + * own handler, distinct from the node the seeded HTTP_CALLS points at — the + * shape extraction produces for a method-less fetch() against a local + * app.get() route. (#1459) */ +static bool cross_repo_seed_local_get_route(const cross_repo_fixture_t *fixture, + const char *source_project, const char *route_path) { + char source_path[512]; + if (!cross_repo_project_path(fixture, source_project, source_path, sizeof(source_path))) { + return false; + } + cbm_store_t *source = cbm_store_open_path(source_path); + if (!source) { + return false; + } + char route_qn[256]; + char handler_qn[256]; + snprintf(route_qn, sizeof(route_qn), "__route__GET__%s", route_path); + snprintf(handler_qn, sizeof(handler_qn), "%s.local-get-handler", source_project); + cbm_node_t route = {.project = source_project, + .label = "Route", + .name = route_path, + .qualified_name = route_qn, + .file_path = "server.c"}; + cbm_node_t handler = {.project = source_project, + .label = "Function", + .name = "handle_local_get", + .qualified_name = handler_qn, + .file_path = "server.c"}; + int64_t route_id = cbm_store_upsert_node(source, &route); + int64_t handler_id = cbm_store_upsert_node(source, &handler); + cbm_edge_t handles = {.project = source_project, + .source_id = handler_id, + .target_id = route_id, + .type = "HANDLES"}; + bool ok = route_id > 0 && handler_id > 0 && cbm_store_insert_edge(source, &handles) > 0; + cbm_store_close(source); + return ok; +} + static bool cross_repo_exec(const cross_repo_fixture_t *fixture, const char *project, const char *sql) { char path[512]; @@ -519,7 +597,101 @@ TEST(cross_repo_accepts_project_with_missed_shadow_row_issue1609) { PASS(); } +/* #1459 floor: a caller whose own project handles the route it calls is + * calling itself — another project exposing the same path is not evidence of + * a cross-service call. Neither direction of the pass may link it. */ +TEST(cross_repo_caller_local_handler_blocks_cross_http_issue1459) { + cross_repo_fixture_t fixture; + bool setup = + cross_repo_fixture_begin(&fixture) && + cross_repo_seed_http_pair(&fixture, "mono-1459", "api-1459", "/api/users", "mono") && + cross_repo_seed_local_handler(&fixture, "mono-1459", "mono"); + if (!setup) { + cross_repo_fixture_end(&fixture); + FAIL("failed to seed caller-local route fixture"); + } + const char *target = "api-1459"; + cbm_cross_repo_result_t fwd = cbm_cross_repo_match("mono-1459", &target, 1); + const char *consumer = "mono-1459"; + cbm_cross_repo_result_t rev = cbm_cross_repo_match("api-1459", &consumer, 1); + int mono_edges = cross_repo_count_edges(&fixture, "mono-1459", "CROSS_HTTP_CALLS"); + int api_edges = cross_repo_count_edges(&fixture, "api-1459", "CROSS_HTTP_CALLS"); + cross_repo_fixture_end(&fixture); + + ASSERT_FALSE(fwd.failed); + ASSERT_FALSE(rev.failed); + ASSERT_EQ(fwd.http_edges, 0); + ASSERT_EQ(rev.http_edges, 0); + ASSERT_EQ(mono_edges, 0); + ASSERT_EQ(api_edges, 0); + PASS(); +} + +/* #1459 floor, real-world shape: a method-less client call points at the ANY + * Route node, while the caller's own handler sits on the GET node for the same + * path. The call is still served in-project and must not cross. */ +TEST(cross_repo_methodless_call_to_local_get_route_blocks_cross_http_issue1459) { + cross_repo_fixture_t fixture; + bool setup = + cross_repo_fixture_begin(&fixture) && + cross_repo_seed_http_pair(&fixture, "mono2-1459", "api3-1459", "/api/users", "mono2") && + cross_repo_exec(&fixture, "mono2-1459", + "UPDATE edges SET properties = '{\"url_path\":\"/api/users\"}' " + "WHERE type = 'HTTP_CALLS';") && + cross_repo_seed_local_get_route(&fixture, "mono2-1459", "/api/users"); + if (!setup) { + cross_repo_fixture_end(&fixture); + FAIL("failed to seed method-less caller-local fixture"); + } + const char *target = "api3-1459"; + cbm_cross_repo_result_t fwd = cbm_cross_repo_match("mono2-1459", &target, 1); + const char *consumer = "mono2-1459"; + cbm_cross_repo_result_t rev = cbm_cross_repo_match("api3-1459", &consumer, 1); + int mono_edges = cross_repo_count_edges(&fixture, "mono2-1459", "CROSS_HTTP_CALLS"); + int api_edges = cross_repo_count_edges(&fixture, "api3-1459", "CROSS_HTTP_CALLS"); + cross_repo_fixture_end(&fixture); + + ASSERT_FALSE(fwd.failed); + ASSERT_FALSE(rev.failed); + ASSERT_EQ(fwd.http_edges, 0); + ASSERT_EQ(rev.http_edges, 0); + ASSERT_EQ(mono_edges, 0); + ASSERT_EQ(api_edges, 0); + PASS(); +} + +/* Control for the #1459 floor: without a caller-local handler the path match + * still links the caller to the remote handler, in both run directions. */ +TEST(cross_repo_caller_without_local_handler_keeps_cross_http_issue1459) { + cross_repo_fixture_t fixture; + bool setup = + cross_repo_fixture_begin(&fixture) && + cross_repo_seed_http_pair(&fixture, "client-1459", "api2-1459", "/api/users", "client"); + if (!setup) { + cross_repo_fixture_end(&fixture); + FAIL("failed to seed remote-only route fixture"); + } + const char *target = "api2-1459"; + cbm_cross_repo_result_t fwd = cbm_cross_repo_match("client-1459", &target, 1); + int client_edges = cross_repo_count_edges(&fixture, "client-1459", "CROSS_HTTP_CALLS"); + int api_edges = cross_repo_count_edges(&fixture, "api2-1459", "CROSS_HTTP_CALLS"); + const char *consumer = "client-1459"; + cbm_cross_repo_result_t rev = cbm_cross_repo_match("api2-1459", &consumer, 1); + cross_repo_fixture_end(&fixture); + + ASSERT_FALSE(fwd.failed); + ASSERT_FALSE(rev.failed); + ASSERT_EQ(fwd.http_edges, 1); + ASSERT_EQ(rev.http_edges, 1); + ASSERT_EQ(client_edges, 1); + ASSERT_EQ(api_edges, 1); + PASS(); +} + SUITE(cross_repo) { + RUN_TEST(cross_repo_caller_local_handler_blocks_cross_http_issue1459); + RUN_TEST(cross_repo_methodless_call_to_local_get_route_blocks_cross_http_issue1459); + RUN_TEST(cross_repo_caller_without_local_handler_keeps_cross_http_issue1459); RUN_TEST(cross_repo_accepts_project_with_missed_shadow_row_issue1609); RUN_TEST(cross_repo_null_target_fails_without_dereference); RUN_TEST(cross_repo_wildcard_keeps_projects_containing_internal_tokens);