Skip to main content

forge_lint/sol/low/
deprecated_oz_function.rs

1use super::DeprecatedOzFunction;
2use crate::{
3    linter::{LateLintPass, LintContext},
4    sol::{Severity, SolLint},
5};
6use solar::{
7    interface::source_map::FileName,
8    sema::{
9        Gcx,
10        hir::{self, Expr, ExprKind, FunctionId, Hir},
11        ty::TyKind,
12    },
13};
14
15declare_forge_lint!(
16    DEPRECATED_OZ_FUNCTION,
17    Severity::Low,
18    "deprecated-oz-function",
19    "OpenZeppelin deprecated this function: `_grantRole` replaces `_setupRole`, `safeIncreaseAllowance` / `safeDecreaseAllowance` replace `safeApprove`"
20);
21
22impl<'hir> LateLintPass<'hir> for DeprecatedOzFunction {
23    fn check_expr(
24        &mut self,
25        ctx: &LintContext,
26        gcx: Gcx<'hir>,
27        hir: &'hir Hir<'hir>,
28        expr: &'hir Expr<'hir>,
29    ) {
30        // A name or member expression typed as a function is a resolved reference, called or
31        // used as a value: judge the single declaration the type checker selected. The linter
32        // visits every expression in the unit, so a reference in a function header (a modifier
33        // or base-constructor argument) is caught too, not only one inside a function body.
34        if matches!(expr.kind, ExprKind::Ident(..) | ExprKind::Member(..)) {
35            let resolver = DeprecatedRefResolver { gcx, hir };
36            if let Some(function_id) = resolver.resolved_function(expr)
37                && resolver.is_deprecated_oz(function_id)
38            {
39                ctx.emit(&DEPRECATED_OZ_FUNCTION, expr.span);
40            }
41        }
42    }
43}
44
45/// Resolves an expression to the deprecated OpenZeppelin function it references, if any.
46struct DeprecatedRefResolver<'hir> {
47    gcx: Gcx<'hir>,
48    hir: &'hir Hir<'hir>,
49}
50
51impl DeprecatedRefResolver<'_> {
52    /// The single function an expression resolves to, for a callee or a reference used as a
53    /// value. `type_of_expr` is the function the type checker resolved, so overload selection,
54    /// override shadowing, `super.`, the qualified and `using for` forms and import aliases
55    /// are already accounted for.
56    fn resolved_function(&self, expr: &Expr<'_>) -> Option<FunctionId> {
57        let ty = self.gcx.type_of_expr(expr.peel_parens().id)?;
58        match ty.kind {
59            TyKind::Fn(function_ty) => function_ty.function_id,
60            _ => None,
61        }
62    }
63
64    /// Whether `function_id` is one of the functions OpenZeppelin deprecated, identified by
65    /// the exact name of the declaring contract or library: `SafeERC20.safeApprove` and
66    /// `AccessControl._setupRole` (plus their upgradeable variants). Extensions inherit these
67    /// functions rather than redeclare them, so resolution still lands on the canonical
68    /// declaration; a same-name function of an unrelated contract or library stays out, and
69    /// so does a same-name local declaration, which fails the provenance check.
70    fn is_deprecated_oz(&self, function_id: FunctionId) -> bool {
71        let function = self.hir.function(function_id);
72        let Some(name) = function.name else { return false };
73        let Some(contract_id) = function.contract else { return false };
74        // The name alone does not prove the declaration is OpenZeppelin's: a local library
75        // or contract may share it. The declaring source must come from an OpenZeppelin
76        // package path (`lib/openzeppelin-contracts`, `@openzeppelin/...`).
77        if !self.is_openzeppelin_source(function.source) {
78            return false;
79        }
80        let contract = self.hir.contract(contract_id);
81        let contract_name = contract.name.as_str();
82        // `safeApprove` lives in the SafeERC20 library, `_setupRole` in the AccessControl
83        // contract: requiring the matching declaration kind tightens the match.
84        if name.as_str() == "safeApprove" {
85            contract.kind.is_library()
86                && matches!(contract_name, "SafeERC20" | "SafeERC20Upgradeable")
87        } else if name.as_str() == "_setupRole" {
88            !contract.kind.is_library()
89                && matches!(contract_name, "AccessControl" | "AccessControlUpgradeable")
90        } else {
91            false
92        }
93    }
94
95    /// Whether a source file belongs to an OpenZeppelin package, judged by a full path
96    /// component against the package roots (the npm scope and the git-submodule directories).
97    /// Matching a whole component rather than a substring keeps a same-name local declaration
98    /// under a misleading path such as `src/not-openzeppelin/` from being recognized.
99    fn is_openzeppelin_source(&self, source_id: hir::SourceId) -> bool {
100        const OPENZEPPELIN_PACKAGE_ROOTS: [&str; 3] =
101            ["@openzeppelin", "openzeppelin-contracts", "openzeppelin-contracts-upgradeable"];
102        match &self.hir.source(source_id).file.name {
103            FileName::Real(path) => path.components().any(|component| {
104                matches!(component, std::path::Component::Normal(name)
105                    if OPENZEPPELIN_PACKAGE_ROOTS.iter().any(|root| name.eq_ignore_ascii_case(root)))
106            }),
107            _ => false,
108        }
109    }
110}