xbp-analysis 10.57.0

Language-agnostic static analysis (error-handling / Aspirator-style) for XBP
Documentation
//! Suppression and approved-pattern filtering.

use crate::domain::request::AnalysisConfig;
use crate::domain::types::{Finding, Suppression};
use std::path::Path;

pub fn is_suppressed(finding: &Finding, config: &AnalysisConfig) -> bool {
    for s in &config.suppressions {
        if suppression_matches(finding, s) {
            return true;
        }
    }
    for p in &config.approved_patterns {
        if let Some(ref rule) = p.rule_id {
            if rule != &finding.rule_id && rule != "*" {
                continue;
            }
        }
        if let Some(ref glob) = p.path_glob {
            let path = finding.location.file.to_string_lossy();
            if !path_matches_glob(&path, glob) {
                continue;
            }
        }
        if let Some(ref needle) = p.code_contains {
            // Approved patterns without code context still match by path/rule.
            let _ = needle;
        }
        return true;
    }
    false
}

fn suppression_matches(finding: &Finding, s: &Suppression) -> bool {
    if let Some(ref rule) = s.rule_id {
        if rule != &finding.rule_id && rule != "*" {
            return false;
        }
    }
    if let Some(ref file) = s.file {
        if finding.location.file != *file
            && !finding
                .location
                .file
                .ends_with(file.as_path())
        {
            return false;
        }
    }
    if let Some(line) = s.line {
        if finding.location.start_line != line {
            return false;
        }
    }
    true
}

/// Minimal glob: `*` and `**` path segments, case-sensitive.
pub fn path_matches_glob(path: &str, pattern: &str) -> bool {
    let path = path.replace('\\', "/");
    let pattern = pattern.replace('\\', "/");
    if pattern == "**" || pattern == "*" {
        return true;
    }
    // `**/target/**` → path contains `/target/` or starts with `target/` or ends with `/target`
    if pattern.starts_with("**/") && pattern.ends_with("/**") {
        let mid = &pattern[3..pattern.len() - 3];
        if mid.is_empty() {
            return true;
        }
        return path == mid
            || path.starts_with(&format!("{mid}/"))
            || path.ends_with(&format!("/{mid}"))
            || path.contains(&format!("/{mid}/"));
    }
    if let Some(rest) = pattern.strip_prefix("**/") {
        let rest = rest.trim_start_matches('/');
        return path == rest
            || path.ends_with(rest)
            || path.ends_with(&format!("/{rest}"))
            || path.contains(&format!("/{rest}"))
            || path.contains(rest);
    }
    if let Some(prefix) = pattern.strip_suffix("/**") {
        return path == prefix || path.starts_with(&format!("{prefix}/"));
    }
    if let Some(suf) = pattern.strip_prefix('*') {
        return path.ends_with(suf);
    }
    path == pattern || path.ends_with(&pattern) || path.ends_with(&format!("/{pattern}"))
}

pub fn parse_inline_suppressions(source: &str, path: &Path) -> Vec<Suppression> {
    let mut out = Vec::new();
    for (idx, line) in source.lines().enumerate() {
        let line_no = (idx + 1) as u32;
        // xbp-analysis: ignore RULE or xbp-audit: ignore RULE
        for marker in ["xbp-analysis: ignore", "xbp-audit: ignore", "xbp-aspirator: ignore"] {
            if let Some(pos) = line.find(marker) {
                let rest = line[pos + marker.len()..].trim();
                let rule = rest.split_whitespace().next().unwrap_or("*");
                out.push(Suppression {
                    rule_id: Some(rule.to_string()),
                    file: Some(path.to_path_buf()),
                    line: Some(line_no),
                    reason: Some("inline".into()),
                    pattern: None,
                });
                // also suppress next line (common comment-above style)
                out.push(Suppression {
                    rule_id: Some(rule.to_string()),
                    file: Some(path.to_path_buf()),
                    line: Some(line_no + 1),
                    reason: Some("inline-next".into()),
                    pattern: None,
                });
            }
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::types::{Confidence, LanguageId, Severity, SourceLocation};

    #[test]
    fn glob_double_star_suffix() {
        assert!(path_matches_glob("crates/foo/src/lib.rs", "**/src/lib.rs"));
        assert!(path_matches_glob("a/b/target/x", "**/target/**"));
    }

    #[test]
    fn suppress_by_rule_and_line() {
        let finding = Finding::builder("ignored-failure", LanguageId::rust())
            .severity(Severity::High)
            .confidence(Confidence::High)
            .location(SourceLocation::single_line("src/a.rs", 10, 1))
            .explanation("x")
            .failure_scenario("y")
            .remediation("z")
            .build();
        let mut config = AnalysisConfig::default_for_rust();
        config.suppressions.push(Suppression {
            rule_id: Some("ignored-failure".into()),
            file: Some("src/a.rs".into()),
            line: Some(10),
            reason: None,
            pattern: None,
        });
        assert!(is_suppressed(&finding, &config));
    }
}