Skip to content

Commit 047c6b6

Browse files
authored
Implement stub sets for reusable stub groups (#4594)
Add `kani::stub_set!` macro and `#[kani::use_stub_set]` attribute for defining reusable groups of stubs that can be applied to multiple proof harnesses without repeating individual `#[kani::stub]` annotations. Resolves #2096. ## Problem When multiple proof harnesses need the same set of stubs (e.g., mocking I/O, randomness, or network calls), users must duplicate `#[kani::stub(orig, repl)]` annotations on every harness. This is verbose, error-prone, and makes it hard to maintain consistent stub configurations across a test suite. ## Solution ### `kani::stub_set!` macro Defines a named, reusable group of stub pairs: ```rust kani::stub_set! { pub my_io_stubs { stub(std::fs::read, mock_read) stub(std::fs::write, mock_write) use_stub_set(other_stubs) // compose with other sets } } ``` ### `#[kani::use_stub_set]` attribute Applies a stub set to a harness: ```rust #[kani::proof] #[kani::use_stub_set(my_io_stubs)] fn check_with_mocked_io() { ... } ``` ### Features - **Composition**: Stub sets can include `use_stub_set(other_set)` entries to compose sets - **Visibility**: `pub` modifier for cross-module access - **Combining**: `#[kani::use_stub_set]` works alongside individual `#[kani::stub]` attributes - **Per-harness**: Different harnesses can use different stub sets - **Circular reference detection**: Errors on circular `use_stub_set` references ### Implementation - `stub_set!` generates a `const` with a `#[kanitool::stub_set(...)]` attribute encoding the stub pairs - `use_stub_set` resolution in `attributes.rs` reads the attribute, resolves paths relative to the harness module, and expands the pairs into the harness's stub list - Path resolution in `resolve.rs` extended to look up `stub_set` consts ## Testing 5 tests covering: - `stub_set_basic.rs` — basic stub set definition and usage - `stub_set_composed.rs` — composing stub sets via `use_stub_set` entries - `stub_set_module.rs` — cross-module access with `pub` visibility - `stub_set_per_harness.rs` — different stub sets per harness - `stub_set_with_individual.rs` — combining stub sets with individual `#[kani::stub]` --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 and MIT licenses.
1 parent 658a988 commit 047c6b6

18 files changed

Lines changed: 650 additions & 2 deletions

File tree

docs/src/reference/experimental/stubbing.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,57 @@ The following trait patterns are supported:
212212

213213
**Known limitation:** Traits with const generic parameters (e.g., `<Type as Buf<16>>::write`) or associated type constraints (e.g., `<Type as Iterator<Item = u32>>::next`) are not currently supported and will produce a resolution error.
214214

215+
### Stub sets
216+
217+
When multiple harnesses share the same set of stubs, you can define a reusable stub set
218+
with the `kani::stub_set!` macro and apply it with `#[kani::use_stub_set(...)]`:
219+
220+
```rust
221+
fn mock_read(_path: std::path::PathBuf) -> std::io::Result<Vec<u8>> {
222+
Ok(vec![0; 10])
223+
}
224+
225+
fn mock_write(_path: std::path::PathBuf, _contents: &[u8]) -> std::io::Result<()> {
226+
Ok(())
227+
}
228+
229+
kani::stub_set!(io_stubs,
230+
stub(std::fs::read, mock_read),
231+
stub(std::fs::write, mock_write),
232+
);
233+
234+
#[kani::proof]
235+
#[kani::use_stub_set(io_stubs)]
236+
fn check_with_io_stubs() {
237+
// Both stubs are applied
238+
}
239+
```
240+
241+
Stub sets can compose other stub sets using `use_stub_set(...)`:
242+
243+
```rust
244+
kani::stub_set!(all_stubs,
245+
use_stub_set(io_stubs),
246+
stub(other::func, my_func),
247+
);
248+
```
249+
250+
You can also combine `#[kani::use_stub_set(...)]` with individual `#[kani::stub(...)]`
251+
attributes on the same harness. Different harnesses can use different stub sets.
252+
253+
To make a stub set accessible from other modules, add a visibility modifier:
254+
255+
```rust
256+
mod stubs {
257+
kani::stub_set!(pub my_set,
258+
stub(real_fn, stub_fn),
259+
);
260+
}
261+
```
262+
263+
**Note:** Paths inside a stub set are resolved relative to the harness that uses it,
264+
not relative to the module where the stub set is defined.
265+
215266
### Usage restrictions
216267

217268
Stub annotations (`#[kani::stub]`) are specified per-harness. When a crate contains multiple

kani-compiler/src/kani_middle/attributes.rs

Lines changed: 204 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ use syn::parse::Parser;
2828
use syn::punctuated::Punctuated;
2929
use syn::{Expr, ExprLit, Lit, PathSegment, TypePath};
3030

31-
use super::resolve::{FnResolution, ResolveError, resolve_fn_path};
31+
use super::resolve::{FnResolution, ResolveError, resolve_fn_path, resolve_item};
3232
use tracing::{debug, trace};
3333

3434
#[derive(Debug, Clone, Copy, AsRefStr, EnumString, PartialEq, Eq, PartialOrd, Ord)]
@@ -44,6 +44,10 @@ enum KaniAttributeKind {
4444
/// A sound [`Self::Stub`] that replaces a function by a stub generated from
4545
/// its contract.
4646
StubVerified,
47+
/// Apply a named stub set defined with `kani::stub_set!`.
48+
UseStubSet,
49+
/// Marks a const as a stub set definition (generated by `kani::stub_set!`).
50+
StubSet,
4751
/// A harness, similar to [`Self::Proof`], but for checking a function
4852
/// contract, e.g. the contract check is substituted for the target function
4953
/// before the the verification runs.
@@ -94,8 +98,10 @@ impl KaniAttributeKind {
9498
| KaniAttributeKind::Stub
9599
| KaniAttributeKind::ProofForContract
96100
| KaniAttributeKind::StubVerified
101+
| KaniAttributeKind::UseStubSet
97102
| KaniAttributeKind::Unwind => true,
98103
KaniAttributeKind::Unstable
104+
| KaniAttributeKind::StubSet
99105
| KaniAttributeKind::FnMarker
100106
| KaniAttributeKind::Recursion
101107
| KaniAttributeKind::RecursionTracker
@@ -122,7 +128,12 @@ impl KaniAttributeKind {
122128

123129
/// Is this a stubbing attribute that requires the experimental stubbing feature?
124130
pub fn demands_stubbing_use(self) -> bool {
125-
matches!(self, KaniAttributeKind::Stub | KaniAttributeKind::StubVerified)
131+
matches!(
132+
self,
133+
KaniAttributeKind::Stub
134+
| KaniAttributeKind::StubVerified
135+
| KaniAttributeKind::UseStubSet
136+
)
126137
}
127138
}
128139

@@ -409,6 +420,13 @@ impl<'tcx> KaniAttributes<'tcx> {
409420
// Ignored here, because it should be an internal attribute. Actual validation
410421
// happens when pragmas are generated.
411422
}
423+
KaniAttributeKind::UseStubSet => {
424+
// Validated when harness attributes are extracted.
425+
}
426+
KaniAttributeKind::StubSet => {
427+
// Marker attribute on const items generated by kani::stub_set!.
428+
// No validation needed here.
429+
}
412430
}
413431
}
414432
(stub_verified_targets, contract_targets)
@@ -584,6 +602,14 @@ impl<'tcx> KaniAttributes<'tcx> {
584602
KaniAttributeKind::FnMarker => {
585603
/* no-op */
586604
}
605+
KaniAttributeKind::UseStubSet => {
606+
harness
607+
.stubs
608+
.extend_from_slice(&self.parse_use_stub_sets(attributes));
609+
}
610+
KaniAttributeKind::StubSet => {
611+
unreachable!("`StubSet` attribute should only appear on const items generated by `kani::stub_set!`, not on harnesses")
612+
}
587613
};
588614
harness
589615
})
@@ -848,6 +874,182 @@ impl<'tcx> KaniAttributes<'tcx> {
848874
})
849875
.collect()
850876
}
877+
878+
/// Parse `#[kani::use_stub_set(name)]` attributes and resolve the stub sets
879+
/// to their constituent stub pairs.
880+
fn parse_use_stub_sets(&self, attributes: &[&'tcx Attribute]) -> Vec<Stub> {
881+
let current_module =
882+
self.tcx.parent_module_from_def_id(self.item.expect_local()).to_local_def_id();
883+
let mut stubs = Vec::new();
884+
for attr in attributes {
885+
let paths = match parse_paths(self.tcx, attr) {
886+
Ok(p) => p,
887+
Err(_) => {
888+
self.tcx.dcx().span_err(
889+
attr.span(),
890+
"attribute `kani::use_stub_set` takes a single path argument",
891+
);
892+
continue;
893+
}
894+
};
895+
if paths.len() != 1 {
896+
self.tcx.dcx().span_err(
897+
attr.span(),
898+
format!(
899+
"attribute `kani::use_stub_set` takes exactly one argument; found {}",
900+
paths.len()
901+
),
902+
);
903+
continue;
904+
}
905+
let name_str = paths[0].to_token_stream().to_string();
906+
match resolve_item(self.tcx, current_module, &name_str) {
907+
Ok(def_id) => {
908+
self.collect_stubs_from_set(
909+
def_id,
910+
attr.span(),
911+
&mut stubs,
912+
&mut HashSet::from([def_id]),
913+
&mut vec![def_id],
914+
);
915+
}
916+
Err(err) => {
917+
self.tcx.dcx().span_err(
918+
attr.span(),
919+
format!("failed to resolve stub set `{name_str}`: {err}"),
920+
);
921+
}
922+
}
923+
}
924+
stubs
925+
}
926+
927+
/// Recursively collect stubs from a stub set definition, following
928+
/// `use_stub_set(...)` entries. Uses `visited` for deduplication (diamond
929+
/// composition) and `path` for cycle detection.
930+
fn collect_stubs_from_set(
931+
&self,
932+
def_id: DefId,
933+
span: Span,
934+
stubs: &mut Vec<Stub>,
935+
visited: &mut HashSet<DefId>,
936+
stack: &mut Vec<DefId>,
937+
) {
938+
let current_module =
939+
self.tcx.parent_module_from_def_id(self.item.expect_local()).to_local_def_id();
940+
941+
// Find the kanitool::stub_set attribute on this item.
942+
let attrs = self.tcx.get_all_attrs(def_id);
943+
let stub_set_attr = attrs.iter().find(|a| {
944+
if let Attribute::Unparsed(normal) = a {
945+
let segments = &normal.path.segments;
946+
segments.len() == 2
947+
&& segments[0].as_str() == "kanitool"
948+
&& segments[1].as_str() == "stub_set"
949+
} else {
950+
false
951+
}
952+
});
953+
954+
let Some(attr) = stub_set_attr else {
955+
self.tcx.dcx().span_err(
956+
span,
957+
format!(
958+
"`{}` is not a stub set (missing `kani::stub_set!` definition)",
959+
self.tcx.def_path_str(def_id)
960+
),
961+
);
962+
return;
963+
};
964+
965+
// Parse the attribute arguments: stub(a, b), use_stub_set(c), ...
966+
let syn_attribute = syn_attr(self.tcx, attr);
967+
let parser = Punctuated::<syn::Expr, syn::Token![,]>::parse_terminated;
968+
let entries = match syn_attribute.parse_args_with(parser) {
969+
Ok(entries) => entries,
970+
Err(err) => {
971+
self.tcx.dcx().span_err(span, format!("failed to parse stub set: {err}"));
972+
return;
973+
}
974+
};
975+
976+
for entry in entries {
977+
// Each entry is a function call expression: stub(a, b) or use_stub_set(c)
978+
if let Expr::Call(call) = &entry {
979+
if let Expr::Path(path) = call.func.as_ref() {
980+
let func_name = path.path.segments.last().map(|s| s.ident.to_string());
981+
match func_name.as_deref() {
982+
Some("stub") if call.args.len() == 2 => {
983+
let orig = call.args[0].to_token_stream().to_string();
984+
let repl = call.args[1].to_token_stream().to_string();
985+
stubs.push(Stub { original: orig, replacement: repl });
986+
}
987+
Some("use_stub_set") if call.args.len() == 1 => {
988+
let set_path = call.args[0].to_token_stream().to_string();
989+
match resolve_item(self.tcx, current_module, &set_path) {
990+
Ok(nested_def_id) => {
991+
if stack.contains(&nested_def_id) {
992+
// True cycle detected (A→B→A).
993+
self.tcx.dcx().span_err(
994+
span,
995+
format!("circular stub set reference: `{set_path}`"),
996+
);
997+
} else if visited.insert(nested_def_id) {
998+
// Not yet processed — recurse.
999+
stack.push(nested_def_id);
1000+
self.collect_stubs_from_set(
1001+
nested_def_id,
1002+
span,
1003+
stubs,
1004+
visited,
1005+
stack,
1006+
);
1007+
stack.pop();
1008+
}
1009+
// else: already processed via another path (diamond) — skip.
1010+
}
1011+
Err(err) => {
1012+
self.tcx.dcx().span_err(
1013+
span,
1014+
format!("failed to resolve stub set `{set_path}`: {err}"),
1015+
);
1016+
}
1017+
}
1018+
}
1019+
Some("stub") => {
1020+
self.tcx.dcx().span_err(
1021+
span,
1022+
"stub() in stub set takes exactly 2 arguments: stub(original, replacement)",
1023+
);
1024+
}
1025+
Some("use_stub_set") => {
1026+
self.tcx.dcx().span_err(
1027+
span,
1028+
"use_stub_set() in stub set takes exactly 1 argument",
1029+
);
1030+
}
1031+
_ => {
1032+
self.tcx.dcx().span_err(
1033+
span,
1034+
format!(
1035+
"unknown entry in stub set: `{}`. Expected `stub(...)` or `use_stub_set(...)`",
1036+
entry.to_token_stream()
1037+
),
1038+
);
1039+
}
1040+
}
1041+
}
1042+
} else {
1043+
self.tcx.dcx().span_err(
1044+
span,
1045+
format!(
1046+
"invalid stub set entry: `{}`. Expected `stub(...)` or `use_stub_set(...)`",
1047+
entry.to_token_stream()
1048+
),
1049+
);
1050+
}
1051+
}
1052+
}
8511053
}
8521054

