use std::collections::HashSet;
use std::path::Path;
use crate::analyzer::{Analyzer, Category, Finding, Severity};
use crate::config::Config;
use crate::git::ChangedFile;
use crate::scanner::{FileData, Language};
pub fn review(root: &Path, changed: &[ChangedFile], config: &Config) -> Vec<Finding> {
let analyzer = Analyzer::with_default_rules(config);
let mut findings = Vec::new();
for cf in changed {
let full = root.join(&cf.path);
let content = match std::fs::read_to_string(&full) {
Ok(c) => c,
Err(_) => continue, };
let added_lines: HashSet<usize> = cf.added.iter().map(|(n, _)| *n).collect();
let file = FileData {
path: cf.path.clone(),
lines: content.lines().count(),
language: Language::from_path(&full),
content,
};
for f in analyzer.analyze_files(std::slice::from_ref(&file)) {
if f.line > 0 && added_lines.contains(&f.line) {
findings.push(f);
}
}
for (line_no, text) in &cf.added {
if let Some(f) = concurrency_check(text, file.language, &cf.path, *line_no) {
findings.push(f);
}
}
}
findings.sort_by(|a, b| {
a.severity
.cmp(&b.severity)
.then_with(|| a.file.cmp(&b.file))
.then_with(|| a.line.cmp(&b.line))
});
findings
}
const STATEFUL_NAMES: &[&str] = &[
"balance",
"amount",
"funds",
"wallet",
"credit",
"credits",
"quantity",
"qty",
"stock",
"inventory",
"count",
"counter",
"total",
"points",
"score",
"supply",
];
const SYNC_MARKERS: &[&str] = &[
"lock",
"mutex",
"atomic",
"transaction",
"begin",
"commit",
"synchronized",
"fetch_sub",
"fetch_add",
"with_lock",
"select for update",
];
fn concurrency_check(line: &str, lang: Language, path: &str, line_no: usize) -> Option<Finding> {
let lower = line.to_ascii_lowercase();
if SYNC_MARKERS.iter().any(|m| lower.contains(m)) {
return None;
}
let mutated = mutated_stateful_var(&lower)?;
Some(Finding {
rule: "race-condition".to_string(),
title: "Possible race condition".to_string(),
category: Category::Security,
severity: Severity::Warning,
file: path.to_string(),
line: line_no,
message: format!(
"`{mutated}` is updated with a non-atomic read-modify-write and no visible lock/transaction. Concurrent updates can be lost."
),
suggestion: Some(match lang {
Language::Rust => {
"Guard the update with a Mutex, use an atomic type (fetch_add/fetch_sub), or a DB transaction.".to_string()
}
_ => "Wrap the update in a lock or an atomic database transaction (e.g. SELECT ... FOR UPDATE).".to_string(),
}),
})
}
fn mutated_stateful_var(lower: &str) -> Option<String> {
for op in ["-=", "+=", "*=", "/="] {
if let Some(idx) = lower.find(op) {
if let Some(name) = trailing_identifier(&lower[..idx]) {
if is_stateful(&name) {
return Some(name);
}
}
}
}
if let Some(eq) = find_plain_assignment(lower) {
let lhs = trailing_identifier(&lower[..eq])?;
let rhs = &lower[eq + 1..];
if is_stateful(&lhs) && rhs.contains(&lhs) && (rhs.contains('-') || rhs.contains('+')) {
return Some(lhs);
}
}
None
}
fn trailing_identifier(s: &str) -> Option<String> {
let s = s.trim_end();
let ident: String = s
.chars()
.rev()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect::<String>()
.chars()
.rev()
.collect();
if ident.is_empty() {
None
} else {
Some(ident)
}
}
fn is_stateful(name: &str) -> bool {
STATEFUL_NAMES.contains(&name)
}
fn find_plain_assignment(s: &str) -> Option<usize> {
let bytes = s.as_bytes();
for i in 0..bytes.len() {
if bytes[i] != b'=' {
continue;
}
let prev = if i > 0 { bytes[i - 1] } else { b' ' };
let next = if i + 1 < bytes.len() {
bytes[i + 1]
} else {
b' '
};
if next == b'=' || matches!(prev, b'=' | b'!' | b'<' | b'>' | b'+' | b'-' | b'*' | b'/') {
continue;
}
return Some(i);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flags_unguarded_balance_mutation() {
let f = concurrency_check(" balance -= amount;", Language::Rust, "pay.rs", 12).unwrap();
assert_eq!(f.rule, "race-condition");
assert_eq!(f.line, 12);
assert_eq!(f.severity, Severity::Warning);
}
#[test]
fn flags_self_referential_assignment() {
let f = concurrency_check("stock = stock - qty", Language::Python, "s.py", 3);
assert!(f.is_some());
}
#[test]
fn ignores_locked_mutation() {
assert!(concurrency_check(
"let _g = lock.lock(); balance -= amount;",
Language::Rust,
"p.rs",
1
)
.is_none());
assert!(concurrency_check(
"balance.fetch_sub(amount, Ordering::SeqCst);",
Language::Rust,
"p.rs",
1
)
.is_none());
}
#[test]
fn ignores_non_stateful_variables() {
assert!(concurrency_check("i += 1;", Language::Rust, "p.rs", 1).is_none());
assert!(concurrency_check("index -= 1;", Language::Rust, "p.rs", 1).is_none());
}
}