Skip to content

Commit 5cd7305

Browse files
committed
feat(contracts): Detect and warn about mutual recursion with #[kani::recursion]
The per-function REENTRY mechanism used by #[kani::recursion] only handles direct recursion (f calls f) soundly. For mutual recursion (f calls g, g calls f), the REENTRY flag for g is never set, so g's body executes fully instead of being replaced by its contract. This is a silent soundness gap — no error or warning was emitted. This change adds check_mutual_recursion() in the contract transform pass. When a function with #[kani::recursion] is being processed in RecursiveCheck mode, we scan its MIR body for calls to other functions that also have contracts AND #[kani::recursion]. For each such callee, we check if the callee's body calls back to the original function. If so, we emit a span_warn pointing at the call site. We require both has_contract() and has_recursion() on the callee because if the callee has a contract but no #[kani::recursion], Kani replaces the call with the contract abstraction — no mutual recursion occurs. Limitations: - Only detects one level of indirection (f->g->f), not deeper chains. - Reports only the first mutual-recursive callee per function. Includes a test case (mutual_recursion_unsound.rs) with two mutually recursive functions that triggers the warning.
1 parent d2c6dca commit 5cd7305

3 files changed

Lines changed: 108 additions & 0 deletions

File tree

kani-compiler/src/kani_middle/transform/contracts.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,9 @@ impl TransformPass for FunctionWithContractPass {
302302
match instance.ty().kind().rigid().unwrap() {
303303
RigidTy::FnDef(def, args) => {
304304
if let Some(mode) = self.contract_mode(tcx, *def) {
305+
if mode == ContractMode::RecursiveCheck {
306+
check_mutual_recursion(tcx, *def, &body);
307+
}
305308
self.mark_unused(tcx, *def, &body, mode);
306309
let new_body = self.set_mode(tcx, body, mode);
307310
(true, new_body)
@@ -552,3 +555,74 @@ fn find_closure(tcx: TyCtxt, fn_def: FnDef, body: &Body, name: &str) -> ClosureD
552555
unreachable!()
553556
})
554557
}
558+
559+
/// Check if a function with `#[kani::recursion]` is involved in mutual recursion.
560+
///
561+
/// Scans the function's MIR body for calls to other functions that also have
562+
/// `#[kani::recursion]`. For each such callee, checks if the callee's body calls
563+
/// back to the original function (one level of indirection). If so, emits a
564+
/// warning because the per-function REENTRY mechanism only handles direct
565+
/// recursion soundly.
566+
///
567+
/// We require both `has_contract()` and `has_recursion()` on the callee because
568+
/// if the callee has a contract but no `#[kani::recursion]`, Kani replaces the
569+
/// callee with its contract abstraction — no mutual recursion occurs.
570+
///
571+
/// Limitations:
572+
/// - Only detects one level of indirection (f→g→f), not deeper chains (f→g→h→f).
573+
/// TODO(#3316): Extend to detect deeper mutual recursion chains.
574+
fn check_mutual_recursion(tcx: TyCtxt, fn_def: FnDef, body: &Body) {
575+
let fn_name = tcx.def_path_str(rustc_internal::internal(tcx, fn_def.def_id()));
576+
577+
for bb in body.blocks.iter() {
578+
let TerminatorKind::Call { func, .. } = &bb.terminator.kind else { continue };
579+
let Ok(func_ty) = func.ty(body.locals()) else { continue };
580+
let TyKind::RigidTy(RigidTy::FnDef(callee_def, callee_args)) = func_ty.kind() else {
581+
continue;
582+
};
583+
584+
// Skip direct recursion (that's handled correctly by REENTRY).
585+
if callee_def.def_id() == fn_def.def_id() {
586+
continue;
587+
}
588+
589+
// Only error when the callee also uses #[kani::recursion] with a contract.
590+
// If the callee has a contract but no #[kani::recursion], Kani replaces
591+
// the call with the contract abstraction, so no mutual recursion occurs.
592+
let callee_attrs = KaniAttributes::for_def_id(tcx, callee_def.def_id());
593+
if !callee_attrs.has_contract() || !callee_attrs.has_recursion() {
594+
continue;
595+
}
596+
597+
// Check if the callee calls back to us (one level deep).
598+
let Ok(callee_instance) = Instance::resolve(callee_def, &callee_args) else { continue };
599+
let Some(callee_body) = callee_instance.body() else { continue };
600+
601+
for callee_bb in callee_body.blocks.iter() {
602+
let TerminatorKind::Call { func: callee_func, .. } = &callee_bb.terminator.kind else {
603+
continue;
604+
};
605+
let Ok(callee_func_ty) = callee_func.ty(callee_body.locals()) else { continue };
606+
let TyKind::RigidTy(RigidTy::FnDef(transitive_def, _)) = callee_func_ty.kind() else {
607+
continue;
608+
};
609+
610+
if transitive_def.def_id() == fn_def.def_id() {
611+
let callee_name =
612+
tcx.def_path_str(rustc_internal::internal(tcx, callee_def.def_id()));
613+
let span = rustc_internal::internal(tcx, bb.terminator.span);
614+
tcx.dcx().span_err(
615+
span,
616+
format!(
617+
"`#[kani::recursion]` is used on `{fn_name}`, which calls \
618+
`{callee_name}` that calls back to `{fn_name}`. \
619+
Mutual recursion is not supported by contract verification \
620+
and produces unsound results. Only direct recursion \
621+
(a function calling itself) is handled correctly."
622+
),
623+
);
624+
break; // One error per callee is enough; continue checking other callees.
625+
}
626+
}
627+
}
628+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
error: `#[kani::recursion]` is used on `mutual_a`, which calls `mutual_b` that calls back to `mutual_a`. Mutual recursion is not supported by contract verification and produces unsound results.
2+
error: `#[kani::recursion]` is used on `mutual_b`, which calls `mutual_a` that calls back to `mutual_b`. Mutual recursion is not supported by contract verification and produces unsound results.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright Kani Contributors
2+
// SPDX-License-Identifier: Apache-2.0 OR MIT
3+
// kani-flags: -Zfunction-contracts
4+
5+
//! Test that Kani detects mutual recursion in functions with contracts and
6+
//! emits an error about unsoundness.
7+
8+
#[kani::requires(x < 100)]
9+
#[kani::ensures(|&result| result <= x)]
10+
#[kani::recursion]
11+
fn mutual_a(x: u32) -> u32 {
12+
if x == 0 { 0 } else { mutual_b(x - 1) }
13+
}
14+
15+
#[kani::requires(x < 100)]
16+
#[kani::ensures(|&result| result <= x)]
17+
#[kani::recursion]
18+
fn mutual_b(x: u32) -> u32 {
19+
if x == 0 { 0 } else { mutual_a(x - 1) }
20+
}
21+
22+
#[kani::proof_for_contract(mutual_a)]
23+
fn check_mutual_a() {
24+
let x: u32 = kani::any();
25+
mutual_a(x);
26+
}
27+
28+
#[kani::proof_for_contract(mutual_b)]
29+
fn check_mutual_b() {
30+
let x: u32 = kani::any();
31+
mutual_b(x);
32+
}

0 commit comments

Comments
 (0)