8531055
/// An efficient check for the existence for a particular [`KaniAttributeKind`].

kani-compiler/src/kani_middle/resolve.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,19 @@ pub fn resolve_fn<'tcx>(
132132
Ok(rustc_internal::internal(tcx, result.def().def_id()))
133133
}
134134

135+
/// Resolve a path string to a `DefId` for any item (not just functions).
136+
pub fn resolve_item<'tcx>(
137+
tcx: TyCtxt<'tcx>,
138+
current_module: LocalDefId,
139+
path_str: &str,
140+
) -> Result<DefId, ResolveError<'tcx>> {
141+
let _span = debug_span!("resolve_item", ?path_str, ?current_module).entered();
142+
let path: syn::Path = syn::parse_str(path_str).map_err(|err| ResolveError::InvalidPath {
143+
msg: format!("Expected a path, but found `{path_str}`. {err}"),
144+
})?;
145+
resolve_path(tcx, current_module, &path)
146+
}
147+
135148
/// Attempts to resolve a simple path (in the form of a string) to a `DefId`.
136149
/// The current module is provided as an argument in order to resolve relative
137150
/// paths.

library/kani/src/lib.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,45 @@ macro_rules! implies {
8888
};
8989
}
9090

91+
/// Define a reusable set of function stubs that can be applied to multiple proof harnesses.
92+
///
93+
/// # Example
94+
///
95+
/// ```ignore
96+
/// kani::stub_set!(my_stubs,
97+
/// stub(std::fs::read, my_read),
98+
/// stub(std::fs::write, my_write),
99+
/// );
100+
///
101+
/// #[kani::proof]
102+
/// #[kani::use_stub_set(my_stubs)]
103+
/// fn my_harness() { /* ... */ }
104+
/// ```
105+
///
106+
/// Stub sets can also include other stub sets:
107+
///
108+
/// ```ignore
109+
/// kani::stub_set!(all_stubs,
110+
/// use_stub_set(my_stubs),
111+
/// stub(other::func, my_func),
112+
/// );
113+
/// ```
114+
#[macro_export]
115+
macro_rules! stub_set {
116+
(pub $name:ident, $($entry:tt)*) => {
117+
/// A Kani stub set definition.
118+
#[allow(non_upper_case_globals)]
119+
#[kanitool::stub_set($($entry)*)]
120+
pub const $name: () = ();
121+
};
122+
($vis:vis $name:ident, $($entry:tt)*) => {
123+
#[doc(hidden)]
124+
#[allow(non_upper_case_globals)]
125+
#[kanitool::stub_set($($entry)*)]
126+
$vis const $name: () = ();
127+
};
128+
}
129+
91130
pub(crate) use kani_macros::unstable_feature as unstable;
92131

93132
pub mod contracts;

0 commit comments

Comments
 (0)