Skip to main content

forge_lint/sol/low/
incorrect_modifier.rs

1use super::IncorrectModifier;
2use crate::{
3    linter::{LateLintPass, LintContext},
4    sol::{Severity, SolLint},
5};
6use solar::{
7    ast,
8    sema::{
9        Gcx, Hir,
10        builtins::Builtin,
11        hir::{Block, Expr, ExprKind, Function, LoopSource, Res, Stmt, StmtKind},
12    },
13};
14
15declare_forge_lint!(
16    INCORRECT_MODIFIER,
17    Severity::Low,
18    "incorrect-modifier",
19    "modifier can finish without executing the modified function"
20);
21
22impl<'hir> LateLintPass<'hir> for IncorrectModifier {
23    fn check_function(
24        &mut self,
25        ctx: &LintContext,
26        _gcx: Gcx<'hir>,
27        _hir: &'hir Hir<'hir>,
28        func: &'hir Function<'hir>,
29    ) {
30        let (ast::FunctionKind::Modifier, Some(body)) = (func.kind, func.body) else {
31            return;
32        };
33
34        if block_outcome(body).can_skip_placeholder() {
35            ctx.emit(&INCORRECT_MODIFIER, func.span);
36        }
37    }
38}
39
40/// Summary of how control flow can leave a statement or block *without* having executed the
41/// placeholder (`_`) or reverted.
42///
43/// Each flag tracks whether there is at least one such path. If every path reaches `_` or reverts,
44/// all flags are `false` ([`Outcome::COVERED`]).
45#[derive(Clone, Copy)]
46pub(crate) struct Outcome {
47    /// Control can reach the end of the construct normally and continue to the next statement.
48    falls_through: bool,
49    /// Control can exit the modifier via `return` before reaching `_`.
50    returns: bool,
51    /// Control can exit the enclosing loop via `break` before reaching `_`.
52    breaks: bool,
53    /// Control can jump to the enclosing loop's next iteration via `continue` before reaching `_`.
54    continues: bool,
55}
56
57impl Outcome {
58    /// Every path reaches `_` or reverts.
59    const COVERED: Self =
60        Self { falls_through: false, returns: false, breaks: false, continues: false };
61    const FALLTHROUGH: Self = Self { falls_through: true, ..Self::COVERED };
62    const RETURNS: Self = Self { returns: true, ..Self::COVERED };
63    const BREAKS: Self = Self { breaks: true, ..Self::COVERED };
64    const CONTINUES: Self = Self { continues: true, ..Self::COVERED };
65
66    /// Whether the modifier body can finish without executing `_`. Only fall-through and `return`
67    /// reach the modifier's end; `break`/`continue` are always consumed by an enclosing loop.
68    pub(crate) const fn can_skip_placeholder(self) -> bool {
69        self.falls_through || self.returns
70    }
71
72    const fn merge(self, other: Self) -> Self {
73        Self {
74            falls_through: self.falls_through || other.falls_through,
75            returns: self.returns || other.returns,
76            breaks: self.breaks || other.breaks,
77            continues: self.continues || other.continues,
78        }
79    }
80}
81
82pub(crate) fn block_outcome(block: Block<'_>) -> Outcome {
83    let mut outcome = Outcome::FALLTHROUGH;
84    for stmt in block.stmts {
85        // Once a statement cannot fall through, the rest of the block is unreachable.
86        if !outcome.falls_through {
87            return outcome;
88        }
89        let stmt_outcome = stmt_outcome(stmt);
90        outcome = Outcome {
91            falls_through: stmt_outcome.falls_through,
92            returns: outcome.returns || stmt_outcome.returns,
93            breaks: outcome.breaks || stmt_outcome.breaks,
94            continues: outcome.continues || stmt_outcome.continues,
95        };
96    }
97    outcome
98}
99
100fn stmt_outcome(stmt: &Stmt<'_>) -> Outcome {
101    match &stmt.kind {
102        StmtKind::Placeholder => Outcome::COVERED,
103        StmtKind::Return(_) => Outcome::RETURNS,
104        StmtKind::Break => Outcome::BREAKS,
105        StmtKind::Continue => Outcome::CONTINUES,
106        StmtKind::Expr(expr) => call_outcome(expr).unwrap_or(Outcome::FALLTHROUGH),
107        StmtKind::Revert(_) => Outcome::COVERED,
108        StmtKind::Block(block)
109        | StmtKind::UncheckedBlock(block)
110        | StmtKind::AssemblyBlock(block) => block_outcome(*block),
111        StmtKind::If(_, then_stmt, else_stmt) => {
112            let then_outcome = stmt_outcome(then_stmt);
113            let else_outcome = else_stmt.map_or(Outcome::FALLTHROUGH, stmt_outcome);
114            then_outcome.merge(else_outcome)
115        }
116        StmtKind::Loop(block, source) => {
117            // `for`/`while`/`do-while` are all desugared to a `Loop` whose body holds the condition
118            // as a synthetic `else break`. The loop can be left (and thus fall through to the
119            // following statement) via a `break`, including that synthetic condition break; a loop
120            // without any `break` (e.g. `for (;;)`) never falls through. For `do-while` the
121            // condition sits *after* the body, so a `continue` in the body also reaches it and can
122            // exit the loop. `break`/`continue` are otherwise consumed by the loop; only `return`
123            // keeps escaping toward the modifier's end.
124            let body = block_outcome(*block);
125            let falls_through =
126                body.breaks || (matches!(source, LoopSource::DoWhile) && body.continues);
127            Outcome { falls_through, returns: body.returns, ..Outcome::COVERED }
128        }
129        StmtKind::Try(try_stmt) => {
130            // Every execution enters exactly one clause (the `returns` clause on success or a
131            // matching `catch`), or the call reverts uncaught. There is no implicit fall-through
132            // path that skips all clauses, so start from `COVERED`.
133            let mut outcome = Outcome::COVERED;
134            for clause in try_stmt.clauses {
135                outcome = outcome.merge(block_outcome(clause.block));
136            }
137            outcome
138        }
139        StmtKind::Switch(switch) => {
140            // A Yul `switch` value that matches no `case` falls through unless a `default` clause
141            // is present (stored last, with no constant).
142            let has_default = switch.cases.last().is_some_and(|case| case.constant.is_none());
143            let mut outcome = if has_default { Outcome::COVERED } else { Outcome::FALLTHROUGH };
144            for case in switch.cases {
145                outcome = outcome.merge(block_outcome(case.body));
146            }
147            outcome
148        }
149        StmtKind::DeclSingle(_)
150        | StmtKind::DeclMulti(_, _)
151        | StmtKind::Emit(_)
152        | StmtKind::Err(_) => Outcome::FALLTHROUGH,
153    }
154}
155
156/// Classifies a statement-level call expression that terminates the current path before reaching
157/// `_`, if any. Covers both the Solidity `revert`/`revert(...)` builtins and the Yul halting
158/// builtins reachable when recursing into an `assembly { .. }` block.
159///
160/// - Failing halts (`revert`, Yul `revert`/`invalid`) leave every path either reverting or reaching
161///   `_`, so they are [`Outcome::COVERED`] (not flagged).
162/// - Successful halts (Yul `return`/`stop`, `selfdestruct`) let the surrounding call finish
163///   *without* running the modified function body, which is exactly what this lint flags, so they
164///   behave like a `return` ([`Outcome::RETURNS`]).
165fn call_outcome(expr: &Expr<'_>) -> Option<Outcome> {
166    let ExprKind::Call(callee, args, _) = &expr.peel_parens().kind else { return None };
167    let ExprKind::Ident(resolutions) = &callee.peel_parens().kind else { return None };
168    resolutions.iter().find_map(|res| match res {
169        Res::Builtin(
170            Builtin::Revert | Builtin::RevertMsg | Builtin::YulRevert | Builtin::YulInvalid,
171        ) => Some(Outcome::COVERED),
172        Res::Builtin(Builtin::Require | Builtin::Assert)
173            if args.exprs().next().is_some_and(is_literal_false) =>
174        {
175            Some(Outcome::COVERED)
176        }
177        Res::Builtin(
178            Builtin::YulReturn
179            | Builtin::YulStop
180            | Builtin::YulSelfdestruct
181            | Builtin::Selfdestruct,
182        ) => Some(Outcome::RETURNS),
183        _ => None,
184    })
185}
186
187fn is_literal_false(expr: &Expr<'_>) -> bool {
188    matches!(
189        &expr.peel_parens().kind,
190        ExprKind::Lit(lit) if matches!(lit.kind, ast::LitKind::Bool(false))
191    )
192}