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}