use super::{AnalysisContext, CfgAnalysis, CfgFinding, Confidence, is_sink};
use crate::cfg::{EdgeKind, StmtKind};
use crate::patterns::Severity;
use petgraph::graph::NodeIndex;
use petgraph::visit::EdgeRef;
fn is_error_var_ident(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
if lower == "err" || lower == "error" {
return true;
}
if lower.starts_with("err_") || lower.starts_with("error_") {
return true;
}
if lower.ends_with("_err") || lower.ends_with("_error") {
return true;
}
false
}
fn contains_negated_err_identifier(text: &str) -> bool {
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'!' {
i += 1;
continue;
}
if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
i += 1;
continue;
}
let mut j = i + 1;
while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t') {
j += 1;
}
if j < bytes.len() && bytes[j] == b'(' {
j += 1;
while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t') {
j += 1;
}
}
let start = j;
while j < bytes.len() {
let b = bytes[j];
if b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || b == b'$' {
j += 1;
} else {
break;
}
}
if j > start {
let mut k = start;
while k + 2 < j {
if (bytes[k] | 0x20) == b'e'
&& (bytes[k + 1] | 0x20) == b'r'
&& (bytes[k + 2] | 0x20) == b'r'
{
return true;
}
k += 1;
}
}
i = if j > i { j } else { i + 1 };
}
false
}
pub struct IncompleteErrorHandling;
fn branch_terminates(cfg: &crate::cfg::Cfg, if_node: NodeIndex) -> bool {
let true_successors: Vec<NodeIndex> = cfg
.edges(if_node)
.filter(|e| matches!(e.weight(), EdgeKind::True))
.map(|e| e.target())
.collect();
if true_successors.is_empty() {
return false;
}
for &start in &true_successors {
if terminates_on_all_paths(cfg, start, if_node) {
return true;
}
}
false
}
fn call_never_returns(info: &crate::cfg::NodeInfo) -> bool {
if info.kind != StmtKind::Call {
return false;
}
let Some(callee) = info.call.callee.as_deref() else {
return false;
};
let last = callee.rsplit(['.', ':']).next().unwrap_or(callee);
if matches!(
last,
"Fatal" | "Fatalf" | "Fatalln" | "FailNow" |
"Panic" | "Panicf" | "Panicln" |
"abort" | "unreachable_unchecked"
) {
return true;
}
match callee {
"panic" => return true,
"os.Exit" | "syscall.Exit" | "runtime.Goexit" | "log.Fatal" | "log.Fatalf"
| "log.Fatalln" | "log.Panic" | "log.Panicf" | "log.Panicln" | "slog.Fatal"
| "klog.Fatal" | "klog.Fatalf" | "klog.Exit" | "klog.Exitf" => return true,
"process::exit" | "process::abort" | "std::process::exit" | "std::process::abort" => {
return true;
}
"sys.exit" | "os._exit" | "os.abort" => return true,
_ => {}
}
false
}
fn terminates_on_all_paths(
cfg: &crate::cfg::Cfg,
node: NodeIndex,
_scope_entry: NodeIndex,
) -> bool {
use std::collections::HashSet;
let mut visited = HashSet::new();
let mut stack = vec![node];
while let Some(current) = stack.pop() {
if !visited.insert(current) {
continue;
}
let info = &cfg[current];
match info.kind {
StmtKind::Return | StmtKind::Throw | StmtKind::Break | StmtKind::Continue => {
continue;
}
_ => {}
}
if call_never_returns(info) {
continue;
}
let successors: Vec<_> = cfg.neighbors(current).collect();
if successors.is_empty() {
return false;
}
for succ in successors {
let is_back_edge = cfg
.edges(current)
.any(|e| e.target() == succ && matches!(e.weight(), EdgeKind::Back));
if !is_back_edge {
stack.push(succ);
}
}
}
true
}
fn find_post_if_sinks(cfg: &crate::cfg::Cfg, if_node: NodeIndex) -> Vec<NodeIndex> {
let mut sinks_after = Vec::new();
let mut visited = std::collections::HashSet::new();
let mut stack: Vec<NodeIndex> = cfg
.edges(if_node)
.filter(|e| matches!(e.weight(), EdgeKind::False | EdgeKind::Seq))
.map(|e| e.target())
.collect();
while let Some(current) = stack.pop() {
if !visited.insert(current) {
continue;
}
let info = &cfg[current];
if is_sink(info) || (info.kind == StmtKind::Call && info.call.callee.is_some()) {
sinks_after.push(current);
}
for edge in cfg.edges(current) {
let succ = edge.target();
if matches!(edge.weight(), EdgeKind::Back | EdgeKind::Exception) {
continue;
}
stack.push(succ);
}
}
sinks_after
}
impl CfgAnalysis for IncompleteErrorHandling {
fn name(&self) -> &'static str {
"incomplete-error-handling"
}
fn run(&self, ctx: &AnalysisContext) -> Vec<CfgFinding> {
let mut findings = Vec::new();
for idx in ctx.cfg.node_indices() {
let info = &ctx.cfg[idx];
if info.kind != StmtKind::If {
continue;
}
let mentions_err = info.condition_vars.iter().any(|u| is_error_var_ident(u));
if !mentions_err {
continue;
}
if let Some(text) = info.condition_text.as_deref()
&& contains_negated_err_identifier(text)
{
continue;
}
if info.condition_negated {
continue;
}
if branch_terminates(ctx.cfg, idx) {
continue;
}
let post_sinks = find_post_if_sinks(ctx.cfg, idx);
let has_dangerous_successor = post_sinks.iter().any(|&s| is_sink(&ctx.cfg[s]));
if has_dangerous_successor {
findings.push(CfgFinding {
rule_id: "cfg-error-fallthrough".to_string(),
title: "Error check without return".to_string(),
severity: Severity::Medium,
confidence: Confidence::Medium,
span: info.ast.span,
message: "Error check does not terminate on error; \
execution falls through to dangerous operations"
.to_string(),
evidence: vec![idx],
score: None,
});
}
}
findings
}
}
#[cfg(test)]
mod negation_tests {
use super::contains_negated_err_identifier;
#[test]
fn detects_simple_negated_err() {
assert!(contains_negated_err_identifier("!err"));
assert!(contains_negated_err_identifier("!error"));
assert!(contains_negated_err_identifier("! err"));
}
#[test]
fn detects_negated_member_err() {
assert!(contains_negated_err_identifier("!data.error"));
assert!(contains_negated_err_identifier(
"data && !data.error && Array.isArray(results)"
));
assert!(contains_negated_err_identifier(
"!response.errorMsg && response.ok"
));
}
#[test]
fn does_not_match_inequality() {
assert!(!contains_negated_err_identifier("err != nil"));
assert!(!contains_negated_err_identifier("error !== null"));
}
#[test]
fn does_not_match_positive_err_checks() {
assert!(!contains_negated_err_identifier("err"));
assert!(!contains_negated_err_identifier("err != null"));
assert!(!contains_negated_err_identifier("response.error"));
assert!(!contains_negated_err_identifier("hasError(x)"));
}
}
#[cfg(test)]
mod err_ident_tests {
use super::is_error_var_ident;
#[test]
fn matches_canonical_error_vars() {
assert!(is_error_var_ident("err"));
assert!(is_error_var_ident("error"));
assert!(is_error_var_ident("ERR"));
assert!(is_error_var_ident("Error"));
}
#[test]
fn matches_snake_case_error_vars() {
assert!(is_error_var_ident("err_resp"));
assert!(is_error_var_ident("error_msg"));
assert!(is_error_var_ident("response_err"));
assert!(is_error_var_ident("parse_error"));
}
#[test]
fn rejects_camelcase_method_names() {
assert!(!is_error_var_ident("isErrorEnabled"));
assert!(!is_error_var_ident("getError"));
assert!(!is_error_var_ident("hasError"));
assert!(!is_error_var_ident("errorMsg"));
assert!(!is_error_var_ident("errCode"));
}
#[test]
fn rejects_unrelated_idents() {
assert!(!is_error_var_ident("user"));
assert!(!is_error_var_ident("merry"));
assert!(!is_error_var_ident("perform"));
}
}
#[cfg(test)]
mod terminator_call_tests {
use super::call_never_returns;
use crate::cfg::{CallMeta, NodeInfo, StmtKind};
fn call_node(callee: &str) -> NodeInfo {
NodeInfo {
kind: StmtKind::Call,
call: CallMeta {
callee: Some(callee.to_string()),
..Default::default()
},
..Default::default()
}
}
#[test]
fn recognises_go_testing_fatal_methods() {
assert!(call_never_returns(&call_node("c.Fatalf")));
assert!(call_never_returns(&call_node("t.Fatal")));
assert!(call_never_returns(&call_node("t.Fatalf")));
assert!(call_never_returns(&call_node("t.Fatalln")));
assert!(call_never_returns(&call_node("b.Fatal")));
assert!(call_never_returns(&call_node("t.FailNow")));
assert!(call_never_returns(&call_node("logger.Panic")));
assert!(call_never_returns(&call_node("logger.Panicf")));
}
#[test]
fn recognises_go_std_terminators() {
assert!(call_never_returns(&call_node("os.Exit")));
assert!(call_never_returns(&call_node("syscall.Exit")));
assert!(call_never_returns(&call_node("runtime.Goexit")));
assert!(call_never_returns(&call_node("log.Fatal")));
assert!(call_never_returns(&call_node("log.Fatalf")));
assert!(call_never_returns(&call_node("log.Fatalln")));
assert!(call_never_returns(&call_node("log.Panic")));
assert!(call_never_returns(&call_node("klog.Exit")));
assert!(call_never_returns(&call_node("panic")));
}
#[test]
fn recognises_rust_and_python_std_terminators() {
assert!(call_never_returns(&call_node("std::process::exit")));
assert!(call_never_returns(&call_node("std::process::abort")));
assert!(call_never_returns(&call_node("process::exit")));
assert!(call_never_returns(&call_node("sys.exit")));
assert!(call_never_returns(&call_node("os._exit")));
}
#[test]
fn does_not_claim_user_defined_lookalikes() {
assert!(!call_never_returns(&call_node("server.Exit")));
assert!(!call_never_returns(&call_node("Exit")));
assert!(!call_never_returns(&call_node("session.exit")));
assert!(!call_never_returns(&call_node("widget.panic")));
assert!(!call_never_returns(&call_node("log.Print")));
assert!(!call_never_returns(&call_node("log.Println")));
assert!(!call_never_returns(&call_node("t.Errorf")));
assert!(!call_never_returns(&call_node("t.Logf")));
assert!(!call_never_returns(&call_node("c.Skip")));
}
#[test]
fn requires_call_kind() {
let mut node = call_node("t.Fatal");
node.kind = StmtKind::Seq;
assert!(!call_never_returns(&node));
node.kind = StmtKind::If;
assert!(!call_never_returns(&node));
}
#[test]
fn missing_callee_does_not_panic() {
let node = NodeInfo {
kind: StmtKind::Call,
call: CallMeta {
callee: None,
..Default::default()
},
..Default::default()
};
assert!(!call_never_returns(&node));
}
}