diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index ec7e92f3f..d00e0cccf 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -730,11 +730,32 @@ static bool is_infra_file(const char *fp) { strstr(fp, ".tf") != NULL || strstr(fp, ".hcl") != NULL || strstr(fp, ".toml") != NULL); } +/* True when an infra key path denotes an upstream dependency, config value, or + * healthcheck target rather than an endpoint this service exposes. Exposed + * endpoint keys such as push_endpoint, callback, and webhook are intentionally + * absent so they can still produce infra Route nodes. */ +static bool is_upstream_config_key(const char *key_path) { + if (!key_path) { + return false; + } + static const char *const deny[] = {"jwks", "registry", "registries", "healthcheck", + "upstream", "_service_url", "auth", NULL}; + for (int i = 0; deny[i]; i++) { + if (strstr(key_path, deny[i]) != NULL) { + return true; + } + } + return false; +} + /* Try to create an infra Route node from one string_ref. */ static void try_upsert_infra_route(cbm_gbuf_t *gbuf, const CBMStringRef *sr, const char *fp) { if (sr->kind != CBM_STRREF_URL || !sr->value || !strstr(sr->value, "://")) { return; } + if (is_upstream_config_key(sr->key_path)) { + return; + } char route_qn[CBM_ROUTE_QN_SIZE]; snprintf(route_qn, sizeof(route_qn), "__route__infra__%s", sr->value); char route_props[CBM_SZ_512]; @@ -747,17 +768,48 @@ static void try_upsert_infra_route(cbm_gbuf_t *gbuf, const CBMStringRef *sr, con cbm_gbuf_upsert_node(gbuf, "Route", sr->value, route_qn, fp, 0, 0, route_props); } +/* The graph node is keyed by URL value, while extraction may emit several refs + * for the same value at different key-path granularities. If any ref says the + * value is upstream/config/healthcheck-only, suppress that URL globally. */ +static bool route_sr_denied(const CBMStringRef *sr) { + if (!sr || !sr->value || strpbrk(sr->value, " \t\r\n") != NULL) { + return true; + } + if (!sr->key_path) { + return true; + } + return is_upstream_config_key(sr->key_path); +} + static void cbm_pipeline_extract_infra_routes(cbm_gbuf_t *gbuf, const cbm_file_info_t *files, CBMFileResult **result_cache, int file_count) { - for (int i = 0; i < file_count; i++) { - if (!result_cache[i] || !is_infra_file(files[i].rel_path)) { - continue; - } - for (int si = 0; si < result_cache[i]->string_refs.count; si++) { - try_upsert_infra_route(gbuf, &result_cache[i]->string_refs.items[si], - files[i].rel_path); + enum { CBM_INFRA_ROUTE_DENY_PASS_COUNT = 2 }; + CBMHashTable *denied = cbm_ht_create(CBM_SZ_16); + if (!denied) { + cbm_log_warn("pass.infra_routes", "reason", "deny_set_alloc_failed"); + return; + } + for (int pass = 0; pass < CBM_INFRA_ROUTE_DENY_PASS_COUNT; pass++) { + for (int i = 0; i < file_count; i++) { + if (!result_cache[i] || !is_infra_file(files[i].rel_path)) { + continue; + } + for (int si = 0; si < result_cache[i]->string_refs.count; si++) { + const CBMStringRef *sr = &result_cache[i]->string_refs.items[si]; + if (sr->kind != CBM_STRREF_URL || !sr->value || !strstr(sr->value, "://")) { + continue; + } + if (pass == 0) { + if (route_sr_denied(sr)) { + cbm_ht_set(denied, sr->value, intptr_to_ptr(1)); + } + } else if (!cbm_ht_has(denied, sr->value)) { + try_upsert_infra_route(gbuf, sr, files[i].rel_path); + } + } } } + cbm_ht_free(denied); } /* Run decorator_tags, configlink, and route matching passes. */ diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index f2a5b770b..92f19cf05 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -808,6 +808,61 @@ TEST(pipeline_persisted_route_purity_for_http_literals) { PASS(); } +TEST(pipeline_infra_route_deny_wins_by_url_value) { + if (setup_test_repo() != 0) { + FAIL("failed to create temp dir"); + } + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/infra.yaml", g_tmpdir); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(path)); + FILE *f = fopen(path, "w"); + if (!f) { + teardown_test_repo(); + FAIL("failed to write infra.yaml"); + } + fprintf(f, "registries:\n" + " terraform_registry:\n" + " url: https://registry.terraform.io\n" + "healthcheck: curl --fail http://localhost:8080/health || exit 1\n" + "push_endpoint: https://hooks.example.test/push\n"); + fclose(f); + + char db_path[CBM_PATH_MAX]; + n = snprintf(db_path, sizeof(db_path), "%s/test_infra_route_deny.db", g_tmpdir); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(db_path)); + cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + const char *project = cbm_pipeline_project_name(p); + + cbm_node_t *routes = NULL; + int route_count = 0; + ASSERT_EQ(cbm_store_find_nodes_by_label(s, project, "Route", &routes, &route_count), + CBM_STORE_OK); + bool saw_push_endpoint = false; + for (int i = 0; i < route_count; i++) { + const char *name = routes[i].name ? routes[i].name : ""; + ASSERT_FALSE(strcmp(name, "https://registry.terraform.io") == 0); + ASSERT_FALSE(strcmp(name, "http://localhost:8080/health") == 0); + ASSERT_FALSE(strstr(name, "curl --fail http://localhost:8080/health") != NULL); + if (strcmp(name, "https://hooks.example.test/push") == 0) { + saw_push_endpoint = true; + } + } + ASSERT_TRUE(saw_push_endpoint); + + cbm_store_free_nodes(routes, route_count); + cbm_store_close(s); + cbm_pipeline_free(p); + teardown_test_repo(); + PASS(); +} + /* ── Calls pass tests ──────────────────────────────────────────── */ TEST(pipeline_calls_resolution) { @@ -10653,6 +10708,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_def_props_valid_json_when_oversized); RUN_TEST(pipeline_edge_props_valid_json); RUN_TEST(pipeline_persisted_route_purity_for_http_literals); + RUN_TEST(pipeline_infra_route_deny_wins_by_url_value); /* Complexity propagation pass (Tier B) */ RUN_TEST(pipeline_complexity_transitive_loop_depth); RUN_TEST(pipeline_complexity_scc_tld_is_deterministic);