Skip to content

Unsound: a #[param(name: { v | φ })] precondition combined with #[ensures(..)] is dropped at call sites too, so a #[thrust::trusted] function with a violated precondition verifies panicking programs as safe #196

Description

@coord-e

Summary

This is the soundness consequence of the annotation-assembly defect reported in #191 (#[param(x: { v | φ })] + #[ensures(ψ)] silently drops the parameter precondition φ).

#191 concludes "Soundness is preserved", reasoning that dropping φ only makes the body check stricter, so no panicking program can slip through. That argument holds only for functions whose body Thrust actually checks. For a #[thrust::trusted] function the body is not analyzed, so the dropped precondition is never enforced anywhere — in particular it is not enforced at call sites. A caller may then violate φ while still assuming the postcondition ψ, and Thrust verifies a program that panics at runtime as safe.

The same reasoning applies to any function whose contract is assumed rather than checked (#[thrust::trusted], and by extension the extern_spec mechanism), which is exactly how external/unverifiable operations are modeled — so this is a real soundness hole, not a corner case.

Reproduction

repro.rs — a trusted function with a correct contract (x > 0 ⟹ result > 0, body returns x), called with a value that violates the precondition:

//@compile-flags: -C debug-assertions=off#[thrust::trusted]#[thrust_macros::param(x:{ v:i64 | v > 0})]#[thrust_macros::ensures(result > 0)]fnpos(x:i64) -> i64{ x }fnmain(){let r = pos(-5);// precondition `x > 0` is violatedassert!(r > 0);// Thrust assumes the postcondition `result > 0`}
$ cargo run -q -- -Adead_code -C debug-assertions=false repro.rs &&echo safesafe

But the program panics at runtime#[thrust::trusted] affects only Thrust's analysis, not codegen, so pos(-5) returns -5 and the assertion fails:

$ # equivalent program with the (analysis-only) annotations stripped
$ cat rt.rsfn pos(x: i64) -> i64 { x }fn main() { let r = pos(-5); assert!(r > 0); }
$ rustc rt.rs -o rt && ./rtthread 'main' panicked at rt.rs:4:5:assertion failed: r > 0

safe for a program that unconditionally panics ⇒ unsound.

The same happens with a non-constant argument (so it is not constant-folding): a #[thrust::callable] fn caller(a: i64) { let r = g(a); assert!(r > 0); } calling the trusted g above also verifies as safe, even though a is unconstrained.

The precondition form matters — matches #191's trigger

Swapping the refinement-style #[param] precondition for the equivalent formula-style #[requires] makes Thrust correctly reject the precondition-violating call:

#[thrust::trusted]#[thrust_macros::requires(x > 0)]// formula-style instead of #[param]#[thrust_macros::ensures(result > 0)]fnpos(x:i64) -> i64{ x }fnmain(){let r = pos(-5);assert!(r > 0);}
$ cargo run -q -- -Adead_code -C debug-assertions=false repro_requires.rs &&echo safeerror: verification error: Unsat

Soundness matrix for a #[thrust::trusted] function called as pos(-5); assert!(r > 0) (should always be Unsat, because the call violates the declared precondition):

precondition formpostcondition formverdictcorrect?
#[requires(x > 0)]#[ensures(result > 0)]Unsat✓ enforced
#[requires(x > 0)]#[ret({ r | r > 0 })]Unsat✓ enforced
#[param(x: { v | v > 0 })]#[ensures(result > 0)]safeunsound

(The #[param] + #[ret] and #[sig(..)] forms on a trusted function with a call currently panic at src/analyze/crate_.rs:115 — a separate lack-of-support issue — so they are omitted from the matrix.)

The param × ensures cell is exactly the combination #191 identifies as dropping the precondition. #191 observes it as over-rejection (the checked body loses the assumption); this report shows the very same dropped fact is also the call-site obligation, which is what turns it into unsoundness once the body is not checked.

Why #191's "Soundness is preserved" argument does not cover this

Per #191's SMT evidence, the entry clause that installs the parameter-context predicate is weakened from

(assert (forall ((v0 Int) (v1 Int)) (=> (and (> v1 0)) (p2 v1 v1)))) ; precondition kept (#[ret] form)

to

(assert (forall ((v0 Int) (v1 Int)) (=> (andtrue) (p2 v1 v1)))) ; precondition dropped (#[ensures] form)

So p2 (the parameter refinement predicate) holds for everyv1, including v1 <= 0. #191 notes this makes the return/panic obligation c0 (from the body) harder to satisfy — the over-rejection. But p2 is also the predicate the caller must establish to satisfy the subtyping obligation arg_type <: param_type at the call site. With p2 provable for all values, that call-site obligation becomes vacuous: the caller is no longer required to establish φ.

Fixing the #191 root cause (keeping the #[param] precondition in the entry clause when an #[ensures] is also present) fixes both manifestations. I'm filing separately because the soundness classification changes the severity and directly contradicts the "Soundness is preserved" section of #191.

Environment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions