1use regex::Regex;
8use std::{collections::HashSet, sync::LazyLock};
9
10const CLAIM_OVERLAP: (usize, usize) = (3, 10);
13const LINE_WINDOW: u64 = 50;
17const STOP_WORDS: [&str; 31] = [
19 "the", "a", "an", "of", "to", "is", "in", "on", "and", "or", "must", "be", "it", "that",
20 "with", "for", "not", "when", "as", "by", "at", "from", "than", "its", "this", "are", "which",
21 "so", "any", "if", "no",
22];
23
24static BUG_TAG: LazyLock<Regex> = LazyLock::new(|| {
27 Regex::new(r"^@(?:custom:)?bug\s+(?:[A-Za-z_]\w*\.)?([A-Za-z_]\w*)(?:\([^)]*\))?\s+([\w./-]+\.sol):(\d+)")
28 .unwrap()
29});
30static SOLIDITY_FILE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[\w./-]+\.sol").unwrap());
32static SOLIDITY_LOCATION: LazyLock<Regex> =
34 LazyLock::new(|| Regex::new(r"([\w./-]+\.sol)(?::(\d+))?").unwrap());
35static FUNCTION_CALL: LazyLock<Regex> =
37 LazyLock::new(|| Regex::new(r"\b[A-Za-z_][A-Za-z0-9_]*\(").unwrap());
38static GENERIC_CALL: LazyLock<Regex> = LazyLock::new(|| {
40 Regex::new(r"^(?:require|assert|revert|keccak256|sha256|ecrecover|encode\w*|decode|call|delegatecall|staticcall|address|payable|type|bool|string|bytes\d*|u?int\d*)\($")
41 .unwrap()
42});
43static CAMEL_CASE: LazyLock<Regex> =
45 LazyLock::new(|| Regex::new(r"\b_?[a-z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*\b").unwrap());
46static WORD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[a-z0-9_]+").unwrap());
48
49#[derive(Debug, PartialEq)]
52struct BugTag {
53 file: String,
55 function: String,
56 line: u64,
57}
58
59impl BugTag {
60 fn new(claim: &str) -> Option<Self> {
61 let captures = BUG_TAG.captures(claim)?;
62 Some(Self {
63 file: captures[2].to_string(),
64 function: captures[1].to_string(),
65 line: captures[3].parse().ok()?,
66 })
67 }
68
69 fn agrees(&self, other: &Self) -> bool {
70 same_file(&self.file, &other.file)
71 && self.function == other.function
72 && self.line.abs_diff(other.line) <= LINE_WINDOW
73 }
74}
75
76struct RootCause {
79 file: String,
81 line: Option<u64>,
83 identifiers: HashSet<String>,
86 words: HashSet<String>,
89}
90
91impl RootCause {
92 fn new(claim: &str) -> Option<Self> {
93 let location = SOLIDITY_LOCATION.captures(claim)?;
94 let file = location[1].to_string();
95 let line = location.get(2).and_then(|line| line.as_str().parse().ok());
96 let identifiers = FUNCTION_CALL
97 .find_iter(claim)
98 .chain(CAMEL_CASE.find_iter(claim))
99 .map(|identifier| identifier.as_str())
100 .filter(|identifier| !GENERIC_CALL.is_match(identifier))
101 .map(String::from)
102 .collect();
103 let claim = SOLIDITY_FILE.replace_all(claim, " ").to_lowercase();
104 let words = WORD
105 .find_iter(&claim)
106 .map(|word| word.as_str())
107 .filter(|word| word.len() > 2 && !STOP_WORDS.contains(word))
108 .map(String::from)
109 .collect();
110 Some(Self { file, line, identifiers, words })
111 }
112
113 fn agrees(&self, other: &Self) -> bool {
116 let shared = self.words.intersection(&other.words).count();
117 let all = self.words.union(&other.words).count();
118 let near = match (self.line, other.line) {
119 (Some(a), Some(b)) => a.abs_diff(b) <= LINE_WINDOW,
120 _ => true,
121 };
122 same_file(&self.file, &other.file)
123 && ((near && !self.identifiers.is_disjoint(&other.identifiers))
124 || (shared > 0 && shared * CLAIM_OVERLAP.1 >= all * CLAIM_OVERLAP.0))
125 }
126}
127
128fn same_file(a: &str, b: &str) -> bool {
133 fn components(path: &str) -> impl Iterator<Item = &str> {
134 path.rsplit('/')
135 .filter(|component| !matches!(*component, "" | "."))
136 .take_while(|component| *component != "..")
137 }
138 components(a).zip(components(b)).all(|(a, b)| a == b)
139}
140
141pub(super) fn root_cause_groups(claims: &[Option<&str>]) -> Vec<usize> {
150 let tags = claims.iter().map(|claim| BugTag::new((*claim)?)).collect::<Vec<_>>();
151 let causes = claims.iter().map(|claim| RootCause::new((*claim)?)).collect::<Vec<_>>();
152 let agree = |i: usize, j: usize| match (&tags[i], &tags[j]) {
153 (Some(a), Some(b)) => a.agrees(b),
154 _ => matches!((&causes[i], &causes[j]), (Some(a), Some(b)) if a.agrees(b)),
155 };
156 let mut groups = Vec::<Vec<usize>>::new();
157 (0..claims.len())
158 .map(|i| match groups.iter_mut().find(|group| group.iter().all(|&j| agree(i, j))) {
159 Some(group) => {
160 group.push(i);
161 group[0]
162 }
163 None => {
164 groups.push(vec![i]);
165 i
166 }
167 })
168 .collect()
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 fn cause(claim: &str) -> RootCause {
176 RootCause::new(claim).unwrap()
177 }
178
179 #[test]
182 fn root_causes_agree_on_file_and_function_or_claim_words() {
183 let cap = cause("The fee is capped at 100 ether; src/Fee.sol:7 fee() caps at 10 ether.");
184 assert!(cap.agrees(&cause("Large amounts pay the maximum: src/Fee.sol:7 fee() is wrong")));
185 assert!(
186 cap.agrees(&cause("The fee is capped at 100 ether for large amounts (src/Fee.sol)"))
187 );
188 assert!(!cap.agrees(&cause("The fee is capped at 100 ether; src/Other.sol:7 fee()")));
189 assert!(!cap.agrees(&cause("A zero amount is charged; src/Fee.sol:11 charges()")));
190 assert!(!cause("Wrong result; src/Fee.sol:7").agrees(&cause("Bad output; src/Fee.sol:7")));
191 assert!(RootCause::new("The fee is capped at 100 ether.").is_none());
192 }
193
194 #[test]
197 fn root_causes_ignore_generic_calls_and_far_lines() {
198 let approve =
199 cause("src/Token.sol:40 approve() lets anyone reset the allowance via transfer(");
200 let burn = cause("src/Token.sol:200 burn() skips the balance check in transfer(");
201 assert!(!approve.agrees(&burn));
202 assert!(approve.agrees(&cause("src/Token.sol:44 transfer( drops the allowance check")));
203 assert!(approve.agrees(&cause("Allowance resets in src/Token.sol: transfer( is open")));
204 assert!(
205 !cause("src/Token.sol mint() skips require( on the cap")
206 .agrees(&cause("src/Token.sol pause() has no require( on the owner"))
207 );
208 }
209
210 #[test]
213 fn paths_agree_by_their_trailing_components() {
214 assert!(same_file("src/Vault.sol", "Vault.sol"));
215 assert!(same_file("./src/Vault.sol", "/tmp/forge-properties-1/src/Vault.sol"));
216 assert!(!same_file("src/one/Vault.sol", "src/two/Vault.sol"));
217 assert!(!same_file("src/Vault.sol", "src/MyVault.sol"));
218 }
219
220 #[test]
223 fn bug_tags_parse_the_target() {
224 let tag = |file: &str, function: &str, line| BugTag {
225 file: file.into(),
226 function: function.into(),
227 line,
228 };
229 assert_eq!(
230 BugTag::new("@bug Fee.fee src/Fee.sol:7 — the cap is 10 ether"),
231 Some(tag("src/Fee.sol", "fee", 7))
232 );
233 assert_eq!(
234 BugTag::new("@bug fee(uint256) contracts/Fee.sol:12 - wrong"),
235 Some(tag("contracts/Fee.sol", "fee", 12))
236 );
237 assert_eq!(
238 BugTag::new("@custom:bug Fee.charges Fee.sol:11"),
239 Some(tag("Fee.sol", "charges", 11))
240 );
241 assert_eq!(BugTag::new("The fee is capped; @bug Fee.fee src/Fee.sol:7"), None);
242 assert_eq!(BugTag::new("@bug Fee.fee src/Fee.sol — no line"), None);
243 }
244
245 #[test]
251 fn tagged_claims_group_on_file_and_function() {
252 let groups = root_cause_groups(&[
253 Some("@bug Fee.fee src/Fee.sol:7 — the cap is 10 ether, not 100 ether"),
254 Some("@bug FeeLib.fee Fee.sol:8 — large amounts pay too little"),
255 Some("@bug Fee.fee src/Other.sol:7 — the cap is 10 ether, not 100 ether"),
256 Some("@bug Fee.charges src/Fee.sol:7 — charges() takes a fee on a zero amount"),
257 Some("@bug Fee.fee src/Fee.sol:90 — an overload rounds up"),
258 Some("A zero amount is charged; src/Fee.sol:11 charges() returns true"),
259 Some("The fee is capped at 100 ether; src/Fee.sol:7 fee() caps at 10 ether."),
260 None,
261 Some("@bug A.f src/one/Vault.sol:2 — returns zero"),
262 Some("@bug B.f src/two/Vault.sol:2 — returns one"),
263 ]);
264 assert_eq!(groups, [0, 0, 2, 3, 4, 3, 6, 7, 8, 9]);
265 }
266
267 #[test]
270 fn groups_need_every_pair_to_agree() {
271 let groups = root_cause_groups(&[
272 Some("@bug A.f src/one/Vault.sol:2 — returns zero"),
273 Some("@bug A.f Vault.sol:2 — returns zero"),
274 Some("@bug A.f src/two/Vault.sol:2 — returns zero"),
275 Some("@bug A.g src/Pool.sol:7 — wrong"),
276 Some("@bug A.g src/Pool.sol:50 — wrong"),
277 Some("@bug A.g src/Pool.sol:95 — wrong"),
278 ]);
279 assert_eq!(groups, [0, 0, 2, 3, 3, 5]);
280 }
281}