You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Unsound: a crate with no main (any --crate-type lib/rlib/…) gets no entry-point anchor, so every inferred precondition is discharged as false and the whole crate — pub API functions that always panic included — verifies as safe #259
crate_::Analyzer::assert_callable_entry anchors exactly one definition: tcx.entry_fn(()), i.e. the crate's main. It is the only place that ever emits a fact clause (true ⟹ p) for an inferred parameter precondition.
A library crate has no entry function. Nothing else in the generated CHC system constrains any inferred precondition predicate from below, so the solver takes every one of them to be false, every body obligation becomes vacuous, and the system is trivially SAT. Thrust exits 0 with no diagnostic for a library whose public API provably panics on every call.
The bodies are analyzed and the panic obligation is emitted — it is just discharged vacuously. Compiling the identical source as a binary with a main that calls the same function is correctly rejected.
Since a real-world Rust crate is a library (src/lib.rs) far more often than a binary, this means pointing thrust-rustc at real-world code silently certifies it. The vacuity also cascades: a private helper that is called from a pub function is equally unverified, because the caller's own precondition is unanchored.
Minimal reproduction
min.rs:
pubfnf(){let v:Vec<i64> = Vec::new();let _ = v[0];// index out of bounds: this function panics on every call}
The vacuity cascades through the intra-crate call graph
A pub entry point calling a private helper is not saved by the call site — the caller is unanchored too, so the callee's precondition stays unconstrained:
The last two rows locate the defect precisely: a function that carries a concrete contract (requires/ensures/callable) is checked normally even in a library, because its precondition is the literal true rather than a predicate variable. Only functions whose precondition is inferred — i.e. every unannotated function, which is the common case — go vacuous.
Root cause
src/analyze/crate_.rs:242:
fnassert_callable_entry(&mutself){ifletSome((def_id, _)) = self.tcx.entry_fn(()){// we want to assert entry function is safe to execute without any assumption
...
for param_ty inentry_ty.params{let cs = builder.clone().with_value_var(¶m_ty.ty).head(param_ty.refinement);self.ctx.extend_clauses(cs);// <-- the only `true ⟹ p` fact in the system}}}
tcx.entry_fn(()) is None for every non-bin crate type, so the whole function is a no-op and no fact clause is ever emitted.
An unannotated function is registered with a template precondition (RUST_LOG=info, pub fn f() in a lib crate):
The emitted CHC systems for cascade.rs show the difference directly (THRUST_OUTPUT_DIR). As a library, p2 — run's parameter precondition — occurs only in a hypothesis position:
; c8 (lib)
(assert (=> (and p2 true) p6))
;; ... and nowhere else. No `(assert (=> true p2))`.
so p2 := false propagates: p6 := false, which kills p0/p4, which makes the panic obligation
So this is not "bodies are skipped" — the obligations are generated correctly; there is simply no root to make them non-vacuous.
Scope / when it bites
Every crate type other than bin: lib, rlib, staticlib, cdylib (all confirmed safe on the minimal repro).
Every function whose precondition is inferred, which is every function without requires/ensures/callable — including private helpers reachable from pub API, since the vacuity propagates backwards along the call graph.
Failure is silent: exit status 0, no warning that nothing was anchored, so a user reasonably reads the result as "verified".
This is the setting Support multi-crate projects (cargo) #255 (cargo/multi-crate) is heading towards: cargo builds src/lib.rs as a library, so a cargo integration built on the current behaviour would report safe for every library crate.
Workaround
Annotate the crate's API entry points with #[thrust::callable] (or requires/ensures), which replaces the inferred precondition with a concrete one and re-anchors everything reachable from it (rows 7–8 of the table).
Suggested direction
Anchor more than tcx.entry_fn(()). Some options, roughly in increasing order of aggressiveness:
Also anchor every function explicitly marked #[thrust::callable] — today callable gets its concrete true precondition from the annotation path, so it happens to work, but it is not routed through assert_callable_entry, and making that explicit would keep the "entry point" notion in one place.
When the crate has no entry function, anchor every externally reachable definition (tcx.effective_visibilities / exported items) — a library's pub API is precisely the set of functions callable "without any assumption" from outside, which is the same justification the existing main anchor uses.
At minimum, emit a diagnostic when the analysis anchors nothing, so safe is never printed for a crate where no obligation could have failed.
Z3 5.0.0, repository-default THRUST_SOLVER_ARGS. Solver-independent: the library system is SAT because it lacks a fact clause, not because of solver strength.
Summary
crate_::Analyzer::assert_callable_entryanchors exactly one definition:tcx.entry_fn(()), i.e. the crate'smain. It is the only place that ever emits a fact clause (true ⟹ p) for an inferred parameter precondition.A library crate has no entry function. Nothing else in the generated CHC system constrains any inferred precondition predicate from below, so the solver takes every one of them to be
false, every body obligation becomes vacuous, and the system is trivially SAT. Thrust exits 0 with no diagnostic for a library whose public API provably panics on every call.The bodies are analyzed and the panic obligation is emitted — it is just discharged vacuously. Compiling the identical source as a binary with a
mainthat calls the same function is correctly rejected.Since a real-world Rust crate is a library (
src/lib.rs) far more often than a binary, this means pointingthrust-rustcat real-world code silently certifies it. The vacuity also cascades: a private helper that is called from apubfunction is equally unverified, because the caller's own precondition is unanchored.Minimal reproduction
min.rs:The same body, in a crate that has a
maincalling it, is correctly rejected:The vacuity cascades through the intra-crate call graph
A
pubentry point calling a private helper is not saved by the call site — the caller is unanchored too, so the callee's precondition stays unconstrained:// same functions + `fn main() { let _ = run(); }` (bin) -> error: verification error: Unsat (correct)Observed vs. expected
pub fn f() { Vec::<i64>::new()[0]; }lib/rlib/staticlib/cdylibfn main() { f(); }binfn main() { assert!(false); }libfn main() { assert!(false); }binpub fn get(..) {..}+pub fn run() { get(&empty, 0) }libfn helper() {..panics..}+pub fn api() { helper() }lib#[thrust::callable] pub fn f() { assert!(false); }lib#[requires(true)] #[ensures(result == n+1)] pub fn f(n: i64) -> i64 { n + 2 }libThe last two rows locate the defect precisely: a function that carries a concrete contract (
requires/ensures/callable) is checked normally even in a library, because its precondition is the literaltruerather than a predicate variable. Only functions whose precondition is inferred — i.e. every unannotated function, which is the common case — go vacuous.Root cause
src/analyze/crate_.rs:242:tcx.entry_fn(())isNonefor every non-bincrate type, so the whole function is a no-op and no fact clause is ever emitted.An unannotated function is registered with a template precondition (
RUST_LOG=info,pub fn f()in a lib crate):The emitted CHC systems for
cascade.rsshow the difference directly (THRUST_OUTPUT_DIR). As a library,p2—run's parameter precondition — occurs only in a hypothesis position:so
p2 := falsepropagates:p6 := false, which killsp0/p4, which makes the panic obligation; c0 ... (=> (and ... (p4 ...) (not (< v6 (tuple_proj<...>.1 v4)))) false)vacuous, and the system is SAT.
Compiled as a binary the very same run gains the anchor clause emitted by
assert_callable_entry, and the chain becomes real (UNSAT):So this is not "bodies are skipped" — the obligations are generated correctly; there is simply no root to make them non-vacuous.
Scope / when it bites
bin:lib,rlib,staticlib,cdylib(all confirmedsafeon the minimal repro).requires/ensures/callable— including private helpers reachable frompubAPI, since the vacuity propagates backwards along the call graph.cargobuildssrc/lib.rsas a library, so a cargo integration built on the current behaviour would reportsafefor every library crate.Workaround
Annotate the crate's API entry points with
#[thrust::callable](orrequires/ensures), which replaces the inferred precondition with a concrete one and re-anchors everything reachable from it (rows 7–8 of the table).Suggested direction
Anchor more than
tcx.entry_fn(()). Some options, roughly in increasing order of aggressiveness:#[thrust::callable]— todaycallablegets its concretetrueprecondition from the annotation path, so it happens to work, but it is not routed throughassert_callable_entry, and making that explicit would keep the "entry point" notion in one place.tcx.effective_visibilities/ exported items) — a library'spubAPI is precisely the set of functions callable "without any assumption" from outside, which is the same justification the existingmainanchor uses.safeis never printed for a crate where no obligation could have failed.Distinct from existing issues
sig/ret/paramwhose body violates the declared refinement verifies assafewhen it is not called (unspecified param precondition becomes an inference template that is discharged vacuously) #179. Unsound: a function annotated only withsig/ret/paramwhose body violates the declared refinement verifies assafewhen it is not called (unspecified param precondition becomes an inference template that is discharged vacuously) #179 is about thesig/ret/paramfront-end leaving an explicitly declared signature's unspecified parameters as templates, and it states that template inference "is the intended behavior for unannotated functions" and that therequires/ensuresfront-end "is checked correctly". Both hold here: the functions in this report carry no annotation at all, and Unsound: a function annotated only withsig/ret/paramwhose body violates the declared refinement verifies assafewhen it is not called (unspecified param precondition becomes an inference template that is discharged vacuously) #179's suggested fix (default the unspecified parameters ofsig/ret/param-annotated functions to concretetrue) would not change any row of the table above. The defect here is upstream of the annotation front-ends — which definitions get anchored as entry points at all (assert_callable_entry), not how one function's declared signature is built.#[thrust::callable]entry point whose body always panics — or whose body violates its ownensures— verifies assafe#200. There the body of an uncalled generic function is never analyzed (deferred def,concrete_def_tyisNone). Here the bodies are analyzed and the clauses are emitted — including the panic obligation quoted above — they are merely satisfiable. Unsound: an uncalled generic function (a type param appears in its signature) has its body skipped entirely, so a#[thrust::callable]entry point whose body always panics — or whose body violates its ownensures— verifies assafe#200's table also asserts that "a plain uncalled non-generic helper ... Thrust does verify"; that row uses#[ensures(..)], and the unannotated non-generic case does not hold in a crate with nomain.thrust-rustc --crate-type libinvocation today.Vec(andassert!(false)), independent of any integer bound.Environment
2bf022dnightly-2025-09-08(perrust-toolchain.toml)THRUST_SOLVER_ARGS. Solver-independent: the library system is SAT because it lacks a fact clause, not because of solver strength.