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::*;

pub(crate) fn analyse_concurrency_block(
    file: &SourceFile,
    block: &FunctionBlock,
    searchable_body: &str,
    findings: &mut Vec<Finding>,
) {
    if block.is_async {
        analyse_async_blocking_calls(file, block, searchable_body, findings);
        analyse_lock_across_await(file, block, searchable_body, findings);
    }

    if static_regex(
            &UNBOUNDED_CHANNEL_REGEX,
            r"\b(std::sync::mpsc::channel|mpsc::unbounded_channel|unbounded_channel)(?:\s*::\s*<[^>]+>)?\s*\(",
        )
            .is_match(searchable_body)
        {
            findings.push(block_finding_with_extras(
                BlockFindingDescriptor {
                    rule_id: "concurrency.unbounded-channel",
                    message: format!(
                        "Function `{}` creates an unbounded channel.",
                        block.name
                    ),
                    file,
                    block,
                    severity: Severity::Advisory,
                    pillar: Pillar::Maintainability,
                },
                BlockFindingExtras {
                    confidence: Confidence::Medium,
                    remediation: Some(
                        "Prefer a bounded channel or document the producer/consumer backpressure policy. If the unbounded channel is in a test harness, add the host path to `paths.ignore` in `.gruff-rs.yaml`."
                            .to_string(),
                    ),
                    metadata: json!({ "pattern": "unbounded-channel" }),
                },
            ));
        }
}

pub(crate) fn analyse_async_blocking_calls(
    file: &SourceFile,
    block: &FunctionBlock,
    searchable_body: &str,
    findings: &mut Vec<Finding>,
) {
    let blocking_patterns = [
        ("std::thread::sleep", "std::thread::sleep"),
        ("std::fs::read_to_string", "std::fs::read_to_string"),
        ("std::fs::read", "std::fs::read"),
        ("std::fs::write", "std::fs::write"),
        ("std::process::Command::new", "std::process::Command::new"),
    ];
    for (pattern, label) in blocking_patterns {
        if searchable_body.contains(pattern) {
            findings.push(block_finding_with_extras(
                    BlockFindingDescriptor {
                        rule_id: "concurrency.blocking-call-in-async",
                        message: format!(
                            "Async function `{}` calls blocking API `{label}`.",
                            block.name
                        ),
                        file,
                        block,
                        severity: Severity::Warning,
                        pillar: Pillar::Maintainability,
                    },
                    BlockFindingExtras {
                        confidence: Confidence::Medium,
                        remediation: Some(
                            "Use an async equivalent or move blocking work behind a dedicated blocking task."
                                .to_string(),
                        ),
                        metadata: json!({ "pattern": label }),
                    },
                ));
            break;
        }
    }
}

pub(crate) fn analyse_lock_across_await(
    file: &SourceFile,
    block: &FunctionBlock,
    searchable_body: &str,
    findings: &mut Vec<Finding>,
) {
    let lines: Vec<&str> = searchable_body.lines().collect();
    if let Some(guard) = find_lock_guard_held_across_await(&lines) {
        findings.push(lock_across_await_finding(file, block, &guard));
    }
}

fn find_lock_guard_held_across_await(lines: &[&str]) -> Option<String> {
    let lock_binding = static_regex(
        &LOCK_BINDING_REGEX,
        r"\blet\s+(?:mut\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?P<rhs>[^;]*\.(?:lock|read|write)\s*\([^;]*);",
    );
    let mut depth = 0usize;
    for (line_index, line) in lines.iter().enumerate() {
        let depth_before_line = depth;
        depth = brace_depth_after_line(depth, line);
        let Some(captures) = lock_binding.captures(line) else {
            continue;
        };
        let guard = captures
            .get(1)
            .map(|guard| guard.as_str())
            .unwrap_or("guard");
        let Some(rhs) = captures.name("rhs") else {
            continue;
        };
        if !rhs_is_lock_guard_binding(rhs.as_str()) {
            continue;
        }
        let later_lines = &lines[line_index + 1..];
        if is_guard_held_across_await(later_lines, guard, depth_before_line, depth) {
            return Some(guard.to_string());
        }
    }
    None
}

fn rhs_is_lock_guard_binding(rhs: &str) -> bool {
    static LOCK_CALL_REGEX: OnceLock<Regex> = OnceLock::new();
    let lock_call = static_regex(&LOCK_CALL_REGEX, r"\.(?:lock|read|write)\s*\([^)]*\)");
    let Some(found) = lock_call.find(rhs) else {
        return false;
    };
    lock_suffix_is_guard_preserving(&rhs[found.end()..])
}

fn lock_suffix_is_guard_preserving(mut suffix: &str) -> bool {
    loop {
        suffix = suffix.trim_start();
        if suffix.is_empty() {
            return true;
        }
        if let Some(rest) = suffix.strip_prefix(".await") {
            suffix = rest;
            continue;
        }
        if let Some(rest) = suffix.strip_prefix(".unwrap()") {
            suffix = rest;
            continue;
        }
        if let Some(rest) = strip_expect_suffix(suffix) {
            suffix = rest;
            continue;
        }
        if let Some(rest) = suffix.strip_prefix('?') {
            suffix = rest;
            continue;
        }
        return false;
    }
}

fn strip_expect_suffix(suffix: &str) -> Option<&str> {
    let rest = suffix.strip_prefix(".expect(")?;
    let close = rest.find(')')?;
    Some(&rest[close + 1..])
}

fn is_guard_held_across_await(
    later_lines: &[&str],
    guard: &str,
    scope_depth: usize,
    mut depth: usize,
) -> bool {
    let drop_call = format!("drop({guard})");
    for line in later_lines {
        if line.contains(&drop_call) {
            return false;
        }
        if line.contains(".await") {
            return depth >= scope_depth;
        }
        depth = brace_depth_after_line(depth, line);
        if depth < scope_depth {
            return false;
        }
    }
    false
}

fn brace_depth_after_line(mut depth: usize, line: &str) -> usize {
    for character in line.chars() {
        match character {
            '{' => depth += 1,
            '}' => depth = depth.saturating_sub(1),
            _ => {}
        }
    }
    depth
}

fn lock_across_await_finding(file: &SourceFile, block: &FunctionBlock, guard: &str) -> Finding {
    block_finding_with_extras(
        BlockFindingDescriptor {
            rule_id: "concurrency.lock-across-await",
            message: format!(
                "Async function `{}` appears to hold lock guard `{guard}` across await.",
                block.name
            ),
            file,
            block,
            severity: Severity::Warning,
            pillar: Pillar::Maintainability,
        },
        BlockFindingExtras {
            confidence: Confidence::Medium,
            remediation: Some(
                "Drop the guard before awaiting or use an async-aware lock.".to_string(),
            ),
            metadata: json!({ "guard": guard }),
        },
    )
}