From 1d5fd65602830e921e33a9262722b9a715cefd31 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 3 Jul 2026 19:53:37 +0200 Subject: [PATCH 1/3] fix(cross-repo): normalize URLs, match templates, run both directions Distills the pass_cross_repo.c half of #536; the pass_calls.c half landed separately as beaa776 (broader: covers the parallel path too). match_http_routes could not match a stored HTTP_CALLS url_path that was a full URL (scheme://host:port/path is stored raw from the call's first string argument), never matched a concrete client path against a templated route QN, and only ran consumer->provider, so a provider-side run found nothing - and worse, destroyed previously recorded links: delete_cross_edges wiped the provider's reverse edges and nothing recreated them. - cr_url_path(): strip scheme://host[:port] before route-QN construction; a bare base URL maps to the root route path. - cr_path_matches_template() + find_route_handler_fuzzy(): segment-wise fallback so /v2/orders/123 matches /v2/orders/{} (method-gated, only for edges the exact lookup missed; O(calls x routes) per project pair, bounded by CR_MAX_EDGES). - Reverse-direction match_http_routes run so provider-side invocations find the consumer's HTTP_CALLS and re-create the wiped reverse edges. Row dedup needs no insert guard: the edges table upserts on its (source_id, target_id, type) UNIQUE key. Four reproduce-first tests in tests/repro/repro_issue523.c (scheme-strip, template fuzzy match, provider-side run, bidirectional convergence), all red on the previous code, green with the fix. Refs #523 Co-authored-by: RithvikReddy0-0 Signed-off-by: Martin Vogel --- src/pipeline/pass_cross_repo.c | 159 ++++++++++++++- tests/repro/repro_issue523.c | 340 ++++++++++++++++++++++++++++----- 2 files changed, 451 insertions(+), 48 deletions(-) diff --git a/src/pipeline/pass_cross_repo.c b/src/pipeline/pass_cross_repo.c index a3ce18e1f..877f601c5 100644 --- a/src/pipeline/pass_cross_repo.c +++ b/src/pipeline/pass_cross_repo.c @@ -35,6 +35,9 @@ enum { CR_INIT_CAP = 32, CR_COL_3 = 3, CR_COL_4 = 4, + CR_SCHEME_SKIP = 3, /* strlen("://") */ + CR_ROUTE_PREFIX_LEN = 9, /* strlen("__route__") */ + CR_ANY_LEN = 3, /* strlen("ANY") */ }; #define CR_MS_PER_SEC 1000.0 @@ -115,7 +118,10 @@ static void delete_cross_edges(cbm_store_t *store, const char *project) { cbm_store_delete_edges_by_type(store, project, "CROSS_TRPC_CALLS"); } -/* Insert a CROSS_* edge into a store. */ +/* Insert a CROSS_* edge into a store. Idempotent by construction: the edges + * table is UNIQUE(source_id, target_id, type) and cbm_store_insert_edge + * upserts on conflict, so a pair reached from both match directions or on a + * repeat run never duplicates a row. (#523) */ static void insert_cross_edge(cbm_store_t *store, const char *project, int64_t from_id, int64_t to_id, const char *edge_type, const char *props) { cbm_edge_t edge = { @@ -128,6 +134,24 @@ static void insert_cross_edge(cbm_store_t *store, const char *project, int64_t f cbm_store_insert_edge(store, &edge); } +/* Strip "scheme://host[:port]" from a stored HTTP_CALLS url, returning the + * path. url_path property values are stored raw from the call's first string + * argument, so they can be full URLs ("http://svc:8080/v2/x") — and + * cbm_route_canon_path only canonicalizes placeholder syntax, never strips + * authorities. Returns "/" for a URL with no path after the host (a request + * against the bare base URL targets the root route). (#523) */ +static const char *cr_url_path(const char *url) { + if (!url) { + return url; + } + const char *scheme_end = strstr(url, "://"); + if (!scheme_end) { + return url; /* already a bare path */ + } + const char *path_start = strchr(scheme_end + CR_SCHEME_SKIP, '/'); + return path_start ? path_start : "/"; +} + /* Look up a node's name and file_path by id. */ static void lookup_node_info(struct sqlite3 *db, int64_t node_id, char *name_out, size_t name_sz, char *file_out, size_t file_sz) { @@ -208,6 +232,114 @@ static int64_t find_route_handler(cbm_store_t *target_store, const char *route_q return handler_id; } +/* Segment-wise match of a concrete path against a route template path, where a + * "{...}" segment in the template matches any single non-empty concrete + * segment. Both inputs are bare paths (no method prefix, no authority). + * Leading/trailing slashes are insignificant. Returns true on a full match. */ +static bool cr_path_matches_template(const char *concrete, const char *templ) { + const char *c = concrete; + const char *t = templ; + while (*c && *t) { + if (*c == '/') { + c++; + } + if (*t == '/') { + t++; + } + const char *cseg = c; + while (*c && *c != '/') { + c++; + } + const char *tseg = t; + while (*t && *t != '/') { + t++; + } + size_t clen = (size_t)(c - cseg); + size_t tlen = (size_t)(t - tseg); + bool t_is_param = (tlen >= PAIR_LEN && tseg[0] == '{' && tseg[tlen - 1] == '}'); + if (!t_is_param) { + if (clen != tlen || strncmp(cseg, tseg, clen) != 0) { + return false; + } + } else if (clen == 0) { + return false; /* a parameter never matches an empty segment */ + } + } + while (*c == '/') { + c++; + } + while (*t == '/') { + t++; + } + return *c == '\0' && *t == '\0'; +} + +/* Fallback for when the exact route-QN lookup misses: a concrete client path + * ("/v2/orders/123") never exact-matches a templated route QN + * ("__route__GET__/v2/orders/{}"). Enumerate the target store's Route nodes + * and segment-match the concrete path against each template. On a match, copy + * the route QN into out_qn and return the handler id; returns 0 on no match. + * + * COST: this scans every Route node of the target project once per unmatched + * HTTP_CALLS edge — O(calls × routes) per project pair. Acceptable while both + * factors stay small (calls are capped at CR_MAX_EDGES and it only runs for + * edges the exact lookup missed); revisit with a prepared template index if + * cross-repo matching ever shows up in profiles. (#523) */ +static int64_t find_route_handler_fuzzy(cbm_store_t *target_store, const char *concrete_path, + const char *method, char *out_qn, size_t out_qn_sz, + char *handler_name, size_t name_sz, char *handler_file, + size_t file_sz) { + struct sqlite3 *db = cbm_store_get_db(target_store); + if (!db || !concrete_path || !concrete_path[0]) { + return 0; + } + sqlite3_stmt *s = NULL; + if (sqlite3_prepare_v2(db, "SELECT qualified_name FROM nodes WHERE label = 'Route'", + CBM_NOT_FOUND, &s, NULL) != SQLITE_OK) { + return 0; + } + int64_t found = 0; + while (sqlite3_step(s) == SQLITE_ROW) { + const char *qn = (const char *)sqlite3_column_text(s, 0); + if (!qn || strncmp(qn, "__route__", CR_ROUTE_PREFIX_LEN) != 0) { + continue; + } + /* Split "__route____" */ + const char *rest = qn + CR_ROUTE_PREFIX_LEN; + const char *sep = strstr(rest, "__"); + if (!sep) { + continue; + } + size_t mlen = (size_t)(sep - rest); + const char *rpath = sep + PAIR_LEN; + /* Method gate: the route's method must equal the caller's, or be ANY. + * A missing caller method matches any route method. */ + if (method && method[0]) { + bool same_method = (strncmp(rest, method, mlen) == 0 && method[mlen] == '\0'); + bool route_any = (mlen == CR_ANY_LEN && strncmp(rest, "ANY", CR_ANY_LEN) == 0); + if (!same_method && !route_any) { + continue; + } + } + if (!cr_path_matches_template(concrete_path, rpath)) { + continue; + } + /* A concrete path can match more than one stored template (e.g. a raw + * "{id}" variant and its canonical "{}" form). Only accept a Route that + * actually has a HANDLES edge — the handler is attached to the + * canonical node. Keep scanning otherwise. */ + int64_t hid = + find_route_handler(target_store, qn, handler_name, name_sz, handler_file, file_sz); + if (hid != 0) { + snprintf(out_qn, out_qn_sz, "%s", qn); + found = hid; + break; + } + } + sqlite3_finalize(s); + return found; +} + /* Emit CROSS_* edge for a route match: forward into source, reverse into target. */ static void emit_cross_route_bidirectional(cbm_store_t *src_store, const char *src_project, struct sqlite3 *src_db, int64_t caller_id, @@ -284,12 +416,12 @@ static int match_http_routes(cbm_store_t *src_store, const char *src_project, continue; } - /* Build the expected Route QN in the target project (param-canonicalized - * so client url_path matches the server handler regardless of framework - * placeholder syntax). */ + /* 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]; char cpath[CBM_SZ_256]; - const char *curl = cbm_route_canon_path(url_path, cpath, sizeof(cpath)); + 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}; @@ -303,6 +435,14 @@ static int match_http_routes(cbm_store_t *src_store, const char *src_project, handler_id = find_route_handler(tgt_store, route_qn, handler_name, sizeof(handler_name), handler_file, sizeof(handler_file)); } + if (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)); + } if (handler_id == 0) { continue; } @@ -655,6 +795,15 @@ cbm_cross_repo_result_t cbm_cross_repo_match(const char *project, const char **t } result.http_edges += match_http_routes(src_store, project, tgt_store, tgt); + /* Reverse direction: when this pass runs from the provider side, the + * consumer's HTTP_CALLS live in tgt, not src — the forward pass above + * finds nothing because the provider has no outbound calls. This also + * re-creates the provider-side reverse edges that delete_cross_edges + * just wiped, which a provider-side run previously destroyed for good. + * A caller edge lives in exactly one DB, so the two directions scan + * disjoint edge sets and never double-count a pair; the store's + * (source, target, type) upsert keeps re-recorded rows unique. (#523) */ + result.http_edges += match_http_routes(tgt_store, tgt, src_store, project); result.async_edges += match_async_routes(src_store, project, tgt_store, tgt); result.channel_edges += match_channels(src_store, project, tgt_store, tgt); result.grpc_edges += match_typed_routes(src_store, project, tgt_store, tgt, "GRPC_CALLS", diff --git a/tests/repro/repro_issue523.c b/tests/repro/repro_issue523.c index 9ea60fb40..82d45b38d 100644 --- a/tests/repro/repro_issue523.c +++ b/tests/repro/repro_issue523.c @@ -76,23 +76,21 @@ * the call is dropped, and no HTTP_CALLS edge is created. */ static const RFile client_files[] = { - { - "client/orders.py", - "import requests\n" - "\n" - "\n" - "BASE_URL = \"http://order-service:8080\"\n" - "\n" - "\n" - "def fetch_order(order_id):\n" - " \"\"\"Fetch a single order from the order service.\"\"\"\n" - " return requests.get(\"/api/orders/{id}\", params={\"id\": order_id})\n" - "\n" - "\n" - "def list_orders():\n" - " \"\"\"Fetch all orders from the order service.\"\"\"\n" - " return requests.get(\"/api/orders\")\n" - }, + {"client/orders.py", + "import requests\n" + "\n" + "\n" + "BASE_URL = \"http://order-service:8080\"\n" + "\n" + "\n" + "def fetch_order(order_id):\n" + " \"\"\"Fetch a single order from the order service.\"\"\"\n" + " return requests.get(\"/api/orders/{id}\", params={\"id\": order_id})\n" + "\n" + "\n" + "def list_orders():\n" + " \"\"\"Fetch all orders from the order service.\"\"\"\n" + " return requests.get(\"/api/orders\")\n"}, }; enum { N_CLIENT_FILES = (int)(sizeof(client_files) / sizeof(client_files[0])) }; @@ -105,24 +103,21 @@ enum { N_CLIENT_FILES = (int)(sizeof(client_files) / sizeof(client_files[0])) }; * via cbm_route_canon_path). A HANDLES edge links the Route to `get_order`. */ static const RFile server_files[] = { - { - "server/app.py", - "from flask import Flask, jsonify\n" - "\n" - "app = Flask(__name__)\n" - "\n" - "\n" - "@app.get(\"/api/orders/{id}\")\n" - "def get_order(order_id):\n" - " \"\"\"Return a single order by id.\"\"\"\n" - " return jsonify({\"id\": order_id, \"status\": \"ok\"})\n" - "\n" - "\n" - "@app.get(\"/api/orders\")\n" - "def list_orders():\n" - " \"\"\"Return all orders.\"\"\"\n" - " return jsonify({\"orders\": []})\n" - }, + {"server/app.py", "from flask import Flask, jsonify\n" + "\n" + "app = Flask(__name__)\n" + "\n" + "\n" + "@app.get(\"/api/orders/{id}\")\n" + "def get_order(order_id):\n" + " \"\"\"Return a single order by id.\"\"\"\n" + " return jsonify({\"id\": order_id, \"status\": \"ok\"})\n" + "\n" + "\n" + "@app.get(\"/api/orders\")\n" + "def list_orders():\n" + " \"\"\"Return all orders.\"\"\"\n" + " return jsonify({\"orders\": []})\n"}, }; enum { N_SERVER_FILES = (int)(sizeof(server_files) / sizeof(server_files[0])) }; @@ -147,8 +142,7 @@ enum { N_SERVER_FILES = (int)(sizeof(server_files) / sizeof(server_files[0])) }; TEST(repro_issue523_crossrepo_http_calls_edge) { /* ── Index client service ─────────────────────────────────── */ RProj client; - cbm_store_t *client_store = - rh_index_files(&client, client_files, N_CLIENT_FILES); + cbm_store_t *client_store = rh_index_files(&client, client_files, N_CLIENT_FILES); ASSERT_NOT_NULL(client_store); int client_http = rh_count_edges(client_store, client.project, "HTTP_CALLS"); @@ -162,13 +156,11 @@ TEST(repro_issue523_crossrepo_http_calls_edge) { /* ── Index server service ─────────────────────────────────── */ RProj server; - cbm_store_t *server_store = - rh_index_files(&server, server_files, N_SERVER_FILES); + cbm_store_t *server_store = rh_index_files(&server, server_files, N_SERVER_FILES); ASSERT_NOT_NULL(server_store); int server_routes = rh_count_label(server_store, server.project, "Route"); - fprintf(stderr, - " [523] server Route nodes=%d (expected>=2; 0=extractor broken)\n", + fprintf(stderr, " [523] server Route nodes=%d (expected>=2; 0=extractor broken)\n", server_routes); /* Server-side extraction is correct — if this fails the test environment is * broken, not the cross-repo linker. Fail fast with a clear message. */ @@ -193,8 +185,7 @@ TEST(repro_issue523_crossrepo_http_calls_edge) { * Buggy: http_edges == 0 (no HTTP_CALLS in client → nothing to match). */ const char *server_project = server.project; - cbm_cross_repo_result_t result = - cbm_cross_repo_match(client.project, &server_project, 1); + cbm_cross_repo_result_t result = cbm_cross_repo_match(client.project, &server_project, 1); fprintf(stderr, " [523] cross_repo http_edges=%d " @@ -225,7 +216,270 @@ TEST(repro_issue523_crossrepo_http_calls_edge) { PASS(); } +/* ── Cross-repo matcher gaps (distilled from PR #536, same issue #523) ───── */ +/* + * The pass_calls half of #523 (HTTP_CALLS edges for unindexed client libs) is + * fixed; these cases cover the remaining pass_cross_repo.c matcher gaps: + * + * 1. url_path can be a FULL URL ("http://host:8080/v2/orders") — stored raw + * from the call's first string arg. cbm_route_canon_path never strips + * scheme/authority, so the built route QN embeds the host and the exact + * lookup misses. → cr_url_path() + * 2. A CONCRETE client path ("/v2/orders/123") never exact-matches a + * templated route QN ("__route__GET__/v2/orders/{}"). + * → find_route_handler_fuzzy() / cr_path_matches_template() + * 3. match_http_routes only ran src→tgt: matching initiated from the + * provider side finds nothing (the provider has no outbound HTTP_CALLS). + * → reverse-direction run in cbm_cross_repo_match() + * 4. Worse, a provider-side run DESTROYED existing links: delete_cross_edges + * wiped the provider's reverse edges and — without reverse matching — + * nothing recreated them. → the reverse-direction run restores them; row + * dedup itself needs no guard (the edges table upserts on the + * (source, target, type) UNIQUE key). + */ + +/* Index a one-file client and a one-file server project through the production + * pipeline. Closes both stores again — cbm_cross_repo_match re-opens the DBs + * from the cache dir. Returns false when either project failed to index or the + * PRECONDITIONS (client HTTP_CALLS edge, server Route node) are missing, so a + * broken fixture fails RED instead of vacuously passing. */ +static bool cr536_setup(RProj *client, const char *client_py, RProj *server, + const char *server_py) { + memset(client, 0, sizeof(*client)); + memset(server, 0, sizeof(*server)); + cbm_store_t *cs = rh_index(client, "client/orders.py", client_py); + if (!cs) { + return false; + } + int client_http = rh_count_edges(cs, client->project, "HTTP_CALLS"); + cbm_store_close(cs); + + cbm_store_t *ss = rh_index(server, "server/app.py", server_py); + if (!ss) { + return false; + } + int server_routes = rh_count_label(ss, server->project, "Route"); + cbm_store_close(ss); + + fprintf(stderr, " [523] precondition: client HTTP_CALLS=%d server Routes=%d (both >=1)\n", + client_http, server_routes); + return client_http >= 1 && server_routes >= 1; +} + +/* Count CROSS_HTTP_CALLS rows in a project DB (re-opened from the cache dir, + * because cbm_cross_repo_match wrote through its own store handles). */ +static int cr536_count_cross(RProj *proj) { + cbm_store_t *st = cbm_store_open_path(proj->dbpath); + if (!st) { + return -1; + } + int n = rh_count_edges(st, proj->project, "CROSS_HTTP_CALLS"); + cbm_store_close(st); + return n; +} + +/* + * TEST: url_path stored as a full URL must match a bare-path route. + * WHY RED on unfixed code: the route QN is built from the raw url_path, so the + * lookup key is "__route__GET__http://order-api.internal:8080/v2/orders" and + * never matches the server's "__route__GET__/v2/orders". + */ +TEST(repro_issue523_scheme_stripped_url_match) { + RProj client, server; + bool ok = cr536_setup(&client, + "import requests\n" + "\n" + "\n" + "def fetch_orders():\n" + " \"\"\"Fetch all orders via the service base URL.\"\"\"\n" + " return requests.get(\"http://order-api.internal:8080/v2/orders\")\n", + &server, + "from flask import Flask, jsonify\n" + "\n" + "app = Flask(__name__)\n" + "\n" + "\n" + "@app.get(\"/v2/orders\")\n" + "def list_orders():\n" + " \"\"\"Return all orders.\"\"\"\n" + " return jsonify({\"orders\": []})\n"); + if (!ok) { + rh_cleanup(&client, NULL); + rh_cleanup(&server, NULL); + FAIL("fixture precondition failed (client HTTP_CALLS / server Route missing)"); + } + + const char *tgt = server.project; + cbm_cross_repo_result_t r = cbm_cross_repo_match(client.project, &tgt, 1); + fprintf(stderr, " [523] full-URL url_path: http_edges=%d (expected>=1)\n", r.http_edges); + + rh_cleanup(&client, NULL); + rh_cleanup(&server, NULL); + ASSERT_GTE(r.http_edges, 1); + PASS(); +} + +/* + * TEST: a concrete client path must fuzzy-match a templated server route. + * WHY RED on unfixed code: "__route__GET__/v2/orders/123" exact-lookup misses + * "__route__GET__/v2/orders/{}" and there is no template fallback. + */ +TEST(repro_issue523_template_fuzzy_match) { + RProj client, server; + bool ok = cr536_setup(&client, + "import requests\n" + "\n" + "\n" + "def fetch_order():\n" + " \"\"\"Fetch one concrete order.\"\"\"\n" + " return requests.get(\"/v2/orders/123\")\n", + &server, + "from flask import Flask, jsonify\n" + "\n" + "app = Flask(__name__)\n" + "\n" + "\n" + "@app.get(\"/v2/orders/{order_id}\")\n" + "def get_order(order_id):\n" + " \"\"\"Return one order by id.\"\"\"\n" + " return jsonify({\"id\": order_id})\n"); + if (!ok) { + rh_cleanup(&client, NULL); + rh_cleanup(&server, NULL); + FAIL("fixture precondition failed (client HTTP_CALLS / server Route missing)"); + } + + const char *tgt = server.project; + cbm_cross_repo_result_t r = cbm_cross_repo_match(client.project, &tgt, 1); + fprintf(stderr, " [523] concrete-vs-template: http_edges=%d (expected>=1)\n", r.http_edges); + + rh_cleanup(&client, NULL); + rh_cleanup(&server, NULL); + ASSERT_GTE(r.http_edges, 1); + PASS(); +} + +/* + * TEST: matching initiated from the PROVIDER side must still find the link. + * WHY RED on unfixed code: match_http_routes only scans the source project's + * HTTP_CALLS — the server has none, so the provider-side run reports 0. + */ +TEST(repro_issue523_reverse_direction_match) { + RProj client, server; + bool ok = cr536_setup(&client, + "import requests\n" + "\n" + "\n" + "def fetch_status():\n" + " \"\"\"Poll the status endpoint.\"\"\"\n" + " return requests.get(\"/v2/status\")\n", + &server, + "from flask import Flask, jsonify\n" + "\n" + "app = Flask(__name__)\n" + "\n" + "\n" + "@app.get(\"/v2/status\")\n" + "def get_status():\n" + " \"\"\"Return service status.\"\"\"\n" + " return jsonify({\"status\": \"ok\"})\n"); + if (!ok) { + rh_cleanup(&client, NULL); + rh_cleanup(&server, NULL); + FAIL("fixture precondition failed (client HTTP_CALLS / server Route missing)"); + } + + /* Provider indexed the match run: server is SRC, client is TGT. */ + const char *tgt = client.project; + cbm_cross_repo_result_t r = cbm_cross_repo_match(server.project, &tgt, 1); + int client_cross = cr536_count_cross(&client); + fprintf(stderr, " [523] provider-side run: http_edges=%d client CROSS_HTTP_CALLS=%d\n", + r.http_edges, client_cross); + + rh_cleanup(&client, NULL); + rh_cleanup(&server, NULL); + ASSERT_GTE(r.http_edges, 1); + ASSERT_GTE(client_cross, 1); + PASS(); +} + +/* + * TEST: the matcher must CONVERGE — running it from either side, repeatedly, + * leaves exactly one CROSS_HTTP_CALLS row in each DB (no losses, no dupes). + * WHY RED on unfixed code: run 2 (provider side) starts with + * delete_cross_edges(server), wiping the reverse edge run 1 wrote into the + * server DB — and, lacking reverse-direction matching, recreates nothing: a + * provider-side run permanently DESTROYS the recorded link (server count 0). + * Duplicate rows are impossible either way (the edges table upserts on the + * (source, target, type) UNIQUE key); the run-3 assertions pin that too. + */ +TEST(repro_issue523_idempotent_cross_edges) { + RProj client, server; + bool ok = cr536_setup(&client, + "import requests\n" + "\n" + "\n" + "def fetch_health():\n" + " \"\"\"Poll the health endpoint.\"\"\"\n" + " return requests.get(\"/v2/health\")\n", + &server, + "from flask import Flask, jsonify\n" + "\n" + "app = Flask(__name__)\n" + "\n" + "\n" + "@app.get(\"/v2/health\")\n" + "def get_health():\n" + " \"\"\"Return health.\"\"\"\n" + " return jsonify({\"health\": \"ok\"})\n"); + if (!ok) { + rh_cleanup(&client, NULL); + rh_cleanup(&server, NULL); + FAIL("fixture precondition failed (client HTTP_CALLS / server Route missing)"); + } + + /* Run 1: consumer side — creates fwd row (client DB) + rev row (server DB). */ + const char *tgt_srv = server.project; + cbm_cross_repo_result_t r1 = cbm_cross_repo_match(client.project, &tgt_srv, 1); + int client_1 = cr536_count_cross(&client); + int server_1 = cr536_count_cross(&server); + + /* Run 2: provider side — must re-find the same pair, not destroy it. */ + const char *tgt_cli = client.project; + cbm_cross_repo_result_t r2 = cbm_cross_repo_match(server.project, &tgt_cli, 1); + int client_2 = cr536_count_cross(&client); + int server_2 = cr536_count_cross(&server); + + /* Run 3: consumer side again — convergence, still exactly one row each. */ + cbm_cross_repo_result_t r3 = cbm_cross_repo_match(client.project, &tgt_srv, 1); + int client_3 = cr536_count_cross(&client); + int server_3 = cr536_count_cross(&server); + + fprintf(stderr, + " [523] convergence: run1 http=%d (client=%d server=%d) " + "run2 http=%d (client=%d server=%d) run3 http=%d (client=%d server=%d) " + "— every count must be 1\n", + r1.http_edges, client_1, server_1, r2.http_edges, client_2, server_2, r3.http_edges, + client_3, server_3); + + rh_cleanup(&client, NULL); + rh_cleanup(&server, NULL); + + ASSERT_EQ(client_1, 1); + ASSERT_EQ(server_1, 1); + /* The regression: on unfixed code the provider-side run leaves server_2==0. */ + ASSERT_EQ(client_2, 1); + ASSERT_EQ(server_2, 1); + ASSERT_EQ(client_3, 1); + ASSERT_EQ(server_3, 1); + PASS(); +} + /* ── Suite ───────────────────────────────────────────────────────────────── */ SUITE(repro_issue523) { RUN_TEST(repro_issue523_crossrepo_http_calls_edge); + RUN_TEST(repro_issue523_scheme_stripped_url_match); + RUN_TEST(repro_issue523_template_fuzzy_match); + RUN_TEST(repro_issue523_reverse_direction_match); + RUN_TEST(repro_issue523_idempotent_cross_edges); } From 78dab4cc18bc75f8ed39f3f107cc9af7d469c9b6 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 3 Jul 2026 21:07:27 +0200 Subject: [PATCH 2/3] docs: use scanner-neutral URL example in cross-repo comment The hardcoded-URL security audit flags http:// literals in src/; the comment only illustrates the shape of stored url_path values. Signed-off-by: Martin Vogel --- src/pipeline/pass_cross_repo.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pipeline/pass_cross_repo.c b/src/pipeline/pass_cross_repo.c index 877f601c5..d233c422b 100644 --- a/src/pipeline/pass_cross_repo.c +++ b/src/pipeline/pass_cross_repo.c @@ -136,7 +136,7 @@ static void insert_cross_edge(cbm_store_t *store, const char *project, int64_t f /* Strip "scheme://host[:port]" from a stored HTTP_CALLS url, returning the * path. url_path property values are stored raw from the call's first string - * argument, so they can be full URLs ("http://svc:8080/v2/x") — and + * argument, so they can be full URLs ("scheme://host:port/v2/x") — and * cbm_route_canon_path only canonicalizes placeholder syntax, never strips * authorities. Returns "/" for a URL with no path after the host (a request * against the bare base URL targets the root route). (#523) */ From c5aef83cc42a6726b72ff75e0a0f9a908c40694d Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 4 Jul 2026 01:08:09 +0200 Subject: [PATCH 3/3] ci: re-trigger CodeQL gate (gate lookup fixed on main via #820) Signed-off-by: Martin Vogel