Skip to main content

forge_lint/sol/high/
enumerable_loop_removal.rs

1use super::EnumerableLoopRemoval;
2use crate::{
3    linter::{LateLintPass, LintContext},
4    sol::{
5        Severity, SolLint,
6        analysis::{branch_always_exits, loop_update, write_target},
7    },
8};
9use alloy_primitives::U256;
10use solar::{
11    ast::{LitKind, UnOpKind},
12    interface::Symbol,
13    sema::{
14        Gcx,
15        hir::{
16            self, BinOpKind, Expr, ExprKind, Hir, LoopSource, Stmt, StmtKind, VarKind, VariableId,
17            Visit,
18        },
19    },
20};
21use std::{convert::Infallible, ops::ControlFlow};
22
23declare_forge_lint!(
24    ENUMERABLE_LOOP_REMOVAL,
25    Severity::High,
26    "enumerable-loop-removal",
27    "`remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration"
28);
29
30// The detector reports only the shape it can judge without a flow analysis: a loop whose own
31// index is written exclusively by simple unconditional increments, reads the set with `at` at
32// that bare index, and removes from the same set in a straight-line body. Other shapes are
33// deliberately unreported even when they corrupt iteration; set operands that cannot be
34// identified statically are conservatively treated as possible aliases.
35
36impl<'gcx> LateLintPass<'gcx> for EnumerableLoopRemoval {
37    fn check_function(
38        &mut self,
39        ctx: &LintContext,
40        gcx: Gcx<'gcx>,
41        func: &'gcx hir::Function<'gcx>,
42    ) {
43        if let Some(body) = func.body {
44            LoopFinder { gcx, ctx, bindings: Vec::new() }.walk_body(body.stmts);
45        }
46    }
47}
48
49/// Walks a function body in statement order and, for each loop, flags the EnumerableSet `remove`
50/// calls that corrupt that loop's own iteration. The walk keeps, at every point, what each local
51/// `storage` reference last named, so each loop is judged against the bindings standing where it
52/// runs rather than against every binding of the function.
53struct LoopFinder<'ctx, 's, 'c, 'gcx> {
54    gcx: Gcx<'gcx>,
55    ctx: &'ctx LintContext<'s, 'c>,
56    /// What each local `storage` reference names where the walk stands, the latest entry
57    /// winning; `None` once a write leaves it without one answer (a conditional branch, a loop
58    /// body, or an unreadable shape).
59    bindings: Vec<(VariableId, Option<SetPath>)>,
60}
61
62impl<'gcx> LoopFinder<'_, '_, '_, 'gcx> {
63    fn walk_body(&mut self, stmts: impl IntoIterator<Item = &'gcx Stmt<'gcx>>) {
64        for stmt in stmts {
65            self.walk_stmt(stmt);
66        }
67    }
68
69    fn walk_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) {
70        // A `for` desugars to `Block { init; Loop(For) }`; its index lives partly in the init,
71        // which runs once, on the straight line entering the loop.
72        if let StmtKind::Block(block) = &stmt.kind
73            && let Some((last, init)) = block.stmts.split_last()
74            && let StmtKind::Loop(body, source @ LoopSource::For { .. }) = &last.kind
75        {
76            self.walk_body(init);
77            return self.enter_loop(init, body.stmts, loop_update(*source));
78        }
79        match &stmt.kind {
80            // A bare block runs on the straight line: what it binds stays bound past it.
81            StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => self.walk_body(block.stmts),
82            StmtKind::Loop(body, source) => self.enter_loop(&[], body.stmts, loop_update(*source)),
83            // Which branch ran is not tracked: everything the statement writes stops naming one
84            // thing, and what a branch binds for its own statements ends with the branch.
85            StmtKind::If(_, then, else_) => {
86                self.poison_writes(std::slice::from_ref(stmt));
87                let mark = self.bindings.len();
88                self.walk_stmt(then);
89                self.bindings.truncate(mark);
90                if let Some(else_) = else_ {
91                    self.walk_stmt(else_);
92                    self.bindings.truncate(mark);
93                }
94            }
95            StmtKind::Try(try_) => {
96                self.poison_writes(std::slice::from_ref(stmt));
97                let mark = self.bindings.len();
98                for clause in try_.clauses {
99                    self.walk_body(clause.block.stmts);
100                    self.bindings.truncate(mark);
101                }
102            }
103            _ => self.apply_bindings(stmt),
104        }
105    }
106
107    /// Analyzes one loop, then walks inside it for the nested ones. A write anywhere in the loop
108    /// may have run on an earlier turn by the time any of its statements runs again, so
109    /// everything the loop writes, init included, stops naming one thing before the loop is
110    /// judged, and stays so past it.
111    fn enter_loop(
112        &mut self,
113        init: &'gcx [Stmt<'gcx>],
114        body: &'gcx [Stmt<'gcx>],
115        update: Option<&'gcx Stmt<'gcx>>,
116    ) {
117        self.poison_writes(init);
118        self.poison_writes(body.iter().chain(update));
119        self.analyze_loop(user_body(body).iter().chain(update));
120        let mark = self.bindings.len();
121        self.walk_body(body.iter().chain(update));
122        self.bindings.truncate(mark);
123    }
124
125    /// Applies one straight-line statement to the bindings: everything it writes stops naming
126    /// one thing, then a declaration or plain assignment binds its reference to what the
127    /// right-hand side names right here (resolved eagerly, so a later write to a reference the
128    /// right-hand side reads does not reach back into this binding).
129    fn apply_bindings(&mut self, stmt: &'gcx Stmt<'gcx>) {
130        self.poison_writes(std::slice::from_ref(stmt));
131        let bindings = &mut self.bindings;
132        let mut bind = |var: VariableId, value: &Expr<'_>| {
133            let path = set_path(self.gcx, value, bindings, &mut Vec::new());
134            bindings.push((var, path));
135        };
136        match &stmt.kind {
137            StmtKind::DeclSingle(var) => {
138                if let Some(init) = self.gcx.hir.variable(*var).initializer {
139                    bind(*var, init);
140                }
141            }
142            StmtKind::Expr(expr) => {
143                if let ExprKind::Assign(target, None, value) = &expr.peel_parens().kind
144                    && let ExprKind::Ident(_) = &target.peel_parens().kind
145                    && let Some(var) = self.gcx.resolved_variable(target)
146                {
147                    bind(var, value);
148                }
149            }
150            _ => {}
151        }
152    }
153
154    /// Marks everything the statements write as no longer naming one thing.
155    fn poison_writes(&mut self, stmts: impl IntoIterator<Item = &'gcx Stmt<'gcx>>) {
156        let mut written = Vec::new();
157        collect_writes(self.gcx, stmts, &mut written);
158        self.bindings.extend(written.into_iter().map(|var| (var, None)));
159    }
160
161    /// Flags the removals in a straight-line loop body that remove from a set the loop reads with
162    /// `at` at an unconditional ascending cadence.
163    fn analyze_loop(&mut self, body: impl Iterator<Item = &'gcx Stmt<'gcx>> + Clone) {
164        // Control flow would make the corruption depend on the path taken, which is not tracked;
165        // without an ascending index there is no upward walk for swap-and-pop to disturb.
166        if !body_is_straight_line(self.gcx, body.clone()) {
167            return;
168        }
169        let cadence = ascending_cadence(self.gcx, body.clone());
170        if cadence.is_empty() {
171            return;
172        }
173        let (mut iterated, mut removes) = (Vec::new(), Vec::new());
174        let mut calls = ExprWalker {
175            hir: &self.gcx.hir,
176            prune_unreachable: true,
177            f: |expr: &'gcx Expr<'gcx>| {
178                let Some(call) = enumerable_set_call(self.gcx, &self.bindings, expr) else {
179                    return;
180                };
181                match call.op {
182                    SetOp::At => {
183                        if call
184                            .index
185                            .and_then(Expr::as_variable)
186                            .is_some_and(|i| cadence.contains(&i))
187                        {
188                            iterated.push(call.set);
189                        }
190                    }
191                    SetOp::Remove => removes.push((call.set, expr.span)),
192                }
193            },
194        };
195        for stmt in body {
196            let _ = calls.visit_stmt(stmt);
197        }
198        for (removed, span) in removes {
199            // Two readable paths name the same set exactly when equal; an unreadable one may.
200            let corrupts = iterated.iter().any(|iterated| {
201                removed.as_ref().zip(iterated.as_ref()).is_none_or(|(a, b)| a == b)
202            });
203            if corrupts {
204                self.ctx.emit(&ENUMERABLE_LOOP_REMOVAL, span);
205            }
206        }
207    }
208}
209
210/// Calls `f` on every expression under the visited statements. With `prune_unreachable`, the
211/// arms of `&&`/`||`/`?:` that a literal boolean condition proves unreachable are skipped.
212struct ExprWalker<'gcx, F> {
213    hir: &'gcx Hir<'gcx>,
214    prune_unreachable: bool,
215    f: F,
216}
217
218impl<'gcx, F: FnMut(&'gcx Expr<'gcx>)> Visit<'gcx> for ExprWalker<'gcx, F> {
219    type BreakValue = Infallible;
220
221    fn hir(&self) -> &'gcx Hir<'gcx> {
222        self.hir
223    }
224
225    fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow<Infallible> {
226        (self.f)(expr);
227        if !self.prune_unreachable {
228            return self.walk_expr(expr);
229        }
230        match &expr.kind {
231            ExprKind::Binary(left, op, right)
232                if matches!(op.kind, BinOpKind::And | BinOpKind::Or) =>
233            {
234                self.visit_expr(left)?;
235                let short_circuits = matches!(
236                    (op.kind, literal_bool(left)),
237                    (BinOpKind::And, Some(false)) | (BinOpKind::Or, Some(true))
238                );
239                if !short_circuits {
240                    self.visit_expr(right)?;
241                }
242                ControlFlow::Continue(())
243            }
244            ExprKind::Ternary(condition, true_expr, false_expr) => {
245                self.visit_expr(condition)?;
246                match literal_bool(condition) {
247                    Some(true) => self.visit_expr(true_expr),
248                    Some(false) => self.visit_expr(false_expr),
249                    None => {
250                        self.visit_expr(true_expr)?;
251                        self.visit_expr(false_expr)
252                    }
253                }
254            }
255            _ => self.walk_expr(expr),
256        }
257    }
258}
259
260/// The user-written body of a loop, peeled out of the synthetic condition guard the lowering
261/// wraps it in: `for`/`while` become a single `if (cond) { body } else break`, `do-while` appends
262/// `if (cond) continue; else break;`. Without peeling, the guard's `break`/`continue` would read
263/// as user control flow. A body of another shape is returned unchanged.
264fn user_body<'gcx>(body: &'gcx [Stmt<'gcx>]) -> &'gcx [Stmt<'gcx>] {
265    let is_break = |stmt: &Stmt<'_>| matches!(stmt.kind, StmtKind::Break);
266    match body {
267        [only] => match &only.kind {
268            StmtKind::If(_, then, Some(else_)) if is_break(else_) => std::slice::from_ref(*then),
269            _ => body,
270        },
271        [rest @ .., last] => match &last.kind {
272            StmtKind::If(_, then, Some(else_))
273                if matches!(then.kind, StmtKind::Continue) && is_break(else_) =>
274            {
275                rest
276            }
277            _ => body,
278        },
279        [] => body,
280    }
281}
282
283/// Whether every statement of a loop body runs on one straight line: no branch, jump, terminal
284/// statement, inline assembly or nested loop (bare blocks are transparent). Any of these could
285/// let control skip a removal or the cadence step, or leave the loop before a shifted slot is
286/// read, none of which this detector tracks.
287fn body_is_straight_line<'gcx>(
288    gcx: Gcx<'_>,
289    stmts: impl IntoIterator<Item = &'gcx Stmt<'gcx>>,
290) -> bool {
291    stmts.into_iter().all(|stmt| {
292        !branch_always_exits(gcx, stmt)
293            && match &stmt.kind {
294                StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => {
295                    body_is_straight_line(gcx, block.stmts)
296                }
297                StmtKind::If(..)
298                | StmtKind::Try(..)
299                | StmtKind::Loop(..)
300                | StmtKind::AssemblyBlock(..)
301                | StmtKind::Break
302                | StmtKind::Continue => false,
303                _ => true,
304            }
305    })
306}
307
308/// The loop's own indices that step upward unconditionally: bare identifiers whose every write
309/// on the straight line of the body (bare blocks included) is a supported ascending step. A
310/// reset, a no-op step, a decrement or composite arithmetic disqualifies the variable.
311fn ascending_cadence<'gcx>(
312    gcx: Gcx<'gcx>,
313    body: impl IntoIterator<Item = &'gcx Stmt<'gcx>>,
314) -> Vec<VariableId> {
315    let (mut cadence, mut other_writes) = (Vec::new(), Vec::new());
316    collect_cadence_writes(gcx, body, &mut cadence, &mut other_writes);
317    cadence.retain(|var| !other_writes.contains(var));
318    cadence
319}
320
321fn collect_cadence_writes<'gcx>(
322    gcx: Gcx<'gcx>,
323    stmts: impl IntoIterator<Item = &'gcx Stmt<'gcx>>,
324    cadence: &mut Vec<VariableId>,
325    other_writes: &mut Vec<VariableId>,
326) {
327    for stmt in stmts {
328        let mut written = match &stmt.kind {
329            StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => {
330                collect_cadence_writes(gcx, block.stmts, cadence, other_writes);
331                continue;
332            }
333            StmtKind::DeclSingle(var) => vec![*var],
334            StmtKind::DeclMulti(vars, _) => vars.iter().flatten().copied().collect(),
335            _ => Vec::new(),
336        };
337        collect_writes(gcx, std::slice::from_ref(stmt), &mut written);
338        let ascending = match &stmt.kind {
339            StmtKind::Expr(expr) => ascending_step(gcx, expr.peel_parens()),
340            _ => None,
341        };
342        for var in written {
343            if ascending != Some(var) {
344                other_writes.push(var);
345            } else if !cadence.contains(&var) {
346                cadence.push(var);
347            }
348        }
349    }
350}
351
352/// The bare identifier an expression steps upward by one of the simple ascending forms:
353/// `i++`/`++i`, `i += <positive literal>`, `i = i + <positive literal>` or its commutation.
354fn ascending_step<'gcx>(gcx: Gcx<'gcx>, expr: &'gcx Expr<'gcx>) -> Option<VariableId> {
355    let variable = |expr: &Expr<'_>| {
356        gcx.resolved_variable(expr)
357            .filter(|_| matches!(expr.peel_parens().kind, ExprKind::Ident(_)))
358    };
359    match &expr.kind {
360        ExprKind::Unary(op, operand) if matches!(op.kind, UnOpKind::PreInc | UnOpKind::PostInc) => {
361            variable(operand)
362        }
363        ExprKind::Assign(lhs, Some(op), rhs)
364            if op.kind == BinOpKind::Add && is_positive_literal(rhs) =>
365        {
366            variable(lhs)
367        }
368        ExprKind::Assign(lhs, None, rhs) => {
369            let target = variable(lhs)?;
370            let ExprKind::Binary(left, op, right) = &rhs.peel_parens().kind else { return None };
371            (op.kind == BinOpKind::Add
372                && ((variable(left) == Some(target) && is_positive_literal(right))
373                    || (is_positive_literal(left) && variable(right) == Some(target))))
374            .then_some(target)
375        }
376        _ => None,
377    }
378}
379
380fn is_positive_literal(expr: &Expr<'_>) -> bool {
381    matches!(&expr.peel_parens().kind, ExprKind::Lit(lit)
382        if matches!(&lit.kind, LitKind::Number(value) if !value.is_zero()))
383}
384
385fn literal_bool(expr: &Expr<'_>) -> Option<bool> {
386    match &expr.peel_parens().kind {
387        ExprKind::Lit(lit) => match lit.kind {
388            LitKind::Bool(value) => Some(value),
389            _ => None,
390        },
391        _ => None,
392    }
393}
394
395/// The variables a statement list writes through expressions, nested loops included:
396/// assignments (tuple targets included), increments, decrements and deletes. Member and indexed
397/// targets do not write their base variable.
398fn collect_writes<'gcx>(
399    gcx: Gcx<'gcx>,
400    stmts: impl IntoIterator<Item = &'gcx Stmt<'gcx>>,
401    out: &mut Vec<VariableId>,
402) {
403    fn lvalue_variables(gcx: Gcx<'_>, expr: &Expr<'_>, out: &mut Vec<VariableId>) {
404        match &expr.peel_parens().kind {
405            ExprKind::Ident(_) => out.extend(gcx.resolved_variable(expr)),
406            ExprKind::Tuple(exprs) => {
407                exprs.iter().flatten().for_each(|expr| lvalue_variables(gcx, expr, out));
408            }
409            _ => {}
410        }
411    }
412    let mut writes = ExprWalker {
413        hir: &gcx.hir,
414        prune_unreachable: false,
415        f: |expr: &Expr<'_>| {
416            if let Some(target) = write_target(expr) {
417                lvalue_variables(gcx, target, out)
418            }
419        },
420    };
421    for stmt in stmts {
422        let _ = writes.visit_stmt(stmt);
423    }
424}
425
426#[derive(PartialEq, Eq, Clone, Copy)]
427enum SetOp {
428    At,
429    Remove,
430}
431
432/// A resolved EnumerableSet call.
433struct SetCall<'gcx> {
434    op: SetOp,
435    set: Option<SetPath>,
436    /// The `index` argument of `at`.
437    index: Option<&'gcx Expr<'gcx>>,
438}
439
440/// The EnumerableSet `at` or `remove` a call dispatches to. Resolving through the type checker
441/// covers the `using for` method form, the library-qualified form and import aliases. The library
442/// is identified only by its kind and exact `EnumerableSet` name, not its source or behavior.
443fn enumerable_set_call<'gcx>(
444    gcx: Gcx<'gcx>,
445    bindings: &Bindings,
446    expr: &'gcx Expr<'gcx>,
447) -> Option<SetCall<'gcx>> {
448    let ExprKind::Call(callee, ..) = &expr.kind else { return None };
449    let function_id = gcx.resolved_function(callee)?;
450    let function = gcx.hir.function(function_id);
451    let contract = gcx.hir.contract(function.contract?);
452    if !contract.kind.is_library() || contract.name.as_str() != "EnumerableSet" {
453        return None;
454    }
455    let op = match function.name?.as_str() {
456        "at" => SetOp::At,
457        "remove" => SetOp::Remove,
458        _ => return None,
459    };
460    // The set operand is the bound receiver in the method form and the first argument in the
461    // library-qualified form; the index of `at` sits right after it.
462    let (set_expr, index_arg) = match &callee.peel_parens().kind {
463        ExprKind::Member(receiver, _)
464            if gcx.resolved_call(expr).is_some_and(|resolved| resolved.attached) =>
465        {
466            (Some(&**receiver), 0)
467        }
468        _ => (gcx.call_arg(expr, 0), 1),
469    };
470    Some(SetCall {
471        op,
472        set: set_expr.and_then(|expr| set_path(gcx, expr, bindings, &mut Vec::new())),
473        index: gcx.call_arg(expr, index_arg),
474    })
475}
476
477/// One step of the storage path naming a set: a struct field or a literal mapping key.
478#[derive(PartialEq, Eq, Clone, Copy)]
479enum Step {
480    Field(Symbol),
481    Key(U256),
482}
483
484/// The storage location a set expression names: a base variable and the steps taken from it.
485/// Two expressions name the same set exactly when they are the same path.
486#[derive(PartialEq, Eq, Clone)]
487struct SetPath {
488    base: VariableId,
489    steps: Vec<Step>,
490}
491
492/// What each local `storage` reference names at the point being analyzed, the latest entry
493/// winning; `None` marks a reference no straight-line reading gives one answer for.
494type Bindings = [(VariableId, Option<SetPath>)];
495
496/// The path a set expression names, or `None` when it cannot be read: an index that varies, a
497/// call result, a reference without one straight-line binding, anything the analysis would have
498/// to evaluate.
499fn set_path(
500    gcx: Gcx<'_>,
501    expr: &Expr<'_>,
502    bindings: &Bindings,
503    seen: &mut Vec<VariableId>,
504) -> Option<SetPath> {
505    match &expr.peel_parens().kind {
506        ExprKind::Ident(_) => {
507            let var = gcx.resolved_variable(expr)?;
508            if seen.contains(&var) {
509                return None;
510            }
511            seen.push(var);
512            let variable = gcx.hir.variable(var);
513            if !matches!(variable.kind, VarKind::Statement) {
514                return Some(SetPath { base: var, steps: Vec::new() });
515            }
516            // A local `storage` reference is another name for the set its last binding gave it.
517            // One declared inside the analyzed loop has no entry and is bound by its initializer
518            // anew each turn; a tuple-destructured one has neither and may name any set.
519            match bindings.iter().rev().find(|(bound, _)| *bound == var) {
520                Some((_, binding)) => binding.clone(),
521                None => set_path(gcx, variable.initializer?, bindings, seen),
522            }
523        }
524        ExprKind::Member(base, field) => {
525            let mut path = set_path(gcx, base, bindings, seen)?;
526            path.steps.push(Step::Field(field.name));
527            Some(path)
528        }
529        ExprKind::Index(base, Some(index)) => {
530            let ExprKind::Lit(lit) = &index.peel_parens().kind else { return None };
531            let LitKind::Number(key) = &lit.kind else { return None };
532            let mut path = set_path(gcx, base, bindings, seen)?;
533            path.steps.push(Step::Key(*key));
534            Some(path)
535        }
536        _ => None,
537    }
538}