Skip to content

Commit e41c7df

Browse files
feat(extract): model Go native channels as gochan Channel topology
Go's own concurrency primitives were invisible: the Go channel extractor only classified gorilla/nhooyr WebSocket send/receive, so a channel-plumbed event pipeline (250 make(chan ...), ~710 send/receive sites on the measured repo) produced zero Channel nodes and trace_path stopped dead at every send site. v1, extraction-only - the existing per-file materializer (create_channel_edges_for_file and its parallel twin) already builds Channel nodes and EMITS/LISTENS_ON edges from CBMChannel records: - send_statement (x <- v) -> EMIT, unary <- -> LISTEN. Both are channel operations BY GRAMMAR, so no type inference is needed for precision - unlike the WebSocket name heuristics. select comm clauses are covered for free (they contain the same node kinds). - Channel identity: the package-qualified tail identifier (module_qn + '.' + field/var name), transport "gochan" - distinct from "websocket", whose classifier is untouched. Same-package cross-file producer/consumer pairs join on one node. - Deliberately deferred (documented in #1930): element types on the node, go statements (CROSS_ASYNC), and for-range receives - range needs the operand's TYPE to know it is a channel, and a name-shape guess would be the #1932 anti-pattern. Also fixes a latent gap this exposed: enclosing_function_qn returned a BARE name, which never matches any def QN, so every channel edge (the WebSocket ones included) silently degraded to the file node through find_channel_source's fallback. It now returns module_qn.name, with the file-node fallback preserved for shapes it cannot express. Reproduce-first (RED with extract_channels.c stashed): extract_go_native_channels (EMIT+LISTEN records, package-qualified names, unary minus not mistaken for a receive) and pipeline_go_native_channel_topology (one gochan Channel node, EMITS from Produce and LISTENS_ON from Drain across files). 643 green across extraction/pipeline/registry. Part of #1930 Signed-off-by: Ilya Brykau <ilya.brykau@orca.security>
1 parent ec08f76 commit e41c7df

3 files changed

Lines changed: 219 additions & 2 deletions

File tree

internal/cbm/extract_channels.c

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,13 @@ static const char *enclosing_function_qn(CBMExtractCtx *ctx, TSNode node) {
196196
if (!ts_node_is_null(name_node)) {
197197
char *name = cbm_node_text(ctx->arena, name_node, ctx->source);
198198
if (name && name[0]) {
199-
return name;
199+
/* #1930: return a resolvable QN — def QNs are
200+
* module-qualified, so a bare name never matched any node
201+
* and every channel edge silently degraded to the file
202+
* node through find_channel_source's fallback. A miss
203+
* (e.g. a class-nested member) still falls back exactly
204+
* as before. */
205+
return cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, name);
200206
}
201207
}
202208
return NULL;
@@ -625,15 +631,78 @@ static void go_process_call(CBMExtractCtx *ctx, TSNode call) {
625631
push_channel(ctx, channel_name, "websocket", direction, call);
626632
}
627633

