use crate::core::Finding;
use anyhow::{Context, Result};
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Suppression {
pub id: String,
pub file: Option<String>,
pub comment: Option<String>,
}
pub fn parse(content: &str) -> Vec<Suppression> {
let mut out = Vec::new();
for raw_line in content.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (code, comment) = match line.find(['#', ';']) {
Some(pos) => (line[..pos].trim(), Some(line[pos + 1..].trim().to_string())),
None => (line, None),
};
let mut tokens = code.split_whitespace();
let Some(id) = tokens.next() else { continue };
if id.is_empty() {
continue;
}
let file = tokens
.next()
.map(str::trim)
.filter(|f| !f.is_empty())
.map(String::from);
out.push(Suppression {
id: id.to_string(),
file,
comment: comment.filter(|c| !c.is_empty()),
});
}
out
}
pub fn load(path: &std::path::Path) -> Result<Vec<Suppression>> {
if !path.exists() {
return Ok(Vec::new());
}
let content = std::fs::read_to_string(path)
.with_context(|| format!("Could not read suppression file {}", path.display()))?;
Ok(parse(&content))
}
pub fn entry_matches(entry: &Suppression, finding: &Finding) -> bool {
let id_matches = finding.id == entry.id || finding.id.starts_with(&format!("{}-", entry.id));
if !id_matches {
return false;
}
match &entry.file {
None => true,
Some(scope) => match &finding.file {
None => false,
Some(file) => file == scope || file.ends_with(&format!("/{}", scope)),
},
}
}
pub fn is_suppressed(finding: &Finding, suppressions: &[Suppression]) -> bool {
suppressions.iter().any(|e| entry_matches(e, finding))
}
fn check_id(finding: &Finding) -> String {
match finding.id.rfind('-') {
Some(pos) if pos > 0 => finding.id[..pos].to_string(),
_ => finding.id.clone(),
}
}
pub fn generate(findings: &[Finding]) -> String {
let mut by_check: std::collections::BTreeMap<String, &Finding> =
std::collections::BTreeMap::new();
for f in findings {
by_check.entry(check_id(f)).or_insert(f);
}
let mut out = String::new();
out.push_str(
"# Forge Guard suppressions — generated by `forge-guard audit --generate-suppressions`\n",
);
out.push_str("# Format: <FINDING_ID> [file] # comment\n");
out.push_str(
"# Findings listed here are excluded from scoring unless --show-suppressed is used.\n\n",
);
for (id, f) in by_check {
let location = match (&f.file, f.line) {
(Some(file), Some(line)) => format!("{}:{}", file, line),
(Some(file), None) => file.clone(),
(None, _) => String::new(),
};
let comment = if location.is_empty() {
f.title.to_string()
} else {
format!("{} ({})", f.title, location)
};
out.push_str(&format!("{} # {}\n", id, comment));
}
out
}
pub fn write(path: &std::path::Path, findings: &[Finding]) -> Result<()> {
let content = generate(findings);
std::fs::write(path, content)
.with_context(|| format!("Could not write suppression file {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn finding(id: &str, file: &str, line: usize) -> Finding {
Finding::builder()
.id(id)
.title("Some finding")
.description("desc")
.severity(crate::core::Severity::High)
.file(file)
.location(line, 0)
.recommendation("fix")
.category("Security")
.build()
}
#[test]
fn test_parse_basic() {
let entries = parse(
"FA-H-001 # Known false positive in Vault.sol\nFA-M-004 src/oracle/PriceFeed.sol\n\n# full comment line\nFA-L-002\n",
);
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].id, "FA-H-001");
assert_eq!(entries[0].file, None);
assert_eq!(
entries[0].comment.as_deref(),
Some("Known false positive in Vault.sol")
);
assert_eq!(entries[1].id, "FA-M-004");
assert_eq!(entries[1].file.as_deref(), Some("src/oracle/PriceFeed.sol"));
assert_eq!(entries[2].id, "FA-L-002");
assert_eq!(entries[2].comment, None);
}
#[test]
fn test_parse_ignores_blank_and_comments() {
assert_eq!(parse(""), Vec::<Suppression>::new());
assert_eq!(parse("# only a comment\n\n \n"), Vec::<Suppression>::new());
}
#[test]
fn test_parse_semicolon_comment() {
let entries = parse("FA-H-001 ; semicolon comment");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].comment.as_deref(), Some("semicolon comment"));
}
#[test]
fn test_entry_matches_prefix() {
let entry = Suppression {
id: "FA-H-001".into(),
file: None,
comment: None,
};
assert!(entry_matches(
&entry,
&finding("FA-H-001-1", "Vault.sol", 42)
));
assert!(entry_matches(&entry, &finding("FA-H-001", "Vault.sol", 42)));
assert!(!entry_matches(
&entry,
&finding("FA-H-002-1", "Vault.sol", 42)
));
assert!(!entry_matches(
&entry,
&finding("FA-M-001-1", "Vault.sol", 42)
));
}
#[test]
fn test_entry_matches_file_scope() {
let entry = Suppression {
id: "FA-H-001".into(),
file: Some("src/Vault.sol".into()),
comment: None,
};
assert!(entry_matches(
&entry,
&finding("FA-H-001-1", "src/Vault.sol", 42)
));
assert!(entry_matches(
&entry,
&finding("FA-H-001-1", "contracts/src/Vault.sol", 42)
));
assert!(!entry_matches(
&entry,
&finding("FA-H-001-1", "src/Other.sol", 42)
));
assert!(!entry_matches(
&entry,
&finding("FA-H-001-1", "Other.sol", 42)
));
}
#[test]
fn test_is_suppressed() {
let suppressions = parse("FA-H-001\nFA-M-003 src/Oracle.sol");
let f1 = finding("FA-H-001-2", "Vault.sol", 10);
let f2 = finding("FA-M-003-1", "src/Oracle.sol", 20);
let f3 = finding("FA-M-003-1", "src/Other.sol", 20);
let f4 = finding("FA-L-009-1", "Vault.sol", 30);
assert!(is_suppressed(&f1, &suppressions));
assert!(is_suppressed(&f2, &suppressions));
assert!(!is_suppressed(&f3, &suppressions));
assert!(!is_suppressed(&f4, &suppressions));
assert!(!is_suppressed(&f1, &[]));
}
#[test]
fn test_generate_deduplicates_and_sorts() {
let findings = vec![
finding("FA-H-003-1", "B.sol", 1),
finding("FA-H-001-1", "A.sol", 1),
finding("FA-H-001-2", "A.sol", 2),
finding("FA-M-002-1", "C.sol", 3),
];
let content = generate(&findings);
assert!(content.contains("FA-H-001 # Some finding (A.sol:1)"));
assert!(content.contains("FA-H-003"));
assert!(content.contains("FA-M-002"));
assert_eq!(content.matches("FA-H-001").count(), 1);
let i1 = content.find("FA-H-001").unwrap();
let i3 = content.find("FA-H-003").unwrap();
let i2 = content.find("FA-M-002").unwrap();
assert!(i1 < i3 && i3 < i2);
}
#[test]
fn test_generate_roundtrip() {
let findings = vec![finding("FA-H-001-1", "Vault.sol", 42)];
let content = generate(&findings);
let entries = parse(&content);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].id, "FA-H-001");
assert!(is_suppressed(&findings[0], &entries));
}
#[test]
fn test_load_missing_file_empty() {
assert!(
load(std::path::Path::new("/nonexistent/definitely-missing"))
.unwrap()
.is_empty()
);
}
#[test]
fn test_write_and_load_roundtrip() {
let dir = std::env::temp_dir().join(format!("fg-sup-{}", std::process::id()));
let path = dir.join(".forge-guard-suppressions");
std::fs::create_dir_all(&dir).unwrap();
write(&path, &[finding("FA-H-001-1", "Vault.sol", 42)]).unwrap();
let loaded = load(&path).unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].id, "FA-H-001");
let _ = std::fs::remove_dir_all(&dir);
}
}