1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::PathBuf;
9
10#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12pub struct AuditTemplate {
13 pub name: String,
15 pub description: String,
17 #[serde(default)]
19 pub enabled_checks: Vec<String>,
20 #[serde(default)]
22 pub disabled_checks: Vec<String>,
23 #[serde(default)]
25 pub min_scores: HashMap<String, u8>,
26 #[serde(default)]
28 pub focus_areas: Vec<String>,
29}
30
31impl AuditTemplate {
32 pub fn new(name: &str, description: &str) -> Self {
34 Self {
35 name: name.to_string(),
36 description: description.to_string(),
37 enabled_checks: Vec::new(),
38 disabled_checks: Vec::new(),
39 min_scores: HashMap::new(),
40 focus_areas: Vec::new(),
41 }
42 }
43}
44
45fn builtin_templates() -> Vec<AuditTemplate> {
47 vec![
48 AuditTemplate {
49 name: "erc20".into(),
50 description: "Focuses on ERC20 compliance, approval bugs, permit replay, and token-specific vulnerabilities".into(),
51 enabled_checks: vec![
52 "FA-H-014".into(), "FA-M-005".into(), ],
55 disabled_checks: vec![],
56 min_scores: HashMap::from([
57 ("access_control".into(), 85u8),
58 ("dependencies".into(), 80u8),
59 ]),
60 focus_areas: vec!["access_control".into(), "dependencies".into()],
61 },
62 AuditTemplate {
63 name: "erc721".into(),
64 description: "Focuses on NFT-specific patterns, royalties, metadata, and collection integrity".into(),
65 enabled_checks: vec![
66 "FA-M-005".into(), ],
68 disabled_checks: vec![],
69 min_scores: HashMap::from([
70 ("access_control".into(), 85u8),
71 ]),
72 focus_areas: vec!["access_control".into(), "security".into()],
73 },
74 AuditTemplate {
75 name: "defi".into(),
76 description: "Focuses on lending, AMM, oracle dependencies, flash loans, and MEV resistance".into(),
77 enabled_checks: vec![
78 "FA-H-011".into(), "FA-H-016".into(), "FA-H-017".into(), "FA-M-003".into(), ],
83 disabled_checks: vec![],
84 min_scores: HashMap::from([
85 ("exploit_resistance".into(), 80u8),
86 ("security".into(), 75u8),
87 ]),
88 focus_areas: vec!["exploit_resistance".into(), "security".into(), "chain_compatibility".into()],
89 },
90 AuditTemplate {
91 name: "bridge".into(),
92 description: "Focuses on cross-chain messaging, validator sets, replay attacks, and bridge-specific patterns".into(),
93 enabled_checks: vec![
94 "FA-H-015".into(), "FA-H-018".into(), "FA-H-013".into(), ],
98 disabled_checks: vec![],
99 min_scores: HashMap::from([
100 ("chain_compatibility".into(), 90u8),
101 ("security".into(), 80u8),
102 ]),
103 focus_areas: vec!["chain_compatibility".into(), "security".into()],
104 },
105 AuditTemplate {
106 name: "upgradeable".into(),
107 description: "Focuses on proxy patterns, storage layouts, initializers, and upgrade path safety".into(),
108 enabled_checks: vec![
109 "FA-H-007".into(), "FA-H-010".into(), "FA-H-022".into(), "FA-H-023".into(), ],
114 disabled_checks: vec![],
115 min_scores: HashMap::from([
116 ("upgradeability".into(), 90u8),
117 ("proxy_safety".into(), 90u8),
118 ]),
119 focus_areas: vec!["upgradeability".into(), "proxy_safety".into(), "architecture".into()],
120 },
121 ]
122}
123
124fn load_user_templates() -> Vec<AuditTemplate> {
126 let mut templates = Vec::new();
127
128 if let Some(user_dir) = user_template_dir() {
129 if user_dir.exists() {
130 if let Ok(entries) = std::fs::read_dir(&user_dir) {
131 for entry in entries.flatten() {
132 let path = entry.path();
133 if path.extension().is_some_and(|e| e == "json") {
134 if let Ok(content) = std::fs::read_to_string(&path) {
135 if let Ok(template) = serde_json::from_str::<AuditTemplate>(&content) {
136 templates.push(template);
137 }
138 }
139 }
140 }
141 }
142 }
143 }
144
145 templates
146}
147
148pub fn get_template(name: &str) -> Option<AuditTemplate> {
151 if let Some(t) = load_user_templates().into_iter().find(|t| t.name == name) {
153 return Some(t);
154 }
155 builtin_templates().into_iter().find(|t| t.name == name)
156}
157
158pub fn list_templates() -> Vec<AuditTemplate> {
162 let mut templates = load_user_templates();
163
164 for template in builtin_templates() {
166 if !templates.iter().any(|t| t.name == template.name) {
167 templates.push(template);
168 }
169 }
170
171 templates
172}
173
174pub fn apply_to_config(config: &mut crate::core::ProjectConfig, template: &AuditTemplate) {
179 config.security.enabled_checks = template.enabled_checks.clone();
180 config.security.disabled_checks = template.disabled_checks.clone();
181}
182
183fn user_template_dir() -> Option<PathBuf> {
185 let home = if cfg!(target_os = "windows") {
186 std::env::var("USERPROFILE").ok()
187 } else {
188 std::env::var("HOME").ok()
189 };
190 home.map(|h| PathBuf::from(h).join(".forge-guard").join("templates"))
191}
192
193pub fn print_template(t: &AuditTemplate) {
195 use colored::*;
196 println!(" {} — {}", t.name.bold().cyan(), t.description);
197 if !t.focus_areas.is_empty() {
198 println!(
199 " {} {}",
200 "Focus areas:".dimmed(),
201 t.focus_areas.join(", ")
202 );
203 }
204 if !t.enabled_checks.is_empty() {
205 println!(
206 " {} {}",
207 "Extra checks:".dimmed(),
208 t.enabled_checks.join(", ")
209 );
210 }
211 println!();
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 #[test]
219 fn test_builtin_templates_exist() {
220 let templates = builtin_templates();
221 assert_eq!(templates.len(), 5);
222 assert!(templates.iter().any(|t| t.name == "erc20"));
223 assert!(templates.iter().any(|t| t.name == "erc721"));
224 assert!(templates.iter().any(|t| t.name == "defi"));
225 assert!(templates.iter().any(|t| t.name == "bridge"));
226 assert!(templates.iter().any(|t| t.name == "upgradeable"));
227 }
228
229 #[test]
230 fn test_get_template_found() {
231 let t = get_template("defi").unwrap();
232 assert_eq!(t.name, "defi");
233 assert!(t.enabled_checks.contains(&"FA-H-011".to_string()));
234 assert!(t.focus_areas.contains(&"exploit_resistance".to_string()));
235 }
236
237 #[test]
238 fn test_get_template_not_found() {
239 assert!(get_template("nonexistent").is_none());
240 }
241
242 #[test]
243 fn test_list_templates_includes_builtin() {
244 let templates = list_templates();
245 assert!(templates.iter().any(|t| t.name == "erc20"));
246 }
247
248 #[test]
249 fn test_template_has_descriptions() {
250 for t in builtin_templates() {
251 assert!(
252 !t.description.is_empty(),
253 "Template {} has no description",
254 t.name
255 );
256 }
257 }
258
259 #[test]
260 fn test_erc20_template_checks() {
261 let t = get_template("erc20").unwrap();
262 assert!(t.enabled_checks.contains(&"FA-H-014".to_string()));
263 assert!(t.focus_areas.contains(&"access_control".to_string()));
264 }
265
266 #[test]
267 fn test_bridge_template_checks() {
268 let t = get_template("bridge").unwrap();
269 assert!(t.enabled_checks.contains(&"FA-H-015".to_string()));
270 assert!(t.enabled_checks.contains(&"FA-H-018".to_string()));
271 assert!(t.focus_areas.contains(&"chain_compatibility".to_string()));
272 }
273
274 #[test]
275 fn test_upgradeable_template_checks() {
276 let t = get_template("upgradeable").unwrap();
277 assert!(t.enabled_checks.contains(&"FA-H-007".to_string()));
278 assert!(t.enabled_checks.contains(&"FA-H-010".to_string()));
279 assert!(t.focus_areas.contains(&"upgradeability".to_string()));
280 }
281
282 #[test]
283 fn test_template_serialization() {
284 let t = get_template("defi").unwrap();
285 let json = serde_json::to_string_pretty(&t).unwrap();
286 assert!(json.contains("defi"));
287 assert!(json.contains("oracle"));
288
289 let deserialized: AuditTemplate = serde_json::from_str(&json).unwrap();
290 assert_eq!(deserialized.name, "defi");
291 assert_eq!(deserialized.focus_areas.len(), 3);
292 }
293
294 #[test]
295 fn test_template_new_constructor() {
296 let t = AuditTemplate::new("custom", "A custom template");
297 assert_eq!(t.name, "custom");
298 assert_eq!(t.description, "A custom template");
299 assert!(t.enabled_checks.is_empty());
300 assert!(t.focus_areas.is_empty());
301 }
302
303 #[test]
304 fn test_user_template_dir_returns_some() {
305 let dir = user_template_dir();
307 assert!(dir.is_some());
308 }
309
310 #[test]
311 fn test_print_template_does_not_panic() {
312 let t = get_template("erc20").unwrap();
313 print_template(&t);
315 }
316
317 #[test]
318 fn test_apply_to_config_sets_checks() {
319 let mut config = crate::core::ProjectConfig::default();
320 let t = get_template("defi").unwrap();
321
322 crate::templates::apply_to_config(&mut config, &t);
323
324 assert!(config
325 .security
326 .enabled_checks
327 .contains(&"FA-H-011".to_string()));
328 assert!(config
329 .security
330 .enabled_checks
331 .contains(&"FA-H-016".to_string()));
332 assert!(config.security.disabled_checks.is_empty());
333 }
334
335 #[test]
336 fn test_apply_to_config_with_disabled_checks() {
337 let mut config = crate::core::ProjectConfig::default();
338 let mut t = AuditTemplate::new("custom", "Custom template");
339 t.enabled_checks = vec!["FA-H-001".into()];
340 t.disabled_checks = vec!["FA-H-014".into()];
341
342 crate::templates::apply_to_config(&mut config, &t);
343
344 assert_eq!(config.security.enabled_checks, vec!["FA-H-001".to_string()]);
345 assert_eq!(
346 config.security.disabled_checks,
347 vec!["FA-H-014".to_string()]
348 );
349 }
350
351 #[test]
352 fn test_get_template_user_override() {
353 let dir = user_template_dir().unwrap();
355 let _ = std::fs::remove_dir_all(&dir);
356 std::fs::create_dir_all(&dir).unwrap();
357
358 let custom = AuditTemplate {
359 name: "erc20".into(),
360 description: "User override".into(),
361 enabled_checks: vec!["FA-H-001".into()],
362 ..Default::default()
363 };
364 std::fs::write(
365 dir.join("erc20.json"),
366 serde_json::to_string(&custom).unwrap(),
367 )
368 .unwrap();
369
370 let t = get_template("erc20").unwrap();
371 assert_eq!(t.description, "User override");
372 assert_eq!(t.enabled_checks, vec!["FA-H-001".to_string()]);
373
374 let _ = std::fs::remove_dir_all(&dir);
375 }
376
377 #[test]
378 fn test_list_templates_dedupes_user_override() {
379 let templates = list_templates();
380 let count = templates.iter().filter(|t| t.name == "erc20").count();
381 assert_eq!(count, 1, "erc20 should appear exactly once");
382 }
383}