Skip to content

Commit 91a3c6a

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 5fbab7b commit 91a3c6a

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
/* #1935: Go struct fields were never extracted — find_class_body() returns the
30193065
* struct_type node, whose only named child is a field_declaration_list, so the
30203066
* member loop matched nothing and every field was silently skipped (0 Field
@@ -6752,6 +6798,7 @@ SUITE(extraction) {
67526798
RUN_TEST(python_imports);
67536799
RUN_TEST(js_imports);
67546800
RUN_TEST(go_imports);
6801+
RUN_TEST(extract_go_native_channels);
67556802
RUN_TEST(extract_go_struct_fields_have_nodes);
67566803
RUN_TEST(java_imports);
67576804
RUN_TEST(rust_imports);

tests/test_pipeline.c

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4693,6 +4693,106 @@ TEST(pipeline_python_receiver_suppresses_weak_method_edge) {
46934693
PASS();
46944694
}
46954695

4696+
/* #1930: does an edge of this type run from the named function to the named
4697+
* Channel node? */
4698+
static bool channel_edge_exists(cbm_store_t *s, const char *project, const char *func_name,
4699+
const char *channel_name, const char *edge_type) {
4700+
cbm_node_t *srcs = NULL;
4701+
cbm_node_t *tgts = NULL;
4702+
int sc = 0;
4703+
int tc = 0;
4704+
cbm_store_find_nodes_by_name(s, project, func_name, &srcs, &sc);
4705+
cbm_store_find_nodes_by_name(s, project, channel_name, &tgts, &tc);
4706+
bool found = false;
4707+
for (int i = 0; i < sc && !found; i++) {
4708+
cbm_edge_t *edges = NULL;
4709+
int ec = 0;
4710+
cbm_store_find_edges_by_source_type(s, srcs[i].id, edge_type, &edges, &ec);
4711+
for (int j = 0; j < ec && !found; j++) {
4712+
for (int k = 0; k < tc; k++) {
4713+
if (edges[j].target_id == tgts[k].id) {
4714+
found = true;
4715+
break;
4716+
}
4717+
}
4718+
}
4719+
if (edges) {
4720+
cbm_store_free_edges(edges, ec);
4721+
}
4722+
}
4723+
if (srcs) {
4724+
cbm_store_free_nodes(srcs, sc);
4725+
}
4726+
if (tgts) {
4727+
cbm_store_free_nodes(tgts, tc);
4728+
}
4729+
return found;
4730+
}
4731+
4732+
TEST(pipeline_go_native_channel_topology) {
4733+
/* #1930: the producer/consumer topology of a Go channel pipeline — a send
4734+
* in one file, a select-receive in another file of the same package —
4735+
* must materialize as one gochan Channel node with EMITS/LISTENS_ON
4736+
* edges, so trace_path can cross the channel. RED on main: zero gochan
4737+
* Channel nodes exist. */
4738+
char tmp[256];
4739+
snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_chan_XXXXXX");
4740+
if (!cbm_mkdtemp(tmp)) {
4741+
FAIL("tmpdir");
4742+
}
4743+
write_temp_file(tmp, "go.mod", "module example.com/fxchan\n\ngo 1.22\n");
4744+
write_temp_file(tmp, "state/state.go",
4745+
"package state\n"
4746+
"\n"
4747+
"var events = make(chan int, 8)\n"
4748+
"\n"
4749+
"func Produce(v int) {\n"
4750+
"\tevents <- v\n"
4751+
"}\n");
4752+
write_temp_file(tmp, "state/drain.go",
4753+
"package state\n"
4754+
"\n"
4755+
"func Drain() int {\n"
4756+
"\tselect {\n"
4757+
"\tcase v := <-events:\n"
4758+
"\t\treturn v\n"
4759+
"\tdefault:\n"
4760+
"\t\treturn 0\n"
4761+
"\t}\n"
4762+
"}\n");
4763+
4764+
char db_path[512];
4765+
snprintf(db_path, sizeof(db_path), "%s/go_chan.db", tmp);
4766+
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
4767+
ASSERT_NOT_NULL(p);
4768+
ASSERT_EQ(cbm_pipeline_run(p), 0);
4769+
const char *project = cbm_pipeline_project_name(p);
4770+
4771+
cbm_store_t *s = cbm_store_open_path(db_path);
4772+
ASSERT_NOT_NULL(s);
4773+
4774+
/* One package-qualified channel node, transport gochan. */
4775+
char chan_name[512];
4776+
snprintf(chan_name, sizeof(chan_name), "%s.state.events", project);
4777+
cbm_node_t *chans = NULL;
4778+
int cc = 0;
4779+
cbm_store_find_nodes_by_name(s, project, chan_name, &chans, &cc);
4780+
ASSERT_EQ(cc, 1);
4781+
ASSERT_TRUE(chans[0].label && strcmp(chans[0].label, "Channel") == 0);
4782+
ASSERT_NOT_NULL(chans[0].properties_json);
4783+
ASSERT_NOT_NULL(strstr(chans[0].properties_json, "\"transport\":\"gochan\""));
4784+
cbm_store_free_nodes(chans, cc);
4785+
4786+
/* Producer and consumer link the SAME node across files. */
4787+
ASSERT_TRUE(channel_edge_exists(s, project, "Produce", chan_name, "EMITS"));
4788+
ASSERT_TRUE(channel_edge_exists(s, project, "Drain", chan_name, "LISTENS_ON"));
4789+
4790+
cbm_store_close(s);
4791+
cbm_pipeline_free(p);
4792+
th_rmtree(tmp);
4793+
PASS();
4794+
}
4795+
46964796
/* Fixture for the #1928 cross-language reference-guard probes (sequential and
46974797
* parallel twins). pad_files > 0 adds filler files to push the index over the
46984798
* parallel-pipeline threshold, since USAGE/WRITES/READS have one resolver per
@@ -13115,6 +13215,7 @@ SUITE(pipeline) {
1311513215
#endif
1311613216
RUN_TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge);
1311713217
RUN_TEST(pipeline_python_receiver_suppresses_weak_method_edge);
13218+
RUN_TEST(pipeline_go_native_channel_topology);
1311813219
RUN_TEST(pipeline_go_rw_usage_never_cross_into_c);
1311913220
RUN_TEST(pipeline_go_rw_usage_never_cross_into_c_parallel);
1312013221
RUN_TEST(pipeline_go_bare_ref_never_binds_field);

0 commit comments

Comments
 (0)