forge/cmd/
geiger.rs

1use clap::{Parser, ValueHint};
2use eyre::{Result, WrapErr};
3use foundry_cli::utils::LoadConfig;
4use foundry_compilers::{resolver::parse::SolData, Graph};
5use foundry_config::{impl_figment_convert_basic, Config};
6use itertools::Itertools;
7use solar_parse::{ast, ast::visit::Visit, interface::Session};
8use std::{
9    ops::ControlFlow,
10    path::{Path, PathBuf},
11};
12
13/// CLI arguments for `forge geiger`.
14#[derive(Clone, Debug, Parser)]
15pub struct GeigerArgs {
16    /// Paths to files or directories to detect.
17    #[arg(
18        conflicts_with = "root",
19        value_hint = ValueHint::FilePath,
20        value_name = "PATH",
21        num_args(1..),
22    )]
23    paths: Vec<PathBuf>,
24
25    /// The project's root path.
26    ///
27    /// By default root of the Git repository, if in one,
28    /// or the current working directory.
29    #[arg(long, value_hint = ValueHint::DirPath, value_name = "PATH")]
30    root: Option<PathBuf>,
31
32    /// Globs to ignore.
33    #[arg(
34        long,
35        value_hint = ValueHint::FilePath,
36        value_name = "PATH",
37        num_args(1..),
38    )]
39    ignore: Vec<PathBuf>,
40
41    #[arg(long, hide = true)]
42    check: bool,
43    #[arg(long, hide = true)]
44    full: bool,
45}
46
47impl_figment_convert_basic!(GeigerArgs);
48
49impl GeigerArgs {
50    pub fn sources(&self, config: &Config) -> Result<Vec<PathBuf>> {
51        let cwd = std::env::current_dir()?;
52
53        let mut sources: Vec<PathBuf> = {
54            if self.paths.is_empty() {
55                let paths = config.project_paths();
56                Graph::<SolData>::resolve(&paths)?
57                    .files()
58                    .keys()
59                    .filter(|f| !paths.has_library_ancestor(f))
60                    .cloned()
61                    .collect()
62            } else {
63                self.paths
64                    .iter()
65                    .flat_map(|path| foundry_common::fs::files_with_ext(path, "sol"))
66                    .unique()
67                    .collect()
68            }
69        };
70
71        sources.retain_mut(|path| {
72            let abs_path = if path.is_absolute() { path.clone() } else { cwd.join(&path) };
73            *path = abs_path.strip_prefix(&cwd).unwrap_or(&abs_path).to_path_buf();
74            !self.ignore.iter().any(|ignore| {
75                if ignore.is_absolute() {
76                    abs_path.starts_with(ignore)
77                } else {
78                    abs_path.starts_with(cwd.join(ignore))
79                }
80            })
81        });
82
83        Ok(sources)
84    }
85
86    pub fn run(self) -> Result<usize> {
87        if self.check {
88            sh_warn!("`--check` is deprecated as it's now the default behavior\n")?;
89        }
90        if self.full {
91            sh_warn!("`--full` is deprecated as reports are not generated anymore\n")?;
92        }
93
94        let config = self.load_config()?;
95        let sources = self.sources(&config).wrap_err("Failed to resolve files")?;
96
97        if config.ffi {
98            sh_warn!("FFI enabled\n")?;
99        }
100
101        let mut sess = Session::builder().with_stderr_emitter().build();
102        sess.dcx = sess.dcx.set_flags(|flags| flags.track_diagnostics = false);
103        let unsafe_cheatcodes = &[
104            "ffi".to_string(),
105            "readFile".to_string(),
106            "readLine".to_string(),
107            "writeFile".to_string(),
108            "writeLine".to_string(),
109            "removeFile".to_string(),
110            "closeFile".to_string(),
111            "setEnv".to_string(),
112            "deriveKey".to_string(),
113        ];
114        Ok(sess
115            .enter(|| sources.iter().map(|file| lint_file(&sess, unsafe_cheatcodes, file)).sum()))
116    }
117}
118
119fn lint_file(sess: &Session, unsafe_cheatcodes: &[String], path: &Path) -> usize {
120    try_lint_file(sess, unsafe_cheatcodes, path).unwrap_or(0)
121}
122
123fn try_lint_file(
124    sess: &Session,
125    unsafe_cheatcodes: &[String],
126    path: &Path,
127) -> solar_parse::interface::Result<usize> {
128    let arena = solar_parse::ast::Arena::new();
129    let mut parser = solar_parse::Parser::from_file(sess, &arena, path)?;
130    let ast = parser.parse_file().map_err(|e| e.emit())?;
131    let mut visitor = Visitor::new(sess, unsafe_cheatcodes);
132    let _ = visitor.visit_source_unit(&ast);
133    Ok(visitor.count)
134}
135
136struct Visitor<'a> {
137    sess: &'a Session,
138    count: usize,
139    unsafe_cheatcodes: &'a [String],
140}
141
142impl<'a> Visitor<'a> {
143    fn new(sess: &'a Session, unsafe_cheatcodes: &'a [String]) -> Self {
144        Self { sess, count: 0, unsafe_cheatcodes }
145    }
146}
147
148impl<'ast> Visit<'ast> for Visitor<'_> {
149    type BreakValue = solar_parse::interface::data_structures::Never;
150
151    fn visit_expr(&mut self, expr: &'ast ast::Expr<'ast>) -> ControlFlow<Self::BreakValue> {
152        if let ast::ExprKind::Call(lhs, _args) = &expr.kind {
153            if let ast::ExprKind::Member(_lhs, member) = &lhs.kind {
154                if self.unsafe_cheatcodes.iter().any(|c| c.as_str() == member.as_str()) {
155                    let msg = format!("usage of unsafe cheatcode `vm.{member}`");
156                    self.sess.dcx.err(msg).span(member.span).emit();
157                    self.count += 1;
158                }
159            }
160        }
161        self.walk_expr(expr)
162    }
163}