use crate::detector::{Detector, Finding, Severity};
use crate::ir::DamlModule;
pub struct MissingEnsureDecimal;
impl Detector for MissingEnsureDecimal {
fn name(&self) -> &str {
"missing-ensure-decimal"
}
fn severity(&self) -> Severity {
Severity::High
}
fn description(&self) -> &str {
"Template has Decimal field with no positivity bound in its ensure clause"
}
fn detect(&self, module: &DamlModule) -> Vec<Finding> {
let mut findings = Vec::new();
for template in &module.templates {
let decimal_fields: Vec<_> = template
.fields
.iter()
.filter(|f| f.daml_type.is_decimal())
.collect();
if decimal_fields.is_empty() {
continue;
}
for field in &decimal_fields {
let has_bound = template
.ensure_clause
.as_ref()
.is_some_and(|ec| ec.has_positive_bound(&field.name));
if !has_bound {
let evidence = if template.ensure_clause.is_none() {
format!("{} : Decimal -- no ensure clause found", field.name)
} else {
format!(
"{} : Decimal -- ensure clause does not bound this field",
field.name
)
};
findings.push(Finding {
detector: self.name().to_string(),
severity: self.severity(),
file: template.span.file.clone(),
line: template.span.line,
column: template.span.column,
message: format!(
"Template '{}' has Decimal field '{}' with no positivity bound (e.g. `{} > 0`) in its ensure clause.",
template.name, field.name, field.name
),
evidence,
});
}
}
}
findings
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse_daml;
use std::path::Path;
#[test]
fn test_missing_ensure_decimal_triggers() {
let source = r#"module Test where
template OpenMiningRound
with
admin : Party
amuletPrice : Decimal
tickDuration : Decimal
where
signatory admin
"#;
let module = parse_daml(source, Path::new("Round.daml"));
let findings = MissingEnsureDecimal.detect(&module);
assert_eq!(findings.len(), 2);
assert!(findings[0].message.contains("amuletPrice"));
assert!(findings[1].message.contains("tickDuration"));
}
#[test]
fn test_ensure_decimal_passes_with_bound() {
let source = r#"module Test where
template SimpleHolding
with
admin : Party
amount : Decimal
where
signatory admin
ensure amount > 0.0
"#;
let module = parse_daml(source, Path::new("Holding.daml"));
let findings = MissingEnsureDecimal.detect(&module);
assert!(findings.is_empty());
}
#[test]
fn test_ensure_exists_but_doesnt_bound_field() {
let source = r#"module Test where
template RoundWithPartialEnsure
with
admin : Party
amuletPrice : Decimal
tickDuration : Decimal
where
signatory admin
ensure tickDuration > 0.0
"#;
let module = parse_daml(source, Path::new("Round.daml"));
let findings = MissingEnsureDecimal.detect(&module);
assert_eq!(findings.len(), 1);
assert!(findings[0].message.contains("amuletPrice"));
}
#[test]
fn test_numeric_field_is_flagged() {
let source = r#"module Test where
template Round
with
admin : Party
price : Numeric 10
where
signatory admin
"#;
let module = parse_daml(source, Path::new("Round.daml"));
let findings = MissingEnsureDecimal.detect(&module);
assert!(findings.iter().any(|f| f.message.contains("'price'")));
}
#[test]
fn test_negated_bound_does_not_count() {
let source = r#"module Test where
template T
with
admin : Party
amount : Decimal
where
signatory admin
ensure not (amount > 0.0)
"#;
let module = parse_daml(source, Path::new("T.daml"));
let findings = MissingEnsureDecimal.detect(&module);
assert!(
findings.iter().any(|f| f.message.contains("'amount'")),
"negated bound must not satisfy positivity: {:?}",
findings
);
}
#[test]
fn test_positive_constant_lower_bound_passes() {
let tmpl = |ensure: &str| {
format!(
"module T where\n\ntemplate M\n with\n owner : Party\n amount : Decimal\n where\n signatory owner\n ensure {}\n",
ensure
)
};
let ok = parse_daml(&tmpl("amount > 100.0"), Path::new("M.daml"));
assert!(
!MissingEnsureDecimal
.detect(&ok)
.iter()
.any(|f| f.message.contains("'amount'")),
"amount > 100.0 bounds amount positive: {:?}",
MissingEnsureDecimal.detect(&ok)
);
let neg = parse_daml(&tmpl("amount > -5.0"), Path::new("M.daml"));
assert!(
MissingEnsureDecimal
.detect(&neg)
.iter()
.any(|f| f.message.contains("'amount'")),
"a negative lower bound does not guarantee positivity: {:?}",
MissingEnsureDecimal.detect(&neg)
);
}
#[test]
fn test_equality_to_positive_constant_passes() {
let tmpl = |ensure: &str| {
format!(
"module T where\n\ntemplate M\n with\n owner : Party\n amount : Decimal\n where\n signatory owner\n ensure {}\n",
ensure
)
};
let flags = |ensure: &str| {
let m = parse_daml(&tmpl(ensure), Path::new("M.daml"));
MissingEnsureDecimal
.detect(&m)
.iter()
.any(|f| f.message.contains("'amount'"))
};
assert!(
!flags("amount == 5.0"),
"amount == 5.0 pins amount positive"
);
assert!(
!flags("5.0 == amount"),
"5.0 == amount pins amount positive"
);
assert!(flags("amount == -5.0"), "== -5.0 is negative, still flags");
assert!(flags("amount == 0.0"), "== 0.0 admits zero, still flags");
}
#[test]
fn test_disjunction_bound_does_not_count() {
let source = r#"module Test where
template T
with
admin : Party
amount : Decimal
flag : Bool
where
signatory admin
ensure flag || amount > 0.0
"#;
let module = parse_daml(source, Path::new("T.daml"));
let findings = MissingEnsureDecimal.detect(&module);
assert!(
findings.iter().any(|f| f.message.contains("'amount'")),
"a bound under || is not guaranteed: {:?}",
findings
);
}
#[test]
fn test_conjunction_bound_counts() {
let source = r#"module Test where
template T
with
admin : Party
amount : Decimal
where
signatory admin
ensure amount > 0.0 && admin == admin
"#;
let module = parse_daml(source, Path::new("T.daml"));
let findings = MissingEnsureDecimal.detect(&module);
assert!(
!findings.iter().any(|f| f.message.contains("'amount'")),
"amount > 0.0 under && IS a bound: {:?}",
findings
);
}
#[test]
fn test_substring_field_not_considered_bounded() {
let source = r#"module Test where
template T
with
admin : Party
count : Decimal
discount : Decimal
where
signatory admin
ensure discount > 0.0
"#;
let module = parse_daml(source, Path::new("T.daml"));
let findings = MissingEnsureDecimal.detect(&module);
assert!(
findings.iter().any(|f| f.message.contains("'count'")),
"count must be flagged: {:?}",
findings
);
assert!(
!findings.iter().any(|f| f.message.contains("'discount'")),
"discount IS bounded: {:?}",
findings
);
}
}