gruff-rs 0.4.0

Rust static analyzer and quality linter for CI: dead-code, complexity, security, secrets, and architecture rules with deterministic SARIF/JSON output and baseline support.
use super::*;

const CUSTOM_RULE_KEYS: &[&str] = &[
    "id",
    "pillar",
    "severity",
    "confidence",
    "message",
    "scope",
    "pattern",
    "include_paths",
    "exclude_paths",
    "remediation",
];

struct CustomRuleIdentity {
    id: String,
    message: String,
}

struct CustomRuleClassification {
    pillar: Pillar,
    severity: Severity,
    confidence: Confidence,
}

struct CustomRulePattern {
    scope: CustomRuleScope,
    pattern: String,
    compiled_pattern: Regex,
}

struct CustomRulePathsAndDoc {
    include_paths: Vec<String>,
    exclude_paths: Vec<String>,
    remediation: Option<String>,
}

pub(crate) fn parse_custom_rule(
    index: usize,
    entry_value: &Value,
    registry: &rules::RuleRegistry,
) -> Result<CustomRule, String> {
    let entry_path = format!("custom_rules[{index}]");
    let entry = entry_value
        .as_object()
        .ok_or_else(|| format!("config key `{entry_path}` must be an object"))?;
    reject_unknown_keys(
        entry,
        CUSTOM_RULE_KEYS,
        &format!("config key `{entry_path}`"),
    )?;

    let identity = parse_custom_rule_identity(entry, &entry_path, registry)?;
    let classification = parse_custom_rule_classification(entry, &entry_path)?;
    let pattern = parse_custom_rule_pattern(entry, &entry_path)?;
    let paths_and_doc = parse_custom_rule_paths_and_doc(entry, &entry_path)?;

    Ok(CustomRule {
        id: identity.id,
        message: identity.message,
        pillar: classification.pillar,
        severity: classification.severity,
        confidence: classification.confidence,
        scope: pattern.scope,
        pattern: pattern.pattern,
        compiled_pattern: pattern.compiled_pattern,
        include_path_matchers: compile_path_matchers(&paths_and_doc.include_paths),
        exclude_path_matchers: compile_path_matchers(&paths_and_doc.exclude_paths),
        remediation: paths_and_doc.remediation,
    })
}

fn parse_custom_rule_identity(
    entry: &serde_json::Map<String, Value>,
    entry_path: &str,
    registry: &rules::RuleRegistry,
) -> Result<CustomRuleIdentity, String> {
    let id = required_config_string(entry, "id", &format!("{entry_path}.id"))?;
    validate_custom_rule_id(&id, &format!("{entry_path}.id"), registry)?;
    let message = required_non_empty_config_string(entry, "message", entry_path)?;
    Ok(CustomRuleIdentity { id, message })
}

fn parse_custom_rule_classification(
    entry: &serde_json::Map<String, Value>,
    entry_path: &str,
) -> Result<CustomRuleClassification, String> {
    let pillar = parse_required_pillar(entry, "pillar", &format!("{entry_path}.pillar"))?;
    let severity = parse_required_severity(entry, "severity", &format!("{entry_path}.severity"))?;
    let confidence = entry
        .get("confidence")
        .map(|value| parse_custom_confidence(value, &format!("{entry_path}.confidence")))
        .transpose()?
        .unwrap_or(Confidence::Medium);
    Ok(CustomRuleClassification {
        pillar,
        severity,
        confidence,
    })
}

fn parse_custom_rule_pattern(
    entry: &serde_json::Map<String, Value>,
    entry_path: &str,
) -> Result<CustomRulePattern, String> {
    let scope = parse_custom_rule_scope(
        &required_config_string(entry, "scope", &format!("{entry_path}.scope"))?,
        &format!("{entry_path}.scope"),
    )?;
    let pattern = required_non_empty_config_string(entry, "pattern", entry_path)?;
    let compiled_pattern = Regex::new(&pattern).map_err(|error| {
        format!("config key `{entry_path}.pattern` failed to compile regex: {error}")
    })?;
    Ok(CustomRulePattern {
        scope,
        pattern,
        compiled_pattern,
    })
}

