use crate::detector::{Detector, Finding, Severity};
use crate::ir::{DamlModule, EnsureClause, Expr, SrcPos, Statement};
use std::collections::HashSet;
struct DenominatorScanContext<'a> {
ensure: Option<&'a EnsureClause>,
file: &'a std::path::Path,
context_name: &'a str,
}
pub struct UnguardedDivision;
impl UnguardedDivision {
fn check_body(
&self,
statements: &[Statement],
ensure: Option<&EnsureClause>,
file: &std::path::Path,
context_name: &str,
) -> Vec<Finding> {
let mut findings = Vec::new();
let scan_context = DenominatorScanContext {
ensure,
file,
context_name,
};
self.scan_stmts(statements, &HashSet::new(), &scan_context, &mut findings);
findings
}
fn scan_stmts(
&self,
statements: &[Statement],
guarded_denominator_keys: &HashSet<String>,
scan_context: &DenominatorScanContext<'_>,
findings: &mut Vec<Finding>,
) {
let mut current_guarded_keys = guarded_denominator_keys.clone();
for statement in statements {
for expr in crate::ir::statement_exprs(statement) {
self.scan_expr(expr, ¤t_guarded_keys, scan_context, findings);
}
match statement {
Statement::TryCatch {
try_body,
catch_body,
..
} => {
self.scan_stmts(try_body, ¤t_guarded_keys, scan_context, findings);
self.scan_stmts(catch_body, ¤t_guarded_keys, scan_context, findings);
}
Statement::Branch { arms, .. } => {
for arm in arms {
self.scan_stmts(&arm.body, ¤t_guarded_keys, scan_context, findings);
}
}
_ => {}
}
if let Statement::Assert { condition_expr, .. } = statement {
collect_nonzero_keys(condition_expr, &mut current_guarded_keys);
}
}
}
fn scan_expr(
&self,
expr: &Expr,
guarded_denominator_keys: &HashSet<String>,
scan_context: &DenominatorScanContext<'_>,
findings: &mut Vec<Finding>,
) {
if let Some((denominator_expr, span)) = division_denominator(expr) {
let denominator_expr = unwrap_numeric_wrapper(denominator_expr);
if !denominator_expr.is_nonzero_numeric_divisor() {
let denominator = denom_display(denominator_expr);
let is_guarded_by_enclosing_if = matches!(
denominator_expr.ref_string(),
Some(key) if guarded_denominator_keys.contains(&key)
);
let is_guarded_by_ensure = scan_context
.ensure
.is_some_and(|ec| ec.guarantees_nonzero(&denominator));
if !is_guarded_by_enclosing_if && !is_guarded_by_ensure {
findings.push(self.finding(
scan_context.file,
span.line,
&denominator,
scan_context.context_name,
&expr.render_text(),
));
}
}
}
match expr {
Expr::If {
cond,
then_branch,
else_branch,
..
} => {
self.scan_expr(cond, guarded_denominator_keys, scan_context, findings);
let mut then_guarded = guarded_denominator_keys.clone();
collect_nonzero_keys(cond, &mut then_guarded);
self.scan_expr(then_branch, &then_guarded, scan_context, findings);
let mut else_guarded = guarded_denominator_keys.clone();
collect_else_nonzero_keys(cond, &mut else_guarded);
self.scan_expr(else_branch, &else_guarded, scan_context, findings);
}
Expr::DoBlock { statements, .. } => {
self.scan_stmts(statements, guarded_denominator_keys, scan_context, findings)
}
_ => {
for child_expr in crate::ir::child_exprs(expr) {
self.scan_expr(child_expr, guarded_denominator_keys, scan_context, findings);
}
}
}
}
fn finding(
&self,
file: &std::path::Path,
line: usize,
denominator: &str,
context_name: &str,
evidence: &str,
) -> Finding {
Finding {
detector: self.name().to_string(),
severity: self.severity(),
file: file.to_path_buf(),
line,
column: 1,
message: format!(
"Unguarded division by '{}' — no prior > 0 check found in {}.",
denominator, context_name
),
evidence: evidence.to_string(),
}
}
}
fn collect_nonzero_keys(cond: &Expr, out: &mut HashSet<String>) {
for c in cond.conjuncts() {
if let Expr::BinOp { lhs, rhs, .. } = c {
for operand in [lhs.as_ref(), rhs.as_ref()] {
if let Some(k) = operand.ref_string() {
if crate::ir::is_nonzero_bound(c, &k) {
out.insert(k);
}
}
}
}
}
}
fn collect_else_nonzero_keys(cond: &Expr, out: &mut HashSet<String>) {
if let Expr::BinOp { op, lhs, rhs, .. } = cond {
if op == "==" {
if lhs.is_zero_lit() {
if let Some(k) = rhs.ref_string() {
out.insert(k);
}
} else if rhs.is_zero_lit() {
if let Some(k) = lhs.ref_string() {
out.insert(k);
}
}
}
}
}
fn division_denominator(e: &Expr) -> Option<(&Expr, &SrcPos)> {
match e {
Expr::BinOp { op, rhs, span, .. } if op == "/" || op == "`div`" => Some((rhs, span)),
Expr::App { func, args, span } if args.len() >= 2 => match func.as_ref() {
Expr::Var {
name,
qualifier: None,
..
} if name == "div" => Some((&args[1], span)),
_ => None,
},
_ => None,
}
}
fn denom_display(e: &Expr) -> String {
match e {
Expr::Var { .. } | Expr::Con { .. } | Expr::Lit { .. } => e.render_text(),
Expr::BinOp { op, .. } if op == "." => e.render_text(),
_ => format!("({})", e.render_text()),
}
}
fn unwrap_numeric_wrapper(e: &Expr) -> &Expr {
if let Expr::App { func, args, .. } = e {
if let Expr::Var { name, .. } = func.as_ref() {
if NUMERIC_WRAPPERS.contains(&name.as_str()) && args.len() == 1 {
return unwrap_numeric_wrapper(&args[0]);
}
}
}
e
}
const NUMERIC_WRAPPERS: [&str; 2] = ["intToDecimal", "intToNumeric"];
impl Detector for UnguardedDivision {
fn name(&self) -> &str {
"unguarded-division"
}
fn severity(&self) -> Severity {
Severity::High
}
fn description(&self) -> &str {
"Division without prior > 0 check on denominator"
}
fn detect(&self, module: &DamlModule) -> Vec<Finding> {
let mut findings = Vec::new();
for template in &module.templates {
let ensure = template.ensure_clause.as_ref();
for choice in &template.choices {
findings.extend(self.check_body(
&choice.body,
ensure,
&module.file,
&format!("choice '{}'", choice.name),
));
}
}
for func in &module.functions {
findings.extend(self.check_body(
&func.body,
None,
&module.file,
&format!("function '{}'", func.name),
));
}
findings
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse_daml;
use std::path::Path;
#[test]
fn test_unguarded_division_triggers() {
let source = r#"module Test where
scaleFees fees rate =
map (\f -> f with amount = f.amount * (1.0 / rate)) fees
"#;
let module = parse_daml(source, Path::new("AmuletRules.daml"));
let findings = UnguardedDivision.detect(&module);
assert!(!findings.is_empty());
}
#[test]
fn test_guarded_division_passes() {
let source = r#"module Test where
safeDivide x y = do
assertMsg "denominator must be positive" (y > 0)
pure (x / y)
"#;
let module = parse_daml(source, Path::new("Safe.daml"));
let findings = UnguardedDivision.detect(&module);
assert!(findings.is_empty());
}
#[test]
fn test_inttodecimal_wrapper_reports_real_denominator() {
let source = r#"module Test where
dayCount total n = total / intToDecimal n
"#;
let module = parse_daml(source, Path::new("DayCount.daml"));
let findings = UnguardedDivision.detect(&module);
assert_eq!(findings.len(), 1);
assert!(
findings[0].message.contains("'n'"),
"expected real denominator 'n', got: {}",
findings[0].message
);
}
#[test]
fn test_guarded_inttodecimal_division_passes() {
let source = r#"module Test where
dayCount total n = do
assertMsg "n must be positive" (n > 0)
pure (total / intToDecimal n)
"#;
let module = parse_daml(source, Path::new("DayCount.daml"));
let findings = UnguardedDivision.detect(&module);
assert!(
findings.is_empty(),
"guard on real denominator should suppress: {:?}",
findings
);
}
#[test]
fn test_guard_after_division_is_flagged() {
let source = r#"module Test where
unsafeDivide x y = do
pure (x / y)
assertMsg "denominator must be positive" (y > 0)
"#;
let module = parse_daml(source, Path::new("Late.daml"));
let findings = UnguardedDivision.detect(&module);
assert!(
!findings.is_empty(),
"a guard after the division must NOT suppress the finding"
);
}
#[test]
fn test_substring_guard_does_not_suppress() {
let source = r#"module Test where
compute x q = do
assertMsg "quantity" (quantity > 0)
pure (x / q)
"#;
let module = parse_daml(source, Path::new("Substr.daml"));
let findings = UnguardedDivision.detect(&module);
assert!(
!findings.is_empty(),
"a guard on `quantity` must not be read as a guard on `q`"
);
}
#[test]
fn test_ge_zero_is_not_a_guard() {
let source = r#"module Test where
divCheck x y = do
assertMsg "non-negative" (y >= 0)
pure (x / y)
"#;
let module = parse_daml(source, Path::new("GeZero.daml"));
let findings = UnguardedDivision.detect(&module);
assert!(
!findings.is_empty(),
"`>= 0` allows zero, so it must not suppress the division finding"
);
}
#[test]
fn test_second_division_on_line_is_flagged() {
let source = r#"module Test where
compute a b c d = do
assertMsg "b ok" (b > 0)
pure (a / b + c / d)
"#;
let module = parse_daml(source, Path::new("Multi.daml"));
let findings = UnguardedDivision.detect(&module);
assert!(
findings.iter().any(|f| f.message.contains("'d'")),
"the unguarded `c / d` must be flagged: {:?}",
findings
);
}
#[test]
fn test_literal_denominator_handling() {
let safe = parse_daml("module T where\nf x = x / 2.0\n", Path::new("Lit.daml"));
assert!(
UnguardedDivision.detect(&safe).is_empty(),
"x / 2.0 is safe"
);
let zero = parse_daml("module T where\nf x = x / 0\n", Path::new("Zero.daml"));
assert!(
!UnguardedDivision.detect(&zero).is_empty(),
"x / 0 must flag"
);
}
#[test]
fn test_slash_in_string_literal_is_not_division() {
let source = r#"module Test where
logUrl = debug "http://host/api/v1/data"
"#;
let module = parse_daml(source, Path::new("Url.daml"));
assert!(
UnguardedDivision.detect(&module).is_empty(),
"slashes inside a string literal are not divisions: {:?}",
UnguardedDivision.detect(&module)
);
}
#[test]
fn test_slash_in_comment_is_not_division() {
let source = r#"module Test where
f x = do
{- ratio a/b/c is documented elsewhere -}
pure x -- see n/m below
"#;
let module = parse_daml(source, Path::new("Cmt.daml"));
assert!(
UnguardedDivision.detect(&module).is_empty(),
"slashes inside comments are not divisions: {:?}",
UnguardedDivision.detect(&module)
);
}
#[test]
fn test_line_wrapped_division_is_flagged() {
let source = "module Test where\n\nratio a b = a /\n b\n";
let module = parse_daml(source, Path::new("Wrap.daml"));
assert!(
!UnguardedDivision.detect(&module).is_empty(),
"a line-wrapped `a / b` is still an unguarded division"
);
}
#[test]
fn test_parenthesized_literal_denominator_is_safe() {
let module = parse_daml("module T where\nf x = x / (2.0)\n", Path::new("P.daml"));
assert!(
UnguardedDivision.detect(&module).is_empty(),
"x / (2.0) is safe: {:?}",
UnguardedDivision.detect(&module)
);
}
#[test]
fn test_if_nonzero_guard_suppresses() {
let m = parse_daml(
"module T where\nf x denom = pure (if denom /= 0.0 then x / denom else 0.0)\n",
Path::new("If.daml"),
);
assert!(
UnguardedDivision.detect(&m).is_empty(),
"if denom /= 0 then x/denom guards the division: {:?}",
UnguardedDivision.detect(&m)
);
}
#[test]
fn test_if_zero_else_guard_suppresses() {
let m = parse_daml(
"module T where\nf x denom = pure (if denom == 0.0 then 0.0 else x / denom)\n",
Path::new("IfElse.daml"),
);
assert!(
UnguardedDivision.detect(&m).is_empty(),
"else-branch of `if denom == 0` has denom /= 0: {:?}",
UnguardedDivision.detect(&m)
);
}
#[test]
fn test_if_unrelated_condition_does_not_guard() {
let m = parse_daml(
"module T where\nf x denom flag = pure (if flag then x / denom else 0.0)\n",
Path::new("IfUnrel.daml"),
);
assert!(
!UnguardedDivision.detect(&m).is_empty(),
"`if flag` says nothing about denom — must still flag"
);
}
#[test]
fn test_prefix_div_is_flagged() {
let source = r#"module Test where
share total n = pure (div total n)
"#;
let module = parse_daml(source, Path::new("Share.daml"));
let findings = UnguardedDivision.detect(&module);
assert!(
findings.iter().any(|f| f.message.contains("'n'")),
"prefix `div total n` divides by n and must flag: {:?}",
findings
);
}
#[test]
fn test_guarded_prefix_div_passes() {
let source = r#"module Test where
share total n = do
assertMsg "n positive" (n > 0)
pure (div total n)
"#;
let module = parse_daml(source, Path::new("Share.daml"));
assert!(
UnguardedDivision.detect(&module).is_empty(),
"a `> 0` guard on n must suppress the prefix-div finding"
);
}
#[test]
fn test_prefix_div_literal_denominator_is_safe() {
let source = "module T where\nf x = pure (div x 2)\n";
let module = parse_daml(source, Path::new("Lit.daml"));
assert!(
UnguardedDivision.detect(&module).is_empty(),
"div x 2 is safe"
);
}
#[test]
fn test_ensure_clause_guards_choice_division() {
let source = r#"module Test where
template Pool
with
admin : Party
rate : Decimal
where
signatory admin
ensure rate > 0.0
choice Share : Decimal
with
total : Decimal
controller admin
do
pure (total / rate)
"#;
let module = parse_daml(source, Path::new("Pool.daml"));
assert!(
UnguardedDivision.detect(&module).is_empty(),
"ensure rate > 0.0 guards the division by rate: {:?}",
UnguardedDivision.detect(&module)
);
}
#[test]
fn test_disjunction_guard_does_not_suppress() {
let source = r#"module Test where
f x y = do
assertMsg "weak" (y > 0 || x > 5)
pure (x / y)
"#;
let module = parse_daml(source, Path::new("Or.daml"));
assert!(
!UnguardedDivision.detect(&module).is_empty(),
"`y > 0 || x > 5` does not guarantee y > 0, so the division must flag"
);
}
#[test]
fn test_same_line_guard_suppresses() {
let source = "module Test where\nf x y = do { assertMsg \"y\" (y > 0.0); pure (x / y) }\n";
let module = parse_daml(source, Path::new("SameLine.daml"));
assert!(
UnguardedDivision.detect(&module).is_empty(),
"a guard before the division on the same line must suppress: {:?}",
UnguardedDivision.detect(&module)
);
}
#[test]
fn test_same_line_guard_after_division_is_flagged() {
let source = "module Test where\nf x y = do { pure (x / y); assertMsg \"y\" (y > 0.0) }\n";
let module = parse_daml(source, Path::new("SameLineLate.daml"));
assert!(
!UnguardedDivision.detect(&module).is_empty(),
"a guard after the division on the same line must NOT suppress"
);
}
#[test]
fn test_negative_literal_denominator_is_safe() {
let infix = parse_daml("module T where\nf x = x / (-2.0)\n", Path::new("Neg.daml"));
assert!(
UnguardedDivision.detect(&infix).is_empty(),
"x / (-2.0) is safe: {:?}",
UnguardedDivision.detect(&infix)
);
let prefix = parse_daml(
"module T where\nf x = div x (-3)\n",
Path::new("NegDiv.daml"),
);
assert!(
UnguardedDivision.detect(&prefix).is_empty(),
"div x (-3) is safe: {:?}",
UnguardedDivision.detect(&prefix)
);
let neg_zero = parse_daml(
"module T where\nf x = x / (-0.0)\n",
Path::new("NegZero.daml"),
);
assert!(
!UnguardedDivision.detect(&neg_zero).is_empty(),
"x / (-0.0) divides by zero and must flag"
);
}
#[test]
fn test_conditional_if_guard_does_not_suppress() {
let m = parse_daml(
"module Test where\nf flag x y = do\n if flag\n then assertMsg \"y ok\" (y > 0.0)\n else pure ()\n pure (x / y)\n",
Path::new("CondIf.daml"),
);
assert!(
!UnguardedDivision.detect(&m).is_empty(),
"an assert only on the then-path must not suppress the later x / y"
);
let mc = parse_daml(
"module Test where\nf k x y = do\n case k of\n _ -> assertMsg \"y\" (y > 0.0)\n pure (x / y)\n",
Path::new("CondCase.daml"),
);
assert!(
!UnguardedDivision.detect(&mc).is_empty(),
"an assert only in a case alt must not suppress the later x / y"
);
}
#[test]
fn test_branch_arm_guard_suppresses_division_in_same_arm() {
let source = r#"module Test where
f flag x y =
if flag then do
assertMsg "y" (y /= 0.0)
pure (x / y)
else
pure 0.0
"#;
let module = parse_daml(source, Path::new("BranchGuard.daml"));
assert!(
UnguardedDivision.detect(&module).is_empty(),
"a guard in the same branch arm dominates that arm's division: {:?}",
UnguardedDivision.detect(&module)
);
}
#[test]
fn test_iterative_or_conditional_guard_does_not_suppress() {
let for_a = parse_daml(
"module Test where\nf x y items = do\n forA_ items (\\i -> assertMsg \"y\" (y > 0.0))\n pure (x / y)\n",
Path::new("ForA.daml"),
);
assert!(
!UnguardedDivision.detect(&for_a).is_empty(),
"an assert inside a forA_ lambda runs zero times on []; must still flag"
);
let when_gated = parse_daml(
"module Test where\nf x y b = do\n when b (assertMsg \"y\" (y > 0.0))\n pure (x / y)\n",
Path::new("WhenG.daml"),
);
assert!(
!UnguardedDivision.detect(&when_gated).is_empty(),
"an assert under `when` runs only when the guard holds; must still flag"
);
}
#[test]
fn test_unconditional_guard_before_branch_division_suppresses() {
let m = parse_daml(
"module Test where\nf x y b = do\n assertMsg \"y\" (y > 0.0)\n if b then pure (x / y) else pure 0.0\n",
Path::new("Dom.daml"),
);
assert!(
UnguardedDivision.detect(&m).is_empty(),
"an unconditional guard dominating the branch must suppress: {:?}",
UnguardedDivision.detect(&m)
);
}
#[test]
fn test_parenthesized_denominator_reported_whole() {
let module = parse_daml(
"module T where\nf x y = x / (y + 1)\n",
Path::new("Paren.daml"),
);
let findings = UnguardedDivision.detect(&module);
assert!(
findings.iter().any(|f| f.message.contains("(y + 1)")),
"expected full parenthesized denominator, got: {:?}",
findings
);
}
}