1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
use super::super::{
argument_place, assignment_declared_type, assignment_is_compound, assignment_place,
assignment_target_node, assignment_value_node, assignment_wrapper_has_nested_assignment,
call_arg_from_node_with_handler, callable_reference_name, expression_flow, extra_lhs_binding_targets,
extract_direct_call_info, extract_rhs_expr_operands, first_call_descendant, keyed_lhs_binding_sources,
looks_like_bare_identifier, looks_like_identifier, node_text, prepend_pipe_arg_to_call,
qualified_assign_target, same_identifier_name, span_of, subscript_place_parts,
type_only_declaration_without_initializer, AssignmentNodeSemantics, FlowEvent, Node,
};
use super::{walk_into, LoweringContext};
pub(super) fn lower_assignment(
node: Node<'_>,
context: LoweringContext<'_>,
out: &mut Vec<FlowEvent>,
) -> bool {
let LoweringContext {
file,
src,
handler,
class_names,
} = context;
let kind = node.kind();
if handler.is_assignment(kind) {
match handler.assignment_semantics(node, src) {
AssignmentNodeSemantics::Assignment => {}
AssignmentNodeSemantics::Pipe => {
// Elixir pipe `lhs |> call(args)` desugars to `call(lhs,
// args)` — the piped value becomes the callee's FIRST
// argument. Without threading it in, `conn.params |>
// System.cmd()` leaves `System.cmd` with no args and the
// piped taint never reaches the sink. Walk the RHS call,
// then prepend the LHS as its arg 0.
if let (Some(left), Some(right)) = (
node.child_by_field_name("left"),
node.child_by_field_name("right"),
) {
walk_into(left, file, src, handler, class_names, out, false);
let before = out.len();
walk_into(right, file, src, handler, class_names, out, false);
prepend_pipe_arg_to_call(out, before, &right, &left, file, src, handler);
}
return true;
}
AssignmentNodeSemantics::Other => {
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
walk_into(child, file, src, handler, class_names, out, false);
}
return true;
}
}
if assignment_wrapper_has_nested_assignment(&node, src, handler) {
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
walk_into(child, file, src, handler, class_names, out, false);
}
return true;
}
// Target-name extraction. Grammars disagree on field names, and
// some emit keywords (`val`, `var`, `let`, `const`, `auto`) as
// visible named children — skip those so the real identifier
// gets picked. Kotlin `property_declaration` is the canonical
// offender: its first named child is the `val`/`var` keyword
// node, which without filtering would become the target text.
let target_node = assignment_target_node(node, src, handler);
// If the picked target is itself a declarator wrapper —
// tree-sitter emits `variable_declarator` in C# /
// JavaScript / TypeScript / Java, `init_declarator` in C /
// C++, and similar in Swift — descend into its
// identifier child so the emitted `target` is the variable
// name rather than the whole `name = rhs` expression. Also
// the downstream walker will visit the declarator anyway
// and emit its own clean Assign, so without this unwrap we
// get two events per variable: one with wrapper text and one
// with the canonical identifier.
let target = target_node
.and_then(|target| assignment_place(target, src, handler))
.unwrap_or_default();
// RHS: most grammars expose it via `right` or `value`. Kotlin's
// property_declaration has no field for the initializer — it's
// just a sibling of the variable-declaration identifier. We'd
// rather over-walk than miss calls on the RHS, so as a fallback
// we pick the LAST named child that isn't the target node.
let rhs = assignment_value_node(node, target_node);
let rhs_is_callable_literal = rhs.is_some_and(|rhs_node| handler.is_lambda(rhs_node.kind()));
let callable_source = rhs
.filter(|_| !rhs_is_callable_literal)
.and_then(|rhs_node| callable_reference_name(&rhs_node, src, handler));
let simple_place_source = rhs
.filter(|rhs_node| looks_like_identifier(rhs_node.kind()))
.and_then(|rhs_node| argument_place(&rhs_node, src, handler));
let mut assignment_value_kind = rhs
.and_then(|value| handler.expression_value_kind(value, src))
.or_else(|| {
(callable_source.is_some() || rhs_is_callable_literal)
.then_some(crate::AssignValueKind::CallableReference)
});
let mut source_name = callable_source
.clone()
.or_else(|| simple_place_source.clone())
.or_else(|| {
rhs.and_then(|rhs_node| {
let rhs_text = node_text(&rhs_node, src).trim();
// Only emit `source_name` for bare-identifier RHS — compound
// expressions go through `source_call` / `source_names`.
looks_like_bare_identifier(rhs_text).then(|| rhs_text.to_string())
})
});
// source_call: when the RHS is a direct call expression,
// capture the callee name + the call's positional
// argument identifier texts. This is what the interprocedural
// taint pass uses to propagate return-value taint:
// `y = transform(x)` → source_call = Some("transform"),
// `y = item.get("k")` → source_call = Some("item.get"),
// source_call_args = ["x"]. If transform's summary says
// param 0 flows to the return, y inherits x's taint.
let (mut source_call, mut source_call_args) = if callable_source.is_some() || rhs_is_callable_literal
{
(None, Vec::new())
} else {
rhs.and_then(|n| extract_direct_call_info(&n, src, handler))
.or_else(|| {
rhs.is_none()
.then(|| {
first_call_descendant(node, handler)
.and_then(|call| extract_direct_call_info(&call, src, handler))
})
.flatten()
})
// Some grammars split one call across sibling CST nodes, so
// the field-selected RHS is only the callee or one selector.
// Give the adapter's exact decoder the complete assignment
// node before concluding that the initializer is not a call.
.or_else(|| {
handler
.direct_call_info_extractor
.and_then(|extract| extract(node, src, handler))
})
.unwrap_or((None, Vec::new()))
};
// G2: when the RHS is a compound expression (template literal,
// string concat, binary op, f-string, interpolation, member /
// subscript access, ternary, null-coalesce), there is no
// single call or bare identifier. Extract every bare-identifier
// operand into `source_names` so the intra / inter passes
// treat "any operand tainted → target tainted". This makes
// `y = "prefix" + tainted` / `y = f"{x}"` / `` y = `${x}` `` /
// `y = obj.field` / `y = cond ? a : b` propagate taint
// correctly across all grammars without requiring the adapter
// to evaluate the expression AST itself.
let mut source_names: Vec<String> = Vec::new();
if callable_source.is_none() && !rhs_is_callable_literal {
if let Some(n) = rhs {
source_names.extend(extract_rhs_expr_operands(&n, src, handler));
// Retain the exact parser-proven qualified place in addition
// to scalar operands. This preserves language sigils on a
// projection (`$obj->token` -> `$obj.token`) without an
// adapter rescanning rendered expression text.
if let Some(place) = argument_place(&n, src, handler).filter(|place| place.contains('.')) {
source_names.push(place);
}
}
}
// Some grammars expose a declaration initializer as a wrapper
// whose "rhs" fallback is only the callee/type node, while the
// actual argument expressions are siblings inside the full
// assignment/declaration node. Walk the full assignment as a
// tree-sitter expression fallback and drop the target itself so
// constructor/object-literal assignments like
// `env = Envelope(cmd: raw)` preserve the `raw` dependency.
if rhs.is_none() {
source_names.extend(extract_rhs_expr_operands(&node, src, handler));
}
// H1: `x OP= rhs` desugars to `x = x OP rhs`, so the LHS is always
// read. Keep it among the sources (don't strip via same_identifier)
// so a literal / untainted RHS can't reach the clean-overwrite kill
// arm and drop `x`'s prior taint.
let is_compound = assignment_is_compound(&node, src, handler);
// Self-referential assignment: `x = x + a`, `x = x.field`,
// `x = cond ? x : y`. When the target appears as an operand of a
// NON-call RHS expression, it is genuinely read into the result
// (exactly like a compound `x += a`), so it must NOT be stripped as
// a clean overwrite — dropping it silently loses `x`'s prior taint,
// a universal false negative. A CALL RHS (`x = sanitize(x)`) still
// strips: there the target is a consumed argument and the result is
// the callee's return, so clean-overwrite / call-result semantics
// apply. `rhs` is `None` only for the full-node structural fallback
// (where the LHS identifier can also appear among descendants), so a
// missing RHS also strips.
let rhs_is_noncall_expr = rhs.as_ref().is_some_and(|n| !handler.is_call(n.kind()));
let target_self_read = is_compound || rhs_is_noncall_expr;
if !target_self_read {
source_names.retain(|name| !same_identifier_name(name, &target));
}
// Only a compound `x += a` unconditionally reads its target, so only
// it re-adds the target when extraction missed it. A plain
// `x = a + b` must NOT push `x` — that would fabricate a self-read
// and keep `x`'s prior taint through a clean overwrite. The genuine
// non-call self-read (`x = x + a`) already carries `x` in
// `source_names` (it was never stripped), so it needs no push.
if is_compound
&& !target.is_empty()
&& !source_names
.iter()
.any(|name| same_identifier_name(name, &target))
{
source_names.push(target.clone());
}
source_names.sort();
source_names.dedup();
// Keyed destructuring is a field projection, not a whole-container
// read. Preserve the parser-declared selector for each binding so
// `['cmd' => $cmd] = $envelope` lowers to
// `cmd <- $envelope.cmd`. This keeps exact aggregate writes
// field-sensitive across a later destructure without recovering keys
// from rendered assignment text.
let keyed_binding_sources = rhs
.and_then(|rhs_node| argument_place(&rhs_node, src, handler))
.map(|base| keyed_lhs_binding_sources(&node, src, &base, handler))
.unwrap_or_default();
// Some grammars emit a declaration-name wrapper as an
// assignment-shaped node (`val raw` / `local raw`) in addition
// to the real initializer assignment. A node with no RHS and
// no surfaced source operands has no value semantics; emitting
// it would be a fake clean overwrite that erases the real
// source assignment immediately after it.
let has_value_semantics =
(rhs.is_some() || source_name.is_some() || source_call.is_some() || !source_names.is_empty())
&& !type_only_declaration_without_initializer(&node, handler);
if has_value_semantics {
// Positional aggregate initialization is a distinct compiler
// operation from scalar assignment. Preserve the initializer's
// ordered tree-sitter value facts here; the workspace semantic
// pass later resolves the declared type against its parsed field
// layout (including layouts declared in another file).
if let Some(rhs_node) = rhs.filter(|rhs_node| {
handler
.positional_aggregate_assignment_kinds
.contains(&node.kind())
&& handler
.positional_aggregate_value_kinds
.contains(&rhs_node.kind())
}) {
let value_flow =
expression_flow::positional_expression_flow_from_node(rhs_node, file, src, handler);
if !value_flow.tuple_items.is_empty() && !target.is_empty() {
out.push(FlowEvent::AggregateAssign {
span: span_of(file, &node),
target: target.clone(),
type_name: assignment_declared_type(&node, src),
value_flow,
});
}
}
// G3 + G4: when the LHS is a member / subscript expression
// (`self.cmd = x`, `env['cmd'] = x`), also emit an Assign for
// the FULL qualified form so reads of `self.cmd` / `env.cmd`
// elsewhere in the function can see the write. The bare
// `cmd` Assign below stays because many reads still come
// through as the bare identifier (`cmd = self.cmd; use(cmd)`).
// Both entries carry the same source_name / source_call so
// the taint transfer sees the same RHS dependency on both
// keys.
let qualified_target = qualified_assign_target(target_node, src, handler);
if let Some(qname) = qualified_target.as_ref() {
if qname != &target {
out.push(FlowEvent::Assign {
span: span_of(file, &node),
target: qname.clone(),
source_name: source_name.clone(),
source_call: source_call.clone(),
source_call_args: source_call_args.clone(),
source_names: source_names.clone(),
declares_new_binding: false,
value_kind: assignment_value_kind,
});
}
}
// Parallel/destructured bindings are grammar-proven independently
// of qualified-place recovery. Lua's `local ok, value = pcall(...)`
// exposes a `variable_list`; its head is also a valid simple
// qualified target, but that must not suppress the remaining
// result slots. Member/subscript places cannot enter this loop
// because `extra_lhs_binding_targets` accepts only aggregate CST
// pattern kinds.
for extra_target in extra_lhs_binding_targets(&node, src, &target, handler) {
let keyed_source = keyed_binding_sources.iter().find_map(|(binding, source)| {
same_identifier_name(binding, &extra_target).then_some(source)
});
out.push(FlowEvent::Assign {
span: span_of(file, &node),
target: extra_target,
source_name: keyed_source.cloned().or_else(|| source_name.clone()),
source_call: keyed_source.is_none().then(|| source_call.clone()).flatten(),
source_call_args: keyed_source.map_or_else(|| source_call_args.clone(), |_| Vec::new()),
source_names: keyed_source
.map(|source| vec![source.clone()])
.unwrap_or_else(|| source_names.clone()),
declares_new_binding: false,
value_kind: keyed_source
.map(|_| crate::AssignValueKind::Destructure)
.or(assignment_value_kind),
});
}
// Preserve a parser-proven indexed write as a typed operation.
// Its arguments are the index and assigned value, so rulepacks
// can select this source-language shape without a fake runtime
// API name in shared lowering. Gated to a simple `<ident>[...]`
// LHS so it never fires on member/nested-subscript shapes.
if let (Some(target_node), Some(value_node)) = (target_node, rhs) {
if let Some((base_node, key_node)) = subscript_place_parts(target_node, handler) {
let base = node_text(&base_node, src).trim();
if looks_like_bare_identifier(base) {
let key_arg = call_arg_from_node_with_handler(key_node, file, src, None, handler);
let value_arg = call_arg_from_node_with_handler(value_node, file, src, None, handler);
if let (Some(key_arg), Some(value_arg)) = (key_arg, value_arg) {
let span = span_of(file, &node);
out.push(FlowEvent::Call {
span,
receiver: Some(base.to_string()),
receiver_types: Vec::new(),
name: format!("{base}.{}", crate::CallKind::IndexWrite.as_str()),
call_kind: crate::CallKind::IndexWrite,
args: vec![key_arg, value_arg],
});
}
}
}
}
// A pattern LHS whose head sanitizes to empty (extractor /
// constructor pattern like Scala `val Envelope(kind, cmd)`,
// where `Envelope(kind` is not a real lvalue) must not emit a
// blank-target Assign — the real bindings are already emitted
// as extras above.
if !target.is_empty() {
if let Some(keyed_source) = keyed_binding_sources
.iter()
.find_map(|(binding, source)| same_identifier_name(binding, &target).then_some(source))
{
source_name = Some(keyed_source.clone());
source_call = None;
source_call_args.clear();
source_names = vec![keyed_source.clone()];
assignment_value_kind = Some(crate::AssignValueKind::Destructure);
}
out.push(FlowEvent::Assign {
span: span_of(file, &node),
target,
source_name,
source_call,
source_call_args,
source_names,
declares_new_binding: false,
value_kind: assignment_value_kind,
});
}
}
// Walk every named child so nested calls inside the LHS or RHS
// surface, regardless of whether the grammar exposes
// `right`/`value` fields. C# `variable_declaration` wraps the
// initializer in a `variable_declarator`; Kotlin's
// `property_declaration` has no field for the initializer at
// all; JS's `variable_declarator` nests the initializer under
// a `value` that we DO have but also has nothing else to skip.
// Over-walking a rhs is fine — call events surface once.
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
walk_into(child, file, src, handler, class_names, out, false);
}
return true;
}
false
}