fn parse_custom_rule_paths_and_doc(
    entry: &serde_json::Map<String, Value>,
    entry_path: &str,
) -> Result<CustomRulePathsAndDoc, String> {
    let include_paths = optional_normalized_string_array(
        entry,
        "include_paths",
        &format!("{entry_path}.include_paths"),
    )?;
    let exclude_paths = optional_normalized_string_array(
        entry,
        "exclude_paths",
        &format!("{entry_path}.exclude_paths"),
    )?;
    let remediation = entry
        .get("remediation")
        .map(|value| {
            value
                .as_str()
                .map(str::to_string)
                .ok_or_else(|| format!("config key `{entry_path}.remediation` must be a string"))
        })
        .transpose()?;
    Ok(CustomRulePathsAndDoc {
        include_paths,
        exclude_paths,
        remediation,
    })
}

pub(crate) fn validate_custom_rule_id(
    id: &str,
    path: &str,
    registry: &rules::RuleRegistry,
) -> Result<(), String> {
    let Some(slug) = id.strip_prefix("custom.") else {
        return Err(format!(
            "config key `{path}` must start with the reserved `custom.` namespace"
        ));
    };
    if slug.is_empty()
        || slug.starts_with('-')
        || slug.ends_with('-')
        || !slug.chars().all(|character| {
            character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
        })
    {
        return Err(format!(
            "config key `{path}` must use `custom.<slug>` with lowercase ASCII letters, digits, and hyphens"
        ));
    }
    if registry.contains(id) {
        return Err(format!(
            "config key `{path}` collides with built-in rule id `{id}`"
        ));
    }
    Ok(())
}

pub(crate) fn parse_required_pillar(
    object: &serde_json::Map<String, Value>,
    key: &str,
    path: &str,
) -> Result<Pillar, String> {
    let pillar = required_config_string(object, key, path)?;
    parse_pillar_selector(&pillar).ok_or_else(|| {
        format!(
            "unknown pillar `{pillar}` in `{path}`; expected a public pillar such as Documentation"
        )
    })
}

pub(crate) fn parse_required_severity(
    object: &serde_json::Map<String, Value>,
    key: &str,
    path: &str,
) -> Result<Severity, String> {
    let severity = required_config_string(object, key, path)?;
    parse_severity_name(&severity).ok_or_else(|| {
        format!("unknown severity `{severity}` in `{path}`; expected advisory, warning, or error")
    })
}

pub(crate) fn parse_severity_name(value: &str) -> Option<Severity> {
    match value.trim().to_ascii_lowercase().as_str() {
        "advisory" => Some(Severity::Advisory),
        "warning" => Some(Severity::Warning),
        "error" => Some(Severity::Error),
        _ => None,
    }
}

pub(crate) fn parse_custom_confidence(value: &Value, path: &str) -> Result<Confidence, String> {
    let number = value
        .as_f64()
        .ok_or_else(|| format!("config key `{path}` must be a number from 0.0 to 1.0"))?;
    if !(0.0..=1.0).contains(&number) {
        return Err(format!("config key `{path}` must be between 0.0 and 1.0"));
    }
    if number >= 0.85 {
        Ok(Confidence::High)
    } else if number >= 0.5 {
        Ok(Confidence::Medium)
    } else {
        Ok(Confidence::Low)
    }
}

pub(crate) fn parse_custom_rule_scope(value: &str, path: &str) -> Result<CustomRuleScope, String> {
    match value.trim() {
        "text" => Ok(CustomRuleScope::Text),
        "rust-code" => Ok(CustomRuleScope::RustCode),
        "comments" => Ok(CustomRuleScope::Comments),
        other => Err(format!(
            "unknown custom rule scope `{other}` in `{path}`; expected text, rust-code, or comments"
        )),
    }
}

pub(crate) fn optional_normalized_string_array(
    object: &serde_json::Map<String, Value>,
    key: &str,
    path: &str,
) -> Result<Vec<String>, String> {
    object
        .get(key)
        .map(|value| {
            string_array(value, path).map(|paths| {
                paths
                    .into_iter()
                    .map(|path| normalize_report_path(&path))
                    .collect()
            })
        })
        .transpose()
        .map(Option::unwrap_or_default)
}