634+
/* ── #1930: Go native channels ───────────────────────────────────────
635+
* `x <- v` and `<-x` are channel operations by GRAMMAR — no type inference is
636+
* needed for precision, unlike the WebSocket name-heuristics above. The
637+
* channel's v1 identity is its package-qualified tail identifier (the struct
638+
* field or variable the operation touches): `s.out <- ev` in package p and
639+
* `<-w.out` in another file of p join on `p…out`, which is exactly the
640+
* producer/consumer topology trace_path could not cross before. Deliberately
641+
* deferred: element types on the node, `go` statements (CROSS_ASYNC), and
642+
* `for range ch` receives (range needs the operand's TYPE to know it is a
643+
* channel — a name-shape guess here would be the #1932 anti-pattern). */
644+
static const char *go_chan_expr_name(CBMExtractCtx *ctx, TSNode expr) {
645+
const char *kind = ts_node_type(expr);
646+
if (strcmp(kind, "parenthesized_expression") == 0 && ts_node_named_child_count(expr) == 1) {
647+
return go_chan_expr_name(ctx, ts_node_named_child(expr, 0));
648+
}
649+
if (strcmp(kind, "identifier") == 0) {
650+
return cbm_node_text(ctx->arena, expr, ctx->source);
651+
}
652+
if (strcmp(kind, "selector_expression") == 0) {
653+
TSNode field = ts_node_child_by_field_name(expr, "field", 5);
654+
if (!ts_node_is_null(field)) {
655+
return cbm_node_text(ctx->arena, field, ctx->source);
656+
}
657+
}
658+
return NULL;
659+
}
660+
661+
static void go_push_native_channel(CBMExtractCtx *ctx, TSNode site, TSNode chan_expr,
662+
CBMChannelDirection direction) {
663+
const char *tail = go_chan_expr_name(ctx, chan_expr);
664+
if (!tail || !tail[0] || strcmp(tail, "_") == 0) {
665+
return;
666+
}
667+
/* Package-qualify so same-named channels in different packages stay
668+
* distinct while cross-file uses within one package join. */
669+
const char *qualified = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, tail);
670+
push_channel(ctx, qualified, "gochan", direction, site);
671+
}
672+
673+
static void go_process_native_send(CBMExtractCtx *ctx, TSNode node) {
674+
TSNode chan_expr = ts_node_child_by_field_name(node, "channel", 7);
675+
if (!ts_node_is_null(chan_expr)) {
676+
go_push_native_channel(ctx, node, chan_expr, CBM_CHANNEL_EMIT);
677+
}
678+
}
679+
680+
static void go_process_native_receive(CBMExtractCtx *ctx, TSNode node) {
681+
TSNode op = ts_node_child_by_field_name(node, "operator", 8);
682+
if (ts_node_is_null(op) || ts_node_end_byte(op) - ts_node_start_byte(op) != 2 ||
683+
strncmp(ctx->source + ts_node_start_byte(op), "<-", 2) != 0) {
684+
return;
685+
}
686+
TSNode operand = ts_node_child_by_field_name(node, "operand", 7);
687+
if (!ts_node_is_null(operand)) {
688+
go_push_native_channel(ctx, node, operand, CBM_CHANNEL_LISTEN);
689+
}
690+
}
691+
628692
static void extract_channels_go(CBMExtractCtx *ctx) {
629693
TSNodeStack stack;
630694
ts_nstack_init(&stack, ctx->arena, CHAN_STACK_CAP);
631695
ts_nstack_push(&stack, ctx->arena, ctx->root);
632696

633697
while (stack.count > 0) {
634698
TSNode node = ts_nstack_pop(&stack);
635-
if (strcmp(ts_node_type(node), "call_expression") == 0) {
699+
const char *kind = ts_node_type(node);
700+
if (strcmp(kind, "call_expression") == 0) {
636701
go_process_call(ctx, node);
702+
} else if (strcmp(kind, "send_statement") == 0) {
703+
go_process_native_send(ctx, node);
704+
} else if (strcmp(kind, "unary_expression") == 0) {
705+
go_process_native_receive(ctx, node);
637706
}
638707
uint32_t count = ts_node_child_count(node);
639708
for (int i = (int)count - SKIP_ONE; i >= 0; i--) {

tests/test_extraction.c

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3015,6 +3015,52 @@ TEST(go_imports) {
30153015
PASS();
30163016
}
30173017

3018+
TEST(extract_go_native_channels) {
3019+
/* #1930: `x <- v` and `<-x` are channel operations by grammar — record
3020+
* them as gochan Channel emits/listens, package-qualified by tail
3021+
* identifier. Arithmetic unary minus must not be mistaken for a receive. */
3022+
CBMFileResult *r = extract("package pipe\n"
3023+
"\n"
3024+
"type Stage struct {\n"
3025+
"\tout chan int\n"
3026+
"}\n"
3027+
"\n"
3028+
"func (s *Stage) Push(v int) {\n"
3029+
"\ts.out <- v\n"
3030+
"}\n"
3031+
"\n"
3032+
"func (s *Stage) Pull() int {\n"
3033+
"\treturn <-s.out\n"
3034+
"}\n"
3035+
"\n"
3036+
"func Neg(v int) int { return -v }\n",
3037+
CBM_LANG_GO, "t", "pipe.go");
3038+
ASSERT_NOT_NULL(r);
3039+
ASSERT_FALSE(r->has_error);
3040+
int emits = 0;
3041+
int listens = 0;
3042+
for (int i = 0; i < r->channels.count; i++) {
3043+
const CBMChannel *ch = &r->channels.items[i];
3044+
ASSERT_NOT_NULL(ch->transport);
3045+
if (strcmp(ch->transport, "gochan") != 0) {
3046+
continue;
3047+
}
3048+
ASSERT_NOT_NULL(ch->channel_name);
3049+
size_t len = strlen(ch->channel_name);
3050+
ASSERT_TRUE(len > 4 && strcmp(ch->channel_name + len - 4, ".out") == 0);
3051+
ASSERT_NOT_NULL(ch->enclosing_func_qn);
3052+
if (ch->direction == CBM_CHANNEL_EMIT) {
3053+
emits++;
3054+
} else {
3055+
listens++;
3056+
}
3057+
}
3058+
ASSERT_EQ(emits, 1);
3059+
ASSERT_EQ(listens, 1);
3060+
cbm_free_result(r);
3061+
PASS();
3062+
}
3063+
30183064
TEST(java_imports) {
30193065
CBMFileResult *r = extract(
30203066
"import java.util.List;\nimport java.util.ArrayList;\nimport static java.lang.Math.PI;\n"
@@ -6699,6 +6745,7 @@ SUITE(extraction) {
66996745
RUN_TEST(python_imports);
67006746
RUN_TEST(js_imports);
67016747
RUN_TEST(go_imports);
6748+
RUN_TEST(extract_go_native_channels);
67026749
RUN_TEST(java_imports);
67036750
RUN_TEST(rust_imports);
67046751
RUN_TEST(c_imports);

tests/test_pipeline.c

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4688,6 +4688,106 @@ TEST(pipeline_python_receiver_suppresses_weak_method_edge) {
46884688
PASS();
46894689
}
46904690

4691+
/* #1930: does an edge of this type run from the named function to the named
4692+
* Channel node? */
4693+
static bool channel_edge_exists(cbm_store_t *s, const char *project, const char *func_name,
4694+
const char *channel_name, const char *edge_type) {
4695+
cbm_node_t *srcs = NULL;
4696+
cbm_node_t *tgts = NULL;
4697+
int sc = 0;
4698+
int tc = 0;
4699+
cbm_store_find_nodes_by_name(s, project, func_name, &srcs, &sc);
4700+
cbm_store_find_nodes_by_name(s, project, channel_name, &tgts, &tc);
4701+
bool found = false;
4702+
for (int i = 0; i < sc && !found; i++) {
4703+
cbm_edge_t *edges = NULL;
4704+
int ec = 0;
4705+
cbm_store_find_edges_by_source_type(s, srcs[i].id, edge_type, &edges, &ec);
4706+
for (int j = 0; j < ec && !found; j++) {
4707+
for (int k = 0; k < tc; k++) {
4708+
if (edges[j].target_id == tgts[k].id) {
4709+
found = true;
4710+
break;
4711+
}
4712+
}
4713+
}
4714+
if (edges) {
4715+
cbm_store_free_edges(edges, ec);
4716+
}
4717+
}
4718+
if (srcs) {
4719+
cbm_store_free_nodes(srcs, sc);
4720+
}
4721+
if (tgts) {
4722+
cbm_store_free_nodes(tgts, tc);
4723+
}
4724+
return found;
4725+
}
4726+
4727+
TEST(pipeline_go_native_channel_topology) {
4728+
/* #1930: the producer/consumer topology of a Go channel pipeline — a send
4729+
* in one file, a select-receive in another file of the same package —
4730+
* must materialize as one gochan Channel node with EMITS/LISTENS_ON
4731+
* edges, so trace_path can cross the channel. RED on main: zero gochan
4732+
* Channel nodes exist. */
4733+
char tmp[256];
4734+
snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_chan_XXXXXX");
4735+
if (!cbm_mkdtemp(tmp)) {
4736+
FAIL("tmpdir");
4737+
}
4738+
write_temp_file(tmp, "go.mod", "module example.com/fxchan\n\ngo 1.22\n");
4739+
write_temp_file(tmp, "state/state.go",
4740+
"package state\n"
4741+
"\n"
4742+
"var events = make(chan int, 8)\n"
4743+
"\n"
4744+
"func Produce(v int) {\n"
4745+
"\tevents <- v\n"
4746+
"}\n");
4747+
write_temp_file(tmp, "state/drain.go",
4748+
"package state\n"
4749+
"\n"
4750+
"func Drain() int {\n"
4751+
"\tselect {\n"
4752+
"\tcase v := <-events:\n"
4753+
"\t\treturn v\n"
4754+
"\tdefault:\n"
4755+
"\t\treturn 0\n"
4756+
"\t}\n"
4757+
"}\n");
4758+
4759+
char db_path[512];
4760+
snprintf(db_path, sizeof(db_path), "%s/go_chan.db", tmp);
4761+
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
4762+
ASSERT_NOT_NULL(p);
4763+
ASSERT_EQ(cbm_pipeline_run(p), 0);
4764+
const char *project = cbm_pipeline_project_name(p);
4765+
4766+
cbm_store_t *s = cbm_store_open_path(db_path);
4767+
ASSERT_NOT_NULL(s);
4768+
4769+
/* One package-qualified channel node, transport gochan. */
4770+
char chan_name[512];
4771+
snprintf(chan_name, sizeof(chan_name), "%s.state.events", project);
4772+
cbm_node_t *chans = NULL;
4773+
int cc = 0;
4774+
cbm_store_find_nodes_by_name(s, project, chan_name, &chans, &cc);
4775+
ASSERT_EQ(cc, 1);
4776+
ASSERT_TRUE(chans[0].label && strcmp(chans[0].label, "Channel") == 0);
4777+
ASSERT_NOT_NULL(chans[0].properties_json);
4778+
ASSERT_NOT_NULL(strstr(chans[0].properties_json, "\"transport\":\"gochan\""));
4779+
cbm_store_free_nodes(chans, cc);
4780+
4781+
/* Producer and consumer link the SAME node across files. */
4782+
ASSERT_TRUE(channel_edge_exists(s, project, "Produce", chan_name, "EMITS"));
4783+
ASSERT_TRUE(channel_edge_exists(s, project, "Drain", chan_name, "LISTENS_ON"));
4784+
4785+
cbm_store_close(s);
4786+
cbm_pipeline_free(p);
4787+
th_rmtree(tmp);
4788+
PASS();
4789+
}
4790+
46914791
/* Count nodes with the given exact name in the project (e.g. a Route path). */
46924792
static int count_nodes_named(cbm_store_t *s, const char *project, const char *name) {
46934793
cbm_node_t *ns = NULL;
@@ -12805,6 +12905,7 @@ SUITE(pipeline) {
1280512905
#endif
1280612906
RUN_TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge);
1280712907
RUN_TEST(pipeline_python_receiver_suppresses_weak_method_edge);
12908+
RUN_TEST(pipeline_go_native_channel_topology);
1280812909
RUN_TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges);
1280912910
RUN_TEST(pipeline_python_receiver_parallel_suppresses_weak_method_edges);
1281012911
RUN_TEST(pipeline_parallel_python_cross_only_dunder_gets_synthetic_carrier);

0 commit comments

Comments
 (0)