1pub mod anchor;
2
3use crate::finding::{Finding, Severity, SourceLocation};
4use crate::syntax::ParsedFile;
5use serde::Serialize;
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
9#[serde(rename_all = "lowercase")]
10pub enum RuleSeverity {
11 Low,
12 Medium,
13 High,
14 Critical,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
18pub struct RuleMetadata {
19 pub id: &'static str,
20 pub title: &'static str,
21 pub severity: RuleSeverity,
22 pub description: &'static str,
23 pub fix_guidance: &'static str,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct RuleMatch {
28 pub rule_id: &'static str,
29 pub severity: RuleSeverity,
30 pub message: String,
31 pub location: SourceLocation,
32 pub help: Option<String>,
33}
34
35pub trait Rule {
36 fn metadata(&self) -> &RuleMetadata;
37 fn match_file(&self, file: &ParsedFile, ctx: &RuleContext<'_>) -> Vec<RuleMatch>;
38}
39
40#[derive(Clone, Copy)]
41pub struct RuleContext<'a> {
42 pub files: &'a [ParsedFile],
43}
44
45pub struct RuleRegistry {
46 rules: Vec<Box<dyn Rule>>,
47}
48
49impl RuleRegistry {
50 pub fn new(rules: Vec<Box<dyn Rule>>) -> Self {
51 Self { rules }
52 }
53
54 pub fn baseline() -> Self {
55 Self::new(vec![
56 Box::new(anchor::missing_signer_check::MissingSignerCheckRule),
57 Box::new(anchor::missing_pda_seeds_bump::MissingPdaSeedsBumpRule),
58 Box::new(anchor::init_if_needed_usage::InitIfNeededUsageRule),
59 Box::new(anchor::missing_realloc_zero::MissingReallocZeroRule),
60 Box::new(anchor::account_info_as_data_account::AccountInfoAsDataAccountRule),
61 Box::new(anchor::account_info_as_cpi_program::AccountInfoAsCpiProgramRule),
62 Box::new(anchor::missing_owner_check::MissingOwnerCheckRule),
63 Box::new(anchor::arbitrary_cpi::ArbitraryCpiRule),
64 Box::new(anchor::missing_cpi_reload::MissingCpiReloadRule),
65 Box::new(anchor::unchecked_arithmetic::UncheckedArithmeticRule),
66 Box::new(anchor::type_cosplay::TypeCosplayRule),
67 Box::new(anchor::missing_token_mint_check::MissingTokenMintCheckRule),
68 Box::new(anchor::missing_token_owner_check::MissingTokenOwnerCheckRule),
69 Box::new(anchor::pda_seed_unvalidated_account::PdaSeedUnvalidatedAccountRule),
70 Box::new(anchor::pda_bump_not_canonical::PdaBumpNotCanonicalRule),
71 ])
72 }
73
74 pub fn all(&self) -> &[Box<dyn Rule>] {
75 &self.rules
76 }
77
78 pub fn matching_rules(&self, rule_filter: Option<&str>) -> Vec<&dyn Rule> {
79 let filter = rule_filter
80 .map(normalize_rule_id)
81 .filter(|filter| !filter.is_empty());
82
83 self.rules
84 .iter()
85 .map(|rule| rule.as_ref())
86 .filter(|rule| {
87 filter.as_ref().is_none_or(|filter| {
88 rule.metadata().id.eq_ignore_ascii_case(filter)
89 })
90 })
91 .collect()
92 }
93}
94
95impl Default for RuleRegistry {
96 fn default() -> Self {
97 Self::baseline()
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct SuppressionSet {
103 same_line: HashMap<usize, Vec<String>>,
104 next_line: HashMap<usize, Vec<String>>,
105}
106
107impl SuppressionSet {
108 pub fn empty() -> Self {
109 Self {
110 same_line: HashMap::new(),
111 next_line: HashMap::new(),
112 }
113 }
114
115 pub fn from_source(source: &str) -> Self {
116 let mut same_line: HashMap<usize, Vec<String>> = HashMap::new();
117 let mut next_line: HashMap<usize, Vec<String>> = HashMap::new();
118
119 for (idx, line) in source.lines().enumerate() {
120 let line_no = idx + 1;
121 if let Some(ids) = parse_ignore_directive(line, "sentio-ignore") {
122 same_line.insert(line_no, ids);
123 }
124 if let Some(ids) = parse_ignore_directive(line, "sentio-ignore-next-line") {
125 next_line.insert(line_no + 1, ids);
126 }
127 }
128
129 Self {
130 same_line,
131 next_line,
132 }
133 }
134
135 pub fn is_suppressed(&self, finding: &Finding) -> bool {
136 let rule_id = finding.rule_id.to_uppercase();
137 let line = finding.location.line;
138
139 self.same_line
140 .get(&line)
141 .is_some_and(|ids| ids.iter().any(|id| id == &rule_id))
142 || self
143 .next_line
144 .get(&line)
145 .is_some_and(|ids| ids.iter().any(|id| id == &rule_id))
146 }
147}
148
149pub fn convert_severity(severity: RuleSeverity) -> Severity {
150 match severity {
151 RuleSeverity::Low => Severity::Low,
152 RuleSeverity::Medium => Severity::Medium,
153 RuleSeverity::High => Severity::High,
154 RuleSeverity::Critical => Severity::Critical,
155 }
156}
157
158fn normalize_rule_id(rule_id: &str) -> String {
159 rule_id.trim().to_uppercase()
160}
161
162fn parse_ignore_directive(line: &str, directive: &str) -> Option<Vec<String>> {
163 let lower = line.to_lowercase();
164 let compact = lower.replace(char::is_whitespace, "");
165 let marker = format!("//{directive}");
166 let start = compact.find(&marker)? + marker.len();
167 let ids = compact[start..]
168 .split(|c: char| c == ',' || c.is_whitespace())
169 .map(|s| s.trim().to_uppercase())
170 .filter(|id| is_rule_id(id))
171 .collect::<Vec<_>>();
172
173 if ids.is_empty() {
174 None
175 } else {
176 Some(ids)
177 }
178}
179
180fn is_rule_id(id: &str) -> bool {
181 id.len() == 5 && id.starts_with("SW") && id[2..].chars().all(|c| c.is_ascii_digit())
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 #[test]
189 fn parses_same_line_and_next_line_suppressions() {
190 let suppressions = SuppressionSet::from_source(
191 r#"
192 // sentio-ignore SW012, SW018
193 let a = 1;
194 // sentio-ignore-next-line SW012
195 let b = 2;
196 "#,
197 );
198
199 let same_line = Finding {
200 rule_id: "SW012".to_string(),
201 severity: Severity::High,
202 message: String::new(),
203 location: SourceLocation {
204 path: "x.rs".to_string(),
205 line: 2,
206 column: 1,
207 },
208 help: None,
209 suppressed: false,
210 };
211 let next_line = Finding {
212 rule_id: "SW012".to_string(),
213 severity: Severity::High,
214 message: String::new(),
215 location: SourceLocation {
216 path: "x.rs".to_string(),
217 line: 5,
218 column: 1,
219 },
220 help: None,
221 suppressed: false,
222 };
223
224 assert!(suppressions.is_suppressed(&same_line));
225 assert!(suppressions.is_suppressed(&next_line));
226 }
227}