mirror of
https://github.com/elicpeter/nyx.git
synced 2026-07-24 21:41:02 +02:00
fix(engine): CFG/SSA/taint/IPA soundness, precision & recall fixes
This commit is contained in:
parent
59e4359257
commit
246f32a419
39 changed files with 4729 additions and 465 deletions
|
|
@ -16,6 +16,28 @@ fn lang_has_exclusive_cases(lang: &str) -> bool {
|
|||
matches!(lang, "rust" | "go")
|
||||
}
|
||||
|
||||
/// True when *this specific switch* has guaranteed-exclusive (non-fall-through)
|
||||
/// cases, so it is safe to reorder the `default` arm to the cascade tail.
|
||||
///
|
||||
/// Rust `match` and Go `switch` are always exclusive. Java mixes shapes: the
|
||||
/// arrow form (`switch_rule` cases, `case x -> ...`) is exclusive, but the
|
||||
/// classic colon form (`switch_block_statement_group`, `case x:` with implicit
|
||||
/// fall-through) is NOT. C/C++/JS/TS/PHP classic switches fall through and are
|
||||
/// never exclusive. Reordering `default` to the tail is only correct for the
|
||||
/// exclusive shapes; doing it for fall-through switches connects the wrong
|
||||
/// case bodies in the source-order fall-through chain (both missed and phantom
|
||||
/// taint flows).
|
||||
fn switch_is_exclusive(lang: &str, cases: &[(Node<'_>, bool)]) -> bool {
|
||||
if lang_has_exclusive_cases(lang) {
|
||||
return true;
|
||||
}
|
||||
if lang == "java" {
|
||||
// Arrow-switch when every case is the arrow `switch_rule` shape.
|
||||
return cases.iter().all(|(c, _)| c.kind() == "switch_rule");
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Extract the scrutinee subtree from a switch-like AST node.
|
||||
///
|
||||
/// Returns the AST node referenced by the language's scrutinee field. Only
|
||||
|
|
@ -591,12 +613,21 @@ pub(super) fn build_switch<'a>(
|
|||
return exits;
|
||||
}
|
||||
|
||||
// Whether this switch's cases are mutually exclusive (no fall-through).
|
||||
// Only exclusive switches may have `default` reordered to the cascade tail.
|
||||
let is_exclusive = switch_is_exclusive(lang, &cases);
|
||||
|
||||
// Reorder so the default arm (if any) sits at the tail of the cascade.
|
||||
// Reordering case dispatch is semantically harmless (mutually exclusive
|
||||
// pattern matches), and it keeps the chain a clean Branch(True→case,
|
||||
// False→next). Fall-through chains are a separate Seq layer below.
|
||||
// Reordering case dispatch is semantically harmless ONLY for mutually
|
||||
// exclusive pattern matches (Rust match, Go switch, Java arrow-switch); it
|
||||
// keeps the chain a clean Branch(True→case, False→next). For classic
|
||||
// fall-through switches (C/C++/JS/TS/PHP, Java colon-switch) a mid-chain
|
||||
// `default:` can fall into the following case and a preceding case can fall
|
||||
// into it, so the source order MUST be preserved — reordering there breaks
|
||||
// the fall-through Seq layer and produces both missed and phantom flows.
|
||||
let default_pos = cases.iter().position(|(_, d)| *d);
|
||||
if let Some(pos) = default_pos
|
||||
if is_exclusive
|
||||
&& let Some(pos) = default_pos
|
||||
&& pos != cases.len() - 1
|
||||
{
|
||||
let default_pair = cases.remove(pos);
|
||||
|
|
@ -648,22 +679,40 @@ pub(super) fn build_switch<'a>(
|
|||
let mut fallthrough_exits: Vec<NodeIndex> = Vec::new();
|
||||
let mut last_header_false: Option<NodeIndex> = None;
|
||||
let mut chain_preds: Vec<NodeIndex> = preds.to_vec();
|
||||
// First node of the `default` body for a fall-through switch where the
|
||||
// default is NOT at the tail. The cumulative no-match path (the last
|
||||
// non-default header's False edge) is wired into it after the loop, so the
|
||||
// default stays in its source position for the fall-through Seq layer while
|
||||
// still being reachable when no case matches.
|
||||
let mut pending_default_no_match: Option<NodeIndex> = None;
|
||||
|
||||
for (idx, (case, is_default)) in cases.iter().copied().enumerate() {
|
||||
let is_last = idx + 1 == cases.len();
|
||||
|
||||
// A `default` arm carries no discriminant test, so it never gets its
|
||||
// own dispatch If. For exclusive switches it has been reordered to the
|
||||
// tail (`is_last`); for fall-through switches it stays in source
|
||||
// position (`!is_exclusive`) and is wired into the Seq fall-through
|
||||
// chain instead of acting as a conditional branch.
|
||||
let default_no_dispatch = is_default && (is_last || !is_exclusive);
|
||||
|
||||
// Default at the chain tail doesn't get its own dispatch If, the
|
||||
// previous header's False edge already targets it directly.
|
||||
let case_first_preds: Vec<NodeIndex> = if is_default && is_last {
|
||||
// First node of the default body becomes the False target of the
|
||||
// previous header. Build the case with the previous chain_preds
|
||||
// (the last header's "fall-through" branch) plus any fallthrough
|
||||
// from the preceding case.
|
||||
let mut p = chain_preds.clone();
|
||||
p.append(&mut fallthrough_exits);
|
||||
// `last_header_false` will receive a False edge once we know the
|
||||
// first node of this body.
|
||||
last_header_false = chain_preds.first().copied();
|
||||
let case_first_preds: Vec<NodeIndex> = if default_no_dispatch {
|
||||
// Body entry = fall-through from the preceding case body.
|
||||
let mut p = std::mem::take(&mut fallthrough_exits);
|
||||
if is_last {
|
||||
// Tail default: the previous header's False branch also lands
|
||||
// here directly (legacy behavior preserved for exclusive
|
||||
// switches and tail defaults).
|
||||
p.extend(chain_preds.iter().copied());
|
||||
last_header_false = chain_preds.first().copied();
|
||||
}
|
||||
// For a non-tail (fall-through) default the dispatch chain must
|
||||
// continue PAST it, so `chain_preds` / `last_header_false` are left
|
||||
// untouched and the next case's dispatch header still receives the
|
||||
// previous header's False edge. The cumulative no-match entry is
|
||||
// recorded below once the body's first node is known.
|
||||
p
|
||||
} else {
|
||||
// Normal case: synthesize a per-case dispatch header. We tie it
|
||||
|
|
@ -741,12 +790,21 @@ pub(super) fn build_switch<'a>(
|
|||
// Wire the dispatch True edge from this header (or from the previous
|
||||
// header for a tail-default) to the first node of the case body.
|
||||
if body_first_idx.index() < g.node_count() {
|
||||
let header_for_true = if is_default && is_last {
|
||||
// The previous header's False already lands here via the
|
||||
// EdgeKind::Seq inside `case_first_preds`; we additionally
|
||||
// emit a False edge directly so SSA labels the branch.
|
||||
if let Some(prev) = last_header_false {
|
||||
g.add_edge(prev, body_first_idx, EdgeKind::False);
|
||||
let header_for_true = if default_no_dispatch {
|
||||
if is_last {
|
||||
// Tail default: the previous header's False already lands
|
||||
// here via the EdgeKind::Seq inside `case_first_preds`; we
|
||||
// additionally emit a False edge directly so SSA labels the
|
||||
// branch.
|
||||
if let Some(prev) = last_header_false {
|
||||
g.add_edge(prev, body_first_idx, EdgeKind::False);
|
||||
}
|
||||
} else {
|
||||
// Non-tail fall-through default: defer wiring the no-match
|
||||
// entry until the last non-default header's False edge is
|
||||
// known (after the loop). The body's only in-edge for now
|
||||
// is the source-order fall-through from the preceding case.
|
||||
pending_default_no_match = Some(body_first_idx);
|
||||
}
|
||||
None
|
||||
} else {
|
||||
|
|
@ -762,11 +820,22 @@ pub(super) fn build_switch<'a>(
|
|||
let _ = is_default;
|
||||
}
|
||||
|
||||
// After the chain: the last non-default header (if no default arm) needs
|
||||
// a False edge that escapes to the post-switch frontier.
|
||||
// Resolve the cumulative no-match (the last non-default header's False
|
||||
// edge):
|
||||
// - If the `default` arm sits mid-chain (fall-through switch), the
|
||||
// no-match path enters the default body — wire the deferred False edge
|
||||
// into it. The default stayed in source position for the fall-through
|
||||
// Seq layer, so this is the only edge making it reachable on no-match.
|
||||
// - Otherwise, with no reachable default (no default arm, or it was the
|
||||
// tail and already consumed the False edge), the no-match path escapes
|
||||
// to the post-switch frontier.
|
||||
let mut exits: Vec<NodeIndex> = switch_breaks;
|
||||
exits.append(&mut fallthrough_exits);
|
||||
if !has_default {
|
||||
if let Some(default_first) = pending_default_no_match {
|
||||
if let Some(prev) = last_header_false {
|
||||
g.add_edge(prev, default_first, EdgeKind::False);
|
||||
}
|
||||
} else if !has_default {
|
||||
if let Some(prev) = last_header_false {
|
||||
exits.push(prev);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,13 +64,26 @@ pub(super) fn get_boolean_operands<'a>(node: Node<'a>) -> Option<(Node<'a>, Node
|
|||
None
|
||||
}
|
||||
|
||||
/// Create a lightweight `StmtKind::If` node for a sub-condition in a boolean chain.
|
||||
/// Create a `StmtKind::If` node for a sub-condition in a boolean chain.
|
||||
///
|
||||
/// When the operand contains a classifiable call (`if (flag && cp.execSync(x))`),
|
||||
/// the node is built via [`push_node`] so the inner source/sink/sanitizer
|
||||
/// classification — callee, labels, arg-uses, gated sinks — is preserved, then
|
||||
/// the branch-condition metadata is overlaid on top. Without this, a sink or
|
||||
/// source CALL inside a short-circuited operand was dropped entirely (the bare
|
||||
/// node only carried `condition_vars`, no callee or labels), so the
|
||||
/// `if (flag && sink(x))` form missed flows that the un-decomposed
|
||||
/// `if (sink(x))` form catches via the mod.rs condition-call fallback. This
|
||||
/// mirrors `lower_ternary_branch`, which already uses `push_node` for the same
|
||||
/// reason. Non-call operands keep the original lightweight shape.
|
||||
pub(super) fn push_condition_node<'a>(
|
||||
g: &mut Cfg,
|
||||
cond_ast: Node<'a>,
|
||||
lang: &str,
|
||||
code: &'a [u8],
|
||||
enclosing_func: Option<&str>,
|
||||
call_ordinal: &mut u32,
|
||||
analysis_rules: Option<&LangAnalysisRules>,
|
||||
) -> NodeIndex {
|
||||
// Pass cond_ast as both args, sub-conditions are never `unless` nodes
|
||||
let (inner, negated) = detect_negation(cond_ast, cond_ast, lang);
|
||||
|
|
@ -94,6 +107,44 @@ pub(super) fn push_condition_node<'a>(
|
|||
// because the per-disjunct cond nodes (built via
|
||||
// `build_condition_chain`) didn't populate `taint.uses`.
|
||||
let uses_for_taint: Vec<String> = vars.clone();
|
||||
|
||||
// Operand containing a call → route through `push_node` for full
|
||||
// source/sink/sanitizer classification, then overlay branch metadata.
|
||||
if has_call_descendant(inner, lang) {
|
||||
let ord = *call_ordinal;
|
||||
*call_ordinal += 1;
|
||||
let node = push_node(
|
||||
g,
|
||||
StmtKind::If,
|
||||
inner,
|
||||
lang,
|
||||
code,
|
||||
enclosing_func,
|
||||
ord,
|
||||
analysis_rules,
|
||||
);
|
||||
// Overlay the branch-condition metadata that the chain wiring depends
|
||||
// on. `push_node` set `defines`/`uses`/`labels`/`callee` from the call;
|
||||
// keep those, but ensure the condition fields and the interned
|
||||
// condition vars (for `apply_branch_predicates`) are present, and the
|
||||
// span/enclosing_func point at the operand expression.
|
||||
let info = &mut g[node];
|
||||
info.kind = StmtKind::If;
|
||||
info.ast.span = span;
|
||||
info.ast.enclosing_func = enclosing_func.map(|s| s.to_string());
|
||||
info.condition_text = text;
|
||||
info.condition_vars = vars;
|
||||
info.condition_negated = negated;
|
||||
// Union the condition idents into `taint.uses` so branch-predicate
|
||||
// lookup keeps working without clobbering the call's own arg uses.
|
||||
for v in &uses_for_taint {
|
||||
if !info.taint.uses.contains(v) {
|
||||
info.taint.uses.push(v.clone());
|
||||
}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
g.add_node(NodeInfo {
|
||||
kind: StmtKind::If,
|
||||
ast: AstMeta {
|
||||
|
|
@ -221,7 +272,15 @@ pub(super) fn build_ternary_diamond<'a>(
|
|||
// 1. Condition header. `push_condition_node` sets span/text/vars/negated
|
||||
// but leaves `is_eq_with_const` default; stamp it explicitly so the
|
||||
// taint engine's equality-narrowing fires for `x === 'literal' ? …`.
|
||||
let cond_if = push_condition_node(g, cond_ast, lang, code, enclosing_func);
|
||||
let cond_if = push_condition_node(
|
||||
g,
|
||||
cond_ast,
|
||||
lang,
|
||||
code,
|
||||
enclosing_func,
|
||||
call_ordinal,
|
||||
analysis_rules,
|
||||
);
|
||||
g[cond_if].is_eq_with_const = detect_eq_with_const(cond_ast, lang);
|
||||
// Capture the pure int-arith + comparison tree so `fold_constant_branches`
|
||||
// can prune a dead constant-condition arm of the ternary (e.g. Java
|
||||
|
|
@ -488,6 +547,7 @@ pub(super) fn classify_ternary_lhs(
|
|||
///
|
||||
/// Returns `(true_exits, false_exits)`, the sets of nodes from which True/False
|
||||
/// edges should connect to the then/else branches.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn build_condition_chain<'a>(
|
||||
cond_ast: Node<'a>,
|
||||
preds: &[NodeIndex],
|
||||
|
|
@ -496,6 +556,8 @@ pub(super) fn build_condition_chain<'a>(
|
|||
lang: &str,
|
||||
code: &'a [u8],
|
||||
enclosing_func: Option<&str>,
|
||||
call_ordinal: &mut u32,
|
||||
analysis_rules: Option<&LangAnalysisRules>,
|
||||
) -> (Vec<NodeIndex>, Vec<NodeIndex>) {
|
||||
let inner = unwrap_parens(cond_ast);
|
||||
|
||||
|
|
@ -503,8 +565,17 @@ pub(super) fn build_condition_chain<'a>(
|
|||
Some(BoolOp::And) => {
|
||||
if let Some((left, right)) = get_boolean_operands(inner) {
|
||||
// Left operand with current preds
|
||||
let (left_true, left_false) =
|
||||
build_condition_chain(left, preds, pred_edge, g, lang, code, enclosing_func);
|
||||
let (left_true, left_false) = build_condition_chain(
|
||||
left,
|
||||
preds,
|
||||
pred_edge,
|
||||
g,
|
||||
lang,
|
||||
code,
|
||||
enclosing_func,
|
||||
call_ordinal,
|
||||
analysis_rules,
|
||||
);
|
||||
// Right operand only evaluated when left is true
|
||||
let (right_true, right_false) = build_condition_chain(
|
||||
right,
|
||||
|
|
@ -514,6 +585,8 @@ pub(super) fn build_condition_chain<'a>(
|
|||
lang,
|
||||
code,
|
||||
enclosing_func,
|
||||
call_ordinal,
|
||||
analysis_rules,
|
||||
);
|
||||
// AND: true only when both true; false when either false
|
||||
let mut false_exits = left_false;
|
||||
|
|
@ -521,7 +594,15 @@ pub(super) fn build_condition_chain<'a>(
|
|||
(right_true, false_exits)
|
||||
} else {
|
||||
// Safety fallback: treat as leaf
|
||||
let node = push_condition_node(g, inner, lang, code, enclosing_func);
|
||||
let node = push_condition_node(
|
||||
g,
|
||||
inner,
|
||||
lang,
|
||||
code,
|
||||
enclosing_func,
|
||||
call_ordinal,
|
||||
analysis_rules,
|
||||
);
|
||||
connect_all(g, preds, node, pred_edge);
|
||||
(vec![node], vec![node])
|
||||
}
|
||||
|
|
@ -529,8 +610,17 @@ pub(super) fn build_condition_chain<'a>(
|
|||
Some(BoolOp::Or) => {
|
||||
if let Some((left, right)) = get_boolean_operands(inner) {
|
||||
// Left operand with current preds
|
||||
let (left_true, left_false) =
|
||||
build_condition_chain(left, preds, pred_edge, g, lang, code, enclosing_func);
|
||||
let (left_true, left_false) = build_condition_chain(
|
||||
left,
|
||||
preds,
|
||||
pred_edge,
|
||||
g,
|
||||
lang,
|
||||
code,
|
||||
enclosing_func,
|
||||
call_ordinal,
|
||||
analysis_rules,
|
||||
);
|
||||
// Right operand only evaluated when left is false
|
||||
let (right_true, right_false) = build_condition_chain(
|
||||
right,
|
||||
|
|
@ -540,6 +630,8 @@ pub(super) fn build_condition_chain<'a>(
|
|||
lang,
|
||||
code,
|
||||
enclosing_func,
|
||||
call_ordinal,
|
||||
analysis_rules,
|
||||
);
|
||||
// OR: true when either true; false only when both false
|
||||
let mut true_exits = left_true;
|
||||
|
|
@ -547,14 +639,30 @@ pub(super) fn build_condition_chain<'a>(
|
|||
(true_exits, right_false)
|
||||
} else {
|
||||
// Safety fallback: treat as leaf
|
||||
let node = push_condition_node(g, inner, lang, code, enclosing_func);
|
||||
let node = push_condition_node(
|
||||
g,
|
||||
inner,
|
||||
lang,
|
||||
code,
|
||||
enclosing_func,
|
||||
call_ordinal,
|
||||
analysis_rules,
|
||||
);
|
||||
connect_all(g, preds, node, pred_edge);
|
||||
(vec![node], vec![node])
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Leaf: single condition node
|
||||
let node = push_condition_node(g, inner, lang, code, enclosing_func);
|
||||
let node = push_condition_node(
|
||||
g,
|
||||
inner,
|
||||
lang,
|
||||
code,
|
||||
enclosing_func,
|
||||
call_ordinal,
|
||||
analysis_rules,
|
||||
);
|
||||
connect_all(g, preds, node, pred_edge);
|
||||
(vec![node], vec![node])
|
||||
}
|
||||
|
|
|
|||
411
src/cfg/mod.rs
411
src/cfg/mod.rs
|
|
@ -3541,9 +3541,17 @@ pub(super) fn push_node<'a>(
|
|||
None
|
||||
};
|
||||
|
||||
// Extract condition metadata for If nodes.
|
||||
// Extract condition metadata for If nodes. Python `elif_clause` and PHP
|
||||
// `else_if_clause` are lowered as guard nodes by `build_alternative_chain`
|
||||
// (the flat-sibling elif-chain handler) and carry their own `condition`
|
||||
// field, so they must also receive condition-metadata extraction even
|
||||
// though their grammar kind maps to `Kind::Block`. These two node kinds
|
||||
// only ever reach `push_node` from that handler, so widening the guard
|
||||
// here cannot affect ordinary block lowering.
|
||||
let (condition_text, condition_vars, condition_negated, cond_arith) =
|
||||
if matches!(lookup(lang, ast.kind()), Kind::If) {
|
||||
if matches!(lookup(lang, ast.kind()), Kind::If)
|
||||
|| matches!(ast.kind(), "elif_clause" | "else_if_clause")
|
||||
{
|
||||
extract_condition_raw(ast, lang, code)
|
||||
} else {
|
||||
(None, Vec::new(), false, None)
|
||||
|
|
@ -5074,6 +5082,230 @@ fn apply_arg_source_bindings(
|
|||
}
|
||||
}
|
||||
|
||||
/// Lower a flat chain of `alternative` siblings (Python `elif_clause`s / PHP
|
||||
/// `else_if_clause`s, optionally trailed by an `else_clause`) into a properly
|
||||
/// nested else-if CFG.
|
||||
///
|
||||
/// tree-sitter exposes these clauses as repeated, FLAT `alternative` fields on
|
||||
/// a single `if_statement` rather than nesting each `else if` inside the
|
||||
/// previous `else` (as JS/TS/Rust/Go/Java/C do). The default single-`else`
|
||||
/// lowering only consumed the first sibling, silently dropping every 2nd+ elif
|
||||
/// and the trailing else — so any source/sink/sanitizer/guard living there was
|
||||
/// invisible to the whole pipeline. This builder restores the missing CFG
|
||||
/// nodes by chaining each elif as a guard whose False edge flows into the next
|
||||
/// alternative.
|
||||
///
|
||||
/// `incoming_edge` is the edge label used to enter the chain (the parent `if`'s
|
||||
/// false edge — `EdgeKind::False` normally, `EdgeKind::True` for Ruby
|
||||
/// `unless`). Returns the union of all branch exits plus the final
|
||||
/// fall-through (when the chain has no terminal `else`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn build_alternative_chain<'a>(
|
||||
alternatives: &[Node<'a>],
|
||||
preds: &[NodeIndex],
|
||||
incoming_edge: EdgeKind,
|
||||
g: &mut Cfg,
|
||||
lang: &str,
|
||||
code: &'a [u8],
|
||||
summaries: &mut FuncSummaries,
|
||||
file_path: &str,
|
||||
enclosing_func: Option<&str>,
|
||||
call_ordinal: &mut u32,
|
||||
analysis_rules: Option<&LangAnalysisRules>,
|
||||
break_targets: &mut Vec<NodeIndex>,
|
||||
continue_targets: &mut Vec<NodeIndex>,
|
||||
throw_targets: &mut Vec<NodeIndex>,
|
||||
bodies: &mut Vec<BodyCfg>,
|
||||
next_body_id: &mut u32,
|
||||
current_body_id: BodyId,
|
||||
) -> Vec<NodeIndex> {
|
||||
// Predecessor frontier entering the current alternative, and the edge label
|
||||
// to use when wiring into it. Updated as we descend the chain: each elif's
|
||||
// False edge becomes the predecessor/edge for the next alternative.
|
||||
let mut chain_preds: Vec<NodeIndex> = preds.to_vec();
|
||||
let mut chain_edge = incoming_edge;
|
||||
let mut exits: Vec<NodeIndex> = Vec::new();
|
||||
|
||||
for &alt in alternatives {
|
||||
let is_elif = matches!(alt.kind(), "elif_clause" | "else_if_clause");
|
||||
|
||||
if is_elif {
|
||||
// Guard node for the elif condition. `push_node` with `StmtKind::If`
|
||||
// extracts condition metadata and runs label classification on the
|
||||
// clause (its `condition` field), so a source/sink call inside an
|
||||
// elif condition is no longer dropped.
|
||||
let guard = push_node(
|
||||
g,
|
||||
StmtKind::If,
|
||||
alt,
|
||||
lang,
|
||||
code,
|
||||
enclosing_func,
|
||||
0,
|
||||
analysis_rules,
|
||||
);
|
||||
connect_all(g, &chain_preds, guard, chain_edge);
|
||||
|
||||
// True branch: the elif body (`consequence` for Python, `body` for
|
||||
// PHP / colon-block forms).
|
||||
let body = alt
|
||||
.child_by_field_name("consequence")
|
||||
.or_else(|| alt.child_by_field_name("body"));
|
||||
if let Some(b) = body {
|
||||
let body_first = NodeIndex::new(g.node_count());
|
||||
let body_exits = build_sub(
|
||||
b,
|
||||
&[guard],
|
||||
g,
|
||||
lang,
|
||||
code,
|
||||
summaries,
|
||||
file_path,
|
||||
enclosing_func,
|
||||
call_ordinal,
|
||||
analysis_rules,
|
||||
break_targets,
|
||||
continue_targets,
|
||||
throw_targets,
|
||||
bodies,
|
||||
next_body_id,
|
||||
current_body_id,
|
||||
);
|
||||
if body_first.index() < g.node_count() {
|
||||
connect_all(g, &[guard], body_first, EdgeKind::True);
|
||||
exits.extend(body_exits);
|
||||
} else if let Some(&first) = body_exits.first() {
|
||||
connect_all(g, &[guard], first, EdgeKind::True);
|
||||
exits.extend(body_exits);
|
||||
} else {
|
||||
// Empty body: the guard's True edge falls through.
|
||||
exits.push(guard);
|
||||
}
|
||||
} else {
|
||||
exits.push(guard);
|
||||
}
|
||||
|
||||
// False branch descends to the next alternative.
|
||||
chain_preds = vec![guard];
|
||||
chain_edge = EdgeKind::False;
|
||||
} else {
|
||||
// Terminal `else_clause` (or any non-guard block): lower its body and
|
||||
// end the chain. The else body field is `body` for both Python and
|
||||
// PHP `else_clause`; fall back to the clause node itself.
|
||||
let body = alt.child_by_field_name("body").unwrap_or(alt);
|
||||
let body_first = NodeIndex::new(g.node_count());
|
||||
let body_exits = build_sub(
|
||||
body,
|
||||
&chain_preds,
|
||||
g,
|
||||
lang,
|
||||
code,
|
||||
summaries,
|
||||
file_path,
|
||||
enclosing_func,
|
||||
call_ordinal,
|
||||
analysis_rules,
|
||||
break_targets,
|
||||
continue_targets,
|
||||
throw_targets,
|
||||
bodies,
|
||||
next_body_id,
|
||||
current_body_id,
|
||||
);
|
||||
if body_first.index() < g.node_count() {
|
||||
connect_all(g, &chain_preds, body_first, chain_edge);
|
||||
exits.extend(body_exits);
|
||||
} else if let Some(&first) = body_exits.first() {
|
||||
connect_all(g, &chain_preds, first, chain_edge);
|
||||
exits.extend(body_exits);
|
||||
} else {
|
||||
exits.extend(chain_preds.iter().copied());
|
||||
}
|
||||
// An else clause terminates the chain; nothing follows it.
|
||||
return exits;
|
||||
}
|
||||
}
|
||||
|
||||
// Chain ended with an elif (no terminal else): the last guard's False edge
|
||||
// is the fall-through path out of the whole if/elif construct. Materialise
|
||||
// it as a synthetic pass-through so the false edge has a concrete target,
|
||||
// mirroring the no-else branch in the `Kind::If` arm.
|
||||
let pass = g.add_node(NodeInfo {
|
||||
kind: StmtKind::Seq,
|
||||
ast: AstMeta {
|
||||
span: chain_preds
|
||||
.first()
|
||||
.map(|&n| g[n].ast.span)
|
||||
.unwrap_or((0, 0)),
|
||||
enclosing_func: enclosing_func.map(|s| s.to_string()),
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
connect_all(g, &chain_preds, pass, chain_edge);
|
||||
exits.push(pass);
|
||||
exits
|
||||
}
|
||||
|
||||
/// Lower a C-style `for` loop's increment/update clause onto the back-edge
|
||||
/// path. `back_sources` are the body/continue exits that would otherwise
|
||||
/// back-edge straight to the loop header. When an `update_subtree` exists it
|
||||
/// is lowered from those sources and its exits are returned, so callers
|
||||
/// back-edge the update's exits to the header instead — making increment-clause
|
||||
/// side effects (assignments, sanitizer calls) visible to taint analysis.
|
||||
/// Without an update clause the input sources are returned unchanged, so the
|
||||
/// CFG is bit-identical to the pre-fix behaviour.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn lower_loop_update<'a>(
|
||||
update_subtree: Option<Node<'a>>,
|
||||
back_sources: &[NodeIndex],
|
||||
g: &mut Cfg,
|
||||
lang: &str,
|
||||
code: &'a [u8],
|
||||
summaries: &mut FuncSummaries,
|
||||
file_path: &str,
|
||||
enclosing_func: Option<&str>,
|
||||
call_ordinal: &mut u32,
|
||||
analysis_rules: Option<&LangAnalysisRules>,
|
||||
break_targets: &mut Vec<NodeIndex>,
|
||||
continue_targets: &mut Vec<NodeIndex>,
|
||||
throw_targets: &mut Vec<NodeIndex>,
|
||||
bodies: &mut Vec<BodyCfg>,
|
||||
next_body_id: &mut u32,
|
||||
current_body_id: BodyId,
|
||||
) -> Vec<NodeIndex> {
|
||||
let Some(update) = update_subtree else {
|
||||
return back_sources.to_vec();
|
||||
};
|
||||
if back_sources.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let update_exits = build_sub(
|
||||
update,
|
||||
back_sources,
|
||||
g,
|
||||
lang,
|
||||
code,
|
||||
summaries,
|
||||
file_path,
|
||||
enclosing_func,
|
||||
call_ordinal,
|
||||
analysis_rules,
|
||||
break_targets,
|
||||
continue_targets,
|
||||
throw_targets,
|
||||
bodies,
|
||||
next_body_id,
|
||||
current_body_id,
|
||||
);
|
||||
// If the update produced no CFG nodes (e.g. it was pure trivia), preserve
|
||||
// the original back-edge sources so the loop still closes.
|
||||
if update_exits.is_empty() {
|
||||
back_sources.to_vec()
|
||||
} else {
|
||||
update_exits
|
||||
}
|
||||
}
|
||||
|
||||
// The recursive *work‑horse* that converts an AST node into a CFG slice.
|
||||
// Returns the set of *exit* nodes that need to be wired further.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
|
@ -5183,6 +5415,8 @@ pub(super) fn build_sub<'a>(
|
|||
lang,
|
||||
code,
|
||||
enclosing_func,
|
||||
call_ordinal,
|
||||
analysis_rules,
|
||||
)
|
||||
} else {
|
||||
// Single-node path (original behavior)
|
||||
|
|
@ -5214,14 +5448,27 @@ pub(super) fn build_sub<'a>(
|
|||
|
||||
// Locate then & else blocks using field-based lookup first,
|
||||
// then positional fallback (Rust uses positional blocks).
|
||||
let (then_block, else_block) = {
|
||||
//
|
||||
// `alternatives` collects *every* `alternative` field, not just the
|
||||
// first. In tree-sitter-python and tree-sitter-php an
|
||||
// `if/elif/.../else` (Python) or `if/elseif/.../else` (PHP) chain
|
||||
// produces several FLAT sibling `alternative` fields on one
|
||||
// `if_statement` (a list of `elif_clause`/`else_if_clause` nodes
|
||||
// optionally trailed by an `else_clause`). JS/TS/Rust/Go/Java/C
|
||||
// nest their `else if`, so they expose at most one `alternative`
|
||||
// (the nested if) and the list has length ≤ 1.
|
||||
let (then_block, else_block, alternatives) = {
|
||||
let field_then = ast
|
||||
.child_by_field_name("consequence")
|
||||
.or_else(|| ast.child_by_field_name("body"));
|
||||
let field_else = ast.child_by_field_name("alternative");
|
||||
let mut alt_cursor = ast.walk();
|
||||
let alternatives: Vec<Node> = ast
|
||||
.children_by_field_name("alternative", &mut alt_cursor)
|
||||
.collect();
|
||||
let field_else = alternatives.first().copied();
|
||||
|
||||
if field_then.is_some() || field_else.is_some() {
|
||||
(field_then, field_else)
|
||||
(field_then, field_else, alternatives)
|
||||
} else {
|
||||
// Fallback: positional block children (Rust `if_expression`)
|
||||
let mut cursor = ast.walk();
|
||||
|
|
@ -5229,10 +5476,22 @@ pub(super) fn build_sub<'a>(
|
|||
.children(&mut cursor)
|
||||
.filter(|n| lookup(lang, n.kind()) == Kind::Block)
|
||||
.collect();
|
||||
(blocks.first().copied(), blocks.get(1).copied())
|
||||
(blocks.first().copied(), blocks.get(1).copied(), Vec::new())
|
||||
}
|
||||
};
|
||||
|
||||
// A flat elif/elseif chain has 2+ `alternative` siblings, or a
|
||||
// single `alternative` that is itself an elif/else-if clause (the
|
||||
// `if a: .. elif b: ..` form with no trailing `else`, where the lone
|
||||
// alternative is an `elif_clause` rather than a nested if). In both
|
||||
// cases the default single-`else_block` lowering below would either
|
||||
// drop later siblings entirely or fail to treat the elif condition
|
||||
// as a branch guard, so route through the dedicated chain builder.
|
||||
let is_flat_elif_chain = alternatives.len() > 1
|
||||
|| alternatives
|
||||
.first()
|
||||
.is_some_and(|a| matches!(a.kind(), "elif_clause" | "else_if_clause"));
|
||||
|
||||
// THEN branch
|
||||
let then_first_node = NodeIndex::new(g.node_count());
|
||||
let then_exits = if let Some(b) = then_block {
|
||||
|
|
@ -5267,7 +5526,30 @@ pub(super) fn build_sub<'a>(
|
|||
|
||||
// ELSE branch
|
||||
let else_first_node = NodeIndex::new(g.node_count());
|
||||
let else_exits = if let Some(b) = else_block {
|
||||
let else_exits = if is_flat_elif_chain {
|
||||
// Flat elif/elseif chain: lower every alternative sibling as a
|
||||
// proper nested else-if so 2nd+ elif and trailing else clauses
|
||||
// are no longer dropped from the CFG.
|
||||
build_alternative_chain(
|
||||
&alternatives,
|
||||
else_preds,
|
||||
else_edge,
|
||||
g,
|
||||
lang,
|
||||
code,
|
||||
summaries,
|
||||
file_path,
|
||||
enclosing_func,
|
||||
call_ordinal,
|
||||
analysis_rules,
|
||||
break_targets,
|
||||
continue_targets,
|
||||
throw_targets,
|
||||
bodies,
|
||||
next_body_id,
|
||||
current_body_id,
|
||||
)
|
||||
} else if let Some(b) = else_block {
|
||||
let exits = build_sub(
|
||||
b,
|
||||
else_preds,
|
||||
|
|
@ -5380,6 +5662,61 @@ pub(super) fn build_sub<'a>(
|
|||
|
||||
// WHILE / FOR: classic loop with a back edge.
|
||||
Kind::While | Kind::For => {
|
||||
// C-style `for (init; cond; incr) body` loops (C/C++, JS/TS, Go
|
||||
// three-clause, PHP) attach `initializer`/`update` (or `increment`)
|
||||
// subtrees that the previous loop lowering ignored entirely — so a
|
||||
// taint source bound in the init (`for (cmd = getenv("X"); …)`) or a
|
||||
// side effect in the increment had no CFG node at all and was
|
||||
// invisible to taint analysis. Tree-sitter exposes these either as
|
||||
// direct fields on the loop node (C/C++/JS/TS/PHP) or nested under a
|
||||
// `for_clause` child (Go's three-clause form). The init runs once
|
||||
// before the header; its exits become the header's predecessors so
|
||||
// its defs flow into both the condition and the body.
|
||||
let clause_owner = ast
|
||||
.child_by_field_name("body")
|
||||
.is_none()
|
||||
.then(|| {
|
||||
let mut c = ast.walk();
|
||||
ast.children(&mut c).find(|n| n.kind() == "for_clause")
|
||||
})
|
||||
.flatten();
|
||||
let init_subtree = ast
|
||||
.child_by_field_name("initializer")
|
||||
.or_else(|| ast.child_by_field_name("initialize"))
|
||||
.or_else(|| clause_owner.and_then(|fc| fc.child_by_field_name("initializer")));
|
||||
let update_subtree = ast
|
||||
.child_by_field_name("update")
|
||||
.or_else(|| ast.child_by_field_name("increment"))
|
||||
.or_else(|| clause_owner.and_then(|fc| fc.child_by_field_name("update")));
|
||||
|
||||
// Lower the initializer (if any) from `preds`; its exits become the
|
||||
// header's predecessors. Empty / absent inits leave `preds`
|
||||
// bit-identical to the pre-fix behaviour.
|
||||
let init_exits_owned = init_subtree.map(|init| {
|
||||
build_sub(
|
||||
init,
|
||||
preds,
|
||||
g,
|
||||
lang,
|
||||
code,
|
||||
summaries,
|
||||
file_path,
|
||||
enclosing_func,
|
||||
call_ordinal,
|
||||
analysis_rules,
|
||||
break_targets,
|
||||
continue_targets,
|
||||
throw_targets,
|
||||
bodies,
|
||||
next_body_id,
|
||||
current_body_id,
|
||||
)
|
||||
});
|
||||
let header_preds: &[NodeIndex] = match &init_exits_owned {
|
||||
Some(exits) if !exits.is_empty() => exits.as_slice(),
|
||||
_ => preds,
|
||||
};
|
||||
|
||||
let header = push_node(
|
||||
g,
|
||||
StmtKind::Loop,
|
||||
|
|
@ -5390,7 +5727,7 @@ pub(super) fn build_sub<'a>(
|
|||
0,
|
||||
analysis_rules,
|
||||
);
|
||||
connect_all(g, preds, header, EdgeKind::Seq);
|
||||
connect_all(g, header_preds, header, EdgeKind::Seq);
|
||||
|
||||
// Check for short-circuit condition
|
||||
let cond_subtree = ast.child_by_field_name("condition");
|
||||
|
|
@ -5445,6 +5782,8 @@ pub(super) fn build_sub<'a>(
|
|||
lang,
|
||||
code,
|
||||
enclosing_func,
|
||||
call_ordinal,
|
||||
analysis_rules,
|
||||
);
|
||||
|
||||
// Wire body from true_exits
|
||||
|
|
@ -5472,13 +5811,33 @@ pub(super) fn build_sub<'a>(
|
|||
connect_all(g, &true_exits, body_first, EdgeKind::True);
|
||||
}
|
||||
|
||||
// The increment runs at the end of each iteration before the
|
||||
// condition is re-checked, so it sits on the back-edge path
|
||||
// between the body/continue exits and the header.
|
||||
let mut back_sources: Vec<NodeIndex> = body_exits;
|
||||
back_sources.extend(loop_continues.iter().copied());
|
||||
let back_sources = lower_loop_update(
|
||||
update_subtree,
|
||||
&back_sources,
|
||||
g,
|
||||
lang,
|
||||
code,
|
||||
summaries,
|
||||
file_path,
|
||||
enclosing_func,
|
||||
call_ordinal,
|
||||
analysis_rules,
|
||||
break_targets,
|
||||
continue_targets,
|
||||
throw_targets,
|
||||
bodies,
|
||||
next_body_id,
|
||||
current_body_id,
|
||||
);
|
||||
// Back-edges go to header (not into the condition chain)
|
||||
for &e in &body_exits {
|
||||
for &e in &back_sources {
|
||||
connect_all(g, &[e], header, EdgeKind::Back);
|
||||
}
|
||||
for &c in &loop_continues {
|
||||
connect_all(g, &[c], header, EdgeKind::Back);
|
||||
}
|
||||
|
||||
// Loop exits = false_exits + breaks
|
||||
let mut exits: Vec<NodeIndex> = false_exits;
|
||||
|
|
@ -5504,14 +5863,32 @@ pub(super) fn build_sub<'a>(
|
|||
current_body_id,
|
||||
);
|
||||
|
||||
// The increment runs on the back-edge path (end of each
|
||||
// iteration, before the next condition check).
|
||||
let mut back_sources: Vec<NodeIndex> = body_exits;
|
||||
back_sources.extend(loop_continues.iter().copied());
|
||||
let back_sources = lower_loop_update(
|
||||
update_subtree,
|
||||
&back_sources,
|
||||
g,
|
||||
lang,
|
||||
code,
|
||||
summaries,
|
||||
file_path,
|
||||
enclosing_func,
|
||||
call_ordinal,
|
||||
analysis_rules,
|
||||
break_targets,
|
||||
continue_targets,
|
||||
throw_targets,
|
||||
bodies,
|
||||
next_body_id,
|
||||
current_body_id,
|
||||
);
|
||||
// Back‑edge for every linear exit → header.
|
||||
for &e in &body_exits {
|
||||
for &e in &back_sources {
|
||||
connect_all(g, &[e], header, EdgeKind::Back);
|
||||
}
|
||||
// Wire continue targets as back edges to header
|
||||
for &c in &loop_continues {
|
||||
connect_all(g, &[c], header, EdgeKind::Back);
|
||||
}
|
||||
// Falling out of the loop = header’s false branch +
|
||||
// any break targets that exit the loop.
|
||||
let mut exits = vec![header];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue