use crate::detector::{Detector, Finding, Severity};
use crate::ir::{DamlModule, Expr, Statement};
fn has_positive_amount_check(body: &[Statement], param: &str) -> bool {
let mut found = false;
crate::ir::walk_unconditional_stmts(body, &mut |s| {
if let Statement::Assert { condition_expr, .. } = s {
if crate::ir::expr_guarantees_strict_positive(condition_expr, param) {
found = true;
}
}
});
found || has_defensive_amount_guard(body, param)
}
fn has_defensive_amount_guard(body: &[Statement], param: &str) -> bool {
let mut found = false;
crate::ir::walk_body_exprs(body, &mut |e| {
if found {
return;
}
if let Expr::App { func, args, .. } = e {
if let Expr::Var {
name,
qualifier: None,
..
} = func.as_ref()
{
match (name.as_str(), args.first(), args.get(1)) {
("when", Some(cond), Some(action))
if is_nonpositive_test(cond, param) && aborts(action) =>
{
found = true;
}
("unless", Some(cond), Some(action))
if is_strict_positive_test(cond, param) && aborts(action) =>
{
found = true;
}
_ => {}
}
}
}
});
if found {
return true;
}
crate::ir::walk_body_stmts(body, &mut |s| {
if let Statement::Branch {
scrutinee: Some(cond),
arms,
..
} = s
{
if arms.len() == 2
&& arms.iter().all(|a| a.pattern.is_none())
&& ((is_nonpositive_test(cond, param) && body_aborts(&arms[0].body))
|| (is_strict_positive_test(cond, param) && body_aborts(&arms[1].body)))
{
found = true;
}
}
});
found
}
fn body_aborts(body: &[Statement]) -> bool {
let mut found = false;
crate::ir::walk_body_exprs(body, &mut |e| {
if aborts(e) {
found = true;
}
});
found
}
fn is_nonpositive_test(c: &Expr, param: &str) -> bool {
match c {
Expr::BinOp { op, lhs, rhs, .. } => match op.as_str() {
"<=" => lhs.refers_to(param) && rhs.is_zero_lit(),
">=" => rhs.refers_to(param) && lhs.is_zero_lit(),
_ => false,
},
Expr::App { func, args, .. } => {
matches!(func.as_ref(), Expr::Var { name, .. } if name == "not")
&& args.len() == 1
&& is_strict_positive_test(&args[0], param)
}
_ => false,
}
}
fn is_strict_positive_test(c: &Expr, param: &str) -> bool {
crate::ir::is_strict_positive_bound(c, param)
}
fn aborts(action: &Expr) -> bool {
let mut found = false;
crate::ir::for_each_subexpr(action, &mut |e| {
if let Expr::Var {
name,
qualifier: None,
..
} = e
{
if matches!(name.as_str(), "abort" | "error" | "fail" | "assertFail") {
found = true;
}
}
});
found
}
fn has_nonempty_list_check(body: &[Statement], param: &str) -> bool {
let mut found = false;
crate::ir::walk_body_stmts(body, &mut |s| {
if let Statement::Assert { condition_expr, .. } = s {
if crate::ir::expr_guarantees_nonempty(condition_expr, param) {
found = true;
}
}
});
found
}
fn has_max_only_count_check(body: &[Statement], field: &str) -> bool {
let mut has_upper = false;
let mut has_lower = false;
crate::ir::walk_body_stmts(body, &mut |s| {
if let Statement::Assert { condition_expr, .. } = s {
if crate::ir::expr_has_size_upper_bound(condition_expr, field) {
has_upper = true;
}
if crate::ir::expr_guarantees_nonempty(condition_expr, field) {
has_lower = true;
}
}
});
has_upper && !has_lower
}
pub struct MissingPositiveAmount;
impl Detector for MissingPositiveAmount {
fn name(&self) -> &str {
"missing-positive-amount"
}
fn severity(&self) -> Severity {
Severity::High
}
fn description(&self) -> &str {
"Choice accepts amount/transfer parameter without positive-value or non-empty check"
}
fn detect(&self, module: &DamlModule) -> Vec<Finding> {
let mut findings = Vec::new();
for template in &module.templates {
for choice in &template.choices {
let amount_params: Vec<_> = choice
.parameters
.iter()
.filter(|p| {
p.daml_type.is_decimal()
&& (p.name.to_lowercase().contains("amount")
|| p.name.to_lowercase() == "quantity"
|| p.name.to_lowercase() == "price")
})
.collect();
for param in &amount_params {
let has_check = has_positive_amount_check(&choice.body, ¶m.name);
if !has_check {
findings.push(Finding {
detector: self.name().to_string(),
severity: self.severity(),
file: choice.span.file.clone(),
line: choice.span.line,
column: choice.span.column,
message: format!(
"Choice '{}' accepts Decimal parameter '{}' without asserting > 0.",
choice.name, param.name
),
evidence: format!(
"{} : Decimal -- no positive-amount check",
param.name
),
});
}
}
let list_params: Vec<_> = choice
.parameters
.iter()
.filter(|p| {
p.daml_type.is_list()
&& (p.name.to_lowercase().contains("input")
|| p.name.to_lowercase().contains("holding")
|| p.name.to_lowercase().contains("cids"))
})
.collect();
for param in &list_params {
let has_nonempty_check = has_nonempty_list_check(&choice.body, ¶m.name);
if !has_nonempty_check {
findings.push(Finding {
detector: self.name().to_string(),
severity: self.severity(),
file: choice.span.file.clone(),
line: choice.span.line,
column: choice.span.column,
message: format!(
"Choice '{}' accepts list parameter '{}' but has no minimum-length check.",
choice.name, param.name
),
evidence: format!("No 'not $ null {}' or min-length check", param.name),
});
}
}
for field in ["transfer.inputHoldingCids", "transfer.inputs"] {
if has_max_only_count_check(&choice.body, field) {
findings.push(Finding {
detector: self.name().to_string(),
severity: self.severity(),
file: choice.span.file.clone(),
line: choice.span.line,
column: choice.span.column,
message: format!(
"Choice '{}' checks max input count but not min. Empty inputs allowed.",
choice.name,
),
evidence: format!(
"Bounds '{field}' from above but never asserts it is non-empty"
),
});
}
}
}
}
findings
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse_daml;
use std::path::Path;
#[test]
fn test_missing_positive_amount_triggers() {
let source = r#"module Test where
template Token
with
owner : Party
where
signatory owner
choice Transfer : ContractId Token
with
amount : Decimal
newOwner : Party
controller owner
do
create this with owner = newOwner
"#;
let module = parse_daml(source, Path::new("Token.daml"));
let findings = MissingPositiveAmount.detect(&module);
assert!(!findings.is_empty());
assert!(findings[0].message.contains("amount"));
}
#[test]
fn test_positive_amount_check_passes() {
let source = r#"module Test where
template Token
with
owner : Party
where
signatory owner
choice Transfer : ContractId Token
with
amount : Decimal
newOwner : Party
controller owner
do
assertMsg "amount must be positive" (amount > 0.0)
create this with owner = newOwner
"#;
let module = parse_daml(source, Path::new("Token.daml"));
let findings = MissingPositiveAmount.detect(&module);
assert!(findings.is_empty());
}
fn choice_with_amount_guard(guard_line: &str, ty: &str) -> Vec<Finding> {
let source = format!(
r#"module Test where
template Token
with
owner : Party
where
signatory owner
choice Transfer : ()
with
amount : {ty}
controller owner
do
{guard_line}
pure ()
"#
);
let module = parse_daml(&source, Path::new("Token.daml"));
MissingPositiveAmount.detect(&module)
}
#[test]
fn test_ge_zero_amount_is_flagged() {
assert!(
!choice_with_amount_guard("assertMsg \"nn\" (amount >= 0.0)", "Decimal").is_empty()
);
}
#[test]
fn test_flipped_positive_guard_passes() {
assert!(choice_with_amount_guard("assertMsg \"pos\" (0.0 < amount)", "Decimal").is_empty());
}
#[test]
fn test_substring_param_guard_does_not_suppress() {
assert!(!choice_with_amount_guard("assertMsg \"x\" (xamount > 0.0)", "Decimal").is_empty());
}
#[test]
fn test_comment_guard_does_not_suppress() {
assert!(
!choice_with_amount_guard("-- amount > 0 is checked elsewhere", "Decimal").is_empty()
);
}
#[test]
fn test_numeric_amount_is_checked() {
assert!(!choice_with_amount_guard("pure ()", "Numeric 10").is_empty());
}
#[test]
fn test_when_nonpositive_abort_is_a_guard() {
assert!(choice_with_amount_guard(
"when (amount <= 0.0) (abort \"must be positive\")",
"Decimal"
)
.is_empty());
}
#[test]
fn test_unless_positive_abort_is_a_guard() {
assert!(
choice_with_amount_guard("unless (amount > 0.0) (abort \"bad\")", "Decimal").is_empty()
);
}
#[test]
fn test_when_strict_negative_abort_is_not_enough() {
assert!(
!choice_with_amount_guard("when (amount < 0.0) (abort \"bad\")", "Decimal").is_empty()
);
}
#[test]
fn test_if_nonpositive_abort_is_a_guard() {
assert!(choice_with_amount_guard(
"if amount <= 0.0 then abort \"bad\" else pure ()",
"Decimal"
)
.is_empty());
}
#[test]
fn test_extra_whitespace_guard_passes() {
assert!(
choice_with_amount_guard("assertMsg \"pos\" (amount > 0.0)", "Decimal").is_empty()
);
}
#[test]
fn test_positive_floor_guard_passes() {
assert!(
choice_with_amount_guard("assertMsg \"floor\" (amount >= 0.01)", "Decimal").is_empty()
);
}
#[test]
fn test_non_asserting_mention_does_not_suppress() {
assert!(
!choice_with_amount_guard("let isPos = amount > 0.0", "Decimal").is_empty(),
"a let-binding that merely computes amount > 0 is not a guard"
);
}
#[test]
fn test_qualified_assert_is_a_positive_guard() {
assert!(
choice_with_amount_guard("DA.Assert.assertMsg \"p\" (amount > 0.0)", "Decimal")
.is_empty(),
"DA.Assert.assertMsg is the stdlib guard and must suppress"
);
assert!(
choice_with_amount_guard("Assert.assertMsg \"p\" (amount > 0.0)", "Decimal").is_empty(),
"Assert.assertMsg is the stdlib guard and must suppress"
);
}
#[test]
fn test_conditional_when_assert_does_not_suppress() {
assert!(
!choice_with_amount_guard("when isLarge (assertMsg \"p\" (amount > 0.0))", "Decimal")
.is_empty(),
"an assert gated on `when isLarge` leaves the False path unchecked"
);
}
#[test]
fn test_conditional_if_then_assert_does_not_suppress() {
let source = r#"module Test where
template Token
with
owner : Party
where
signatory owner
choice Transfer : ()
with
amount : Decimal
flag : Bool
controller owner
do
if flag
then assertMsg "pos" (amount > 0.0)
else pure ()
create this with owner = owner
"#;
let module = parse_daml(source, Path::new("Token.daml"));
assert!(
!MissingPositiveAmount.detect(&module).is_empty(),
"an assert only on the then-arm of `if flag` does not guarantee amount > 0"
);
}
#[test]
fn test_if_nonpositive_abort_both_paths_is_a_guard() {
assert!(
choice_with_amount_guard(
"if amount <= 0.0 then abort \"bad\" else pure ()",
"Decimal"
)
.is_empty(),
"rejecting amount <= 0 on the abort path is a real guard"
);
}
fn choice_with_list_guard(guard_line: &str, extra_param: &str) -> Vec<Finding> {
let source = format!(
r#"module Test where
template Batch
with
owner : Party
where
signatory owner
choice Exec : ()
with
inputHoldingCids : [ContractId Token]
{extra_param} controller owner
do
{guard_line}
pure ()
"#
);
let module = parse_daml(&source, Path::new("Batch.daml"));
MissingPositiveAmount.detect(&module)
}
#[test]
fn test_list_upper_bound_does_not_suppress() {
assert!(
!choice_with_list_guard("assertMsg \"max\" (length inputHoldingCids < 10)", "")
.is_empty(),
"`length p < 10` is an upper bound; the empty list still passes"
);
assert!(
!choice_with_list_guard("assertMsg \"max\" (length inputHoldingCids <= 10)", "")
.is_empty(),
"`length p <= 10` is an upper bound; the empty list still passes"
);
}
#[test]
fn test_list_strict_lower_bound_passes() {
assert!(
choice_with_list_guard("assertMsg \"ne\" (length inputHoldingCids > 0)", "").is_empty(),
"`length p > 0` proves non-empty"
);
}
#[test]
fn test_superstring_list_check_does_not_suppress() {
let findings = choice_with_list_guard(
"assertMsg \"ne\" (not (null inputHoldingCidsBackup))",
" inputHoldingCidsBackup : [ContractId Token]\n",
);
assert!(
findings
.iter()
.any(|f| f.message.contains("inputHoldingCids")
&& f.message.contains("minimum-length")),
"a check on inputHoldingCidsBackup does not guard inputHoldingCids"
);
}
#[test]
fn test_not_null_passes() {
assert!(
choice_with_list_guard("assertMsg \"ne\" (not (null inputHoldingCids))", "").is_empty()
);
assert!(
choice_with_list_guard("assertMsg \"ne\" (not $ null inputHoldingCids)", "").is_empty()
);
}
fn settlement_with_guard(guard_line: &str) -> Vec<Finding> {
let source = format!(
r#"module Test where
template Settlement
with
owner : Party
transfer : TransferData
where
signatory owner
choice Execute : ()
with
ctx : Context
controller owner
do
{guard_line}
pure ()
"#
);
let module = parse_daml(&source, Path::new("Settlement.daml"));
MissingPositiveAmount.detect(&module)
}
#[test]
fn test_transfer_length_max_only_is_flagged() {
assert!(
!settlement_with_guard(
"assertMsg \"max\" (length transfer.inputHoldingCids < maxNumInputs)"
)
.is_empty(),
"`length transfer.inputHoldingCids < maxNumInputs` is max-only"
);
}
#[test]
fn test_transfer_size_max_only_is_flagged() {
assert!(!settlement_with_guard(
"assertMsg \"max\" (maxNumInputs > size transfer.inputHoldingCids)"
)
.is_empty());
}
#[test]
fn test_transfer_min_and_max_is_clean() {
assert!(
settlement_with_guard(
"assertMsg \"ne\" (length transfer.inputHoldingCids > 0 && length transfer.inputHoldingCids < maxNumInputs)"
)
.is_empty(),
"a real `length > 0` lower bound alongside the max check is safe"
);
}
}