1

Consider the following Rust program:

#![feature(generic_associated_types)]

pub trait Func {
    type Input<'a>;
    type Output;
    
    fn call(self, input: Self::Input<'_>) -> Self::Output;
}

fn invoke<'cx, F>(f: F, ctx: &'cx mut u8)
    where F: 'static + Func<Input<'cx> = &'cx u8, Output = u8>,
{
    let input = &*ctx;
    let out = f.call(input);
    *ctx = out;
}

I've used #![feature(generic_associated_types)], but I think the question I'm asking is still relevant if you move 'a from Func::Input to Func and use a higher-rank trait bound on invoke.

This code errors, but I don't think it's unsound:

error[E0506]: cannot assign to `*ctx` because it is borrowed
  --> src/lib.rs:15:5
   |
10 | fn invoke<'cx, F>(f: F, ctx: &'cx mut u8)
   |         --- lifetime `'cx` defined here
...
13 |     let input = &*ctx;
   |                 ----- borrow of `*ctx` occurs here
14 |     let out = f.call(input);
   |               ------------- argument requires that `*ctx` is borrowed for `'cx`
15 |     *ctx = out;
   |     ^^^^^^^^^^ assignment to borrowed `*ctx` occurs here

First ctx is reborrowed as input, which is passed to f.call and then never used again. f.call returns a value that does not contain any lifetimes (u8: 'static), so there is no connection between out and ctx.

Likewise, the type of f contains no lifetimes (F: 'static), so it cannot hold a reference with lifetime 'cx. Furthermore, the lifetime 'cx cannot be safely coerced to 'static inside call, so there's no way to "smuggle out" a reference with that lifetime that's accessible beyond the invocation of f.call. Therefore, I don't see how anything can alias ctx, and I think assigning to it in the last line should be sound.

Am I missing something? Would accepting this code be unsound? If not, why does Rust fail to take advantage of 'static bounds in this way?

ecstaticm0rse
  • 1,436
  • 9
  • 17
  • 1
    https://play.integer32.com/?version=nightly&mode=debug&edition=2021&gist=a40f6e6a345a535624c4bf210f9160d2 – Stargateur Dec 17 '21 at 18:41
  • Does this answer your question? [Writing a generic function that takes an iterable container as parameter in Rust](https://stackoverflow.com/questions/35940068/writing-a-generic-function-that-takes-an-iterable-container-as-parameter-in-rust) see also https://stackoverflow.com/questions/34630695/how-to-write-a-trait-bound-for-adding-two-references-of-a-generic-type/34635854#34635854 – Stargateur Dec 17 '21 at 18:45
  • I think the question as written is an interesting (it's unsound because `'cx` could be `'static`), but yes what I actually wanted is what you wrote. Thank you! – ecstaticm0rse Dec 17 '21 at 19:25
  • Ah, I remember why I was trying to avoid HRTBs now. I need to bound `Input` by a trait that also takes a lifetime. That requires either specifying a separate type parameter such that `Func::Input = I` and adding the bound to `I` or enabling `#![feature(associated_type_bounds)]`. It seems that the second approach forces the innermost lifetime (in the trait bound on `Func::Input`) to be `'static`. – ecstaticm0rse Dec 17 '21 at 19:49
  • 1
    However, the following works. `C: for<'db> Computation<'db, T, Input: for<'db2> Input<'db2, T>>`, – ecstaticm0rse Dec 17 '21 at 20:15

2 Answers2

1

The lifetime 'cx could be 'static meaning input can be smuggled elsewhere and be invalidated by *ctx = out.

There's no way to constrain that a lifetime is strictly less than another, so I don't think adding a "broader" lifetime constraint to a generic type is even considered by the borrow checker.

kmdreko
  • 42,554
  • 6
  • 57
  • 106
  • Yes, this is correct. I find it intriguing since it's the first motivating example I've seen for `!= 'static` constraints, which are [supported in some of the constraint solvers I was investigating for Polonius](https://ecstaticmorse.net/posts/quantifier-elimination/#existential-quantification). It's likely more trouble than it's worth. – ecstaticm0rse Dec 17 '21 at 19:07
1

The code as written is unsound. The lifetime bound of 'static on F is completely irrelevant, because the lifetime bounds of F::Input and F are two distinct lifetimes, and it's the associated type's lifetime that's causing the error. By declaring F::Input<'ctx> = &'ctx u8, you are declaring that the immutable borrow lives the length of the mutable one, making the mutable reference unsafe to use.

As @Stargateur mentioned, the thing that can make this work are Higher Ranked Trait bounds:

fn invoke<F>(f: F, ctx: &mut u8)
    where F: for<'ctx> Func<Input<'ctx> = &'ctx u8, Output = u8>,
{
    let input = ctx;
    let out = f.call(input);
    *input = out;
}

Playground

That is, instead of declaring that the function call is valid for some specific lifetime 'ctx it is valid for all lifetime's 'ctx. This way, the compiler can freely pick an appropriate lifetime for the reborrow to make this work.

As a side note, you might think that using two specific lifetimes in the function definition would be able to work, but any attempt to do so results in the compiler failing to choose the appropriate lifetime that makes things work.

Aiden4
  • 2,504
  • 1
  • 7
  • 24
  • I don't think this sufficiently explains why the function is unsound (as kmdreko's does), though it is quite helpful. I did initially start with the HRTB bound, but I vaguely remember it causing problems when I actually tried to implement `Func` and use `invoke`. I'll give it another try. – ecstaticm0rse Dec 17 '21 at 19:19
  • @ecstaticm0rse if it makes it any clearer, the soundness issue stems from the fact that the immutable borrow must last as long as the mutable one does, so any usage of the mutable borrow after the immutable one takes place would cause an aliasing mutable reference, which is undefined behavior. – Aiden4 Dec 17 '21 at 19:25
  • Rejected by the NLL borrow checker does not necessarily equal unsound. If we were able to write a constraint like `'cx != 'static` on invoke, would it still be unsound? – ecstaticm0rse Dec 17 '21 at 19:28
  • @ecstaticm0rse Yes it would be, the compiler would be allowed to assume the immutable reference passed into `Func` and the mutable reference `input` point to two different objects, and that assumption could cause issues, especially if `out` is derived from `input`. In other words, the compiler sees [this,](https://play.rust-lang.org/?version=nightly&mode=debug&edition=2021&gist=a02a5bbaa90b81b5554da31a74726bc3) which is clearly unsound. – Aiden4 Dec 17 '21 at 19:43
  • `out` does not contain any lifetimes, though, so I don't agree with your conclusion about what the compiler sees, although that doesn't mean you are wrong. [Code like this](https://play.rust-lang.org/?version=nightly&mode=debug&edition=2021&gist=493ad4788cde5caefcd6e3e9f998cdf1) is perfectly sound (cannot be used to create unsoundness), yet is rejected by the borrow checker. I agree that, in general, asserting that a mutable reference has *exactly the same lifetime* as a shared one is an error, I just don't see how that can be weaponized in this particular case. – ecstaticm0rse Dec 17 '21 at 20:09
  • But I don't know how important that is, since we can't write bounds like `'cx != 'static`, and that's the context in which the borrow-checker is designed to work. – ecstaticm0rse Dec 17 '21 at 20:10
  • Sorry I meant https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=ea08ef7419997ffd36168d6a3e934833 – ecstaticm0rse Dec 17 '21 at 20:19
  • @ecstaticm0rse one thing that would definitely cause a soundness hole in this instance would be the compiler inlining `call` in [this](https://play.rust-lang.org/?version=nightly&mode=debug&edition=2021&gist=6f8db7113a4958c6201bfc2b64e3e7db) specific case. The compiler must assume that `F` is a black box that is using every reference passed into it for as long as it is allowed to. LLVM is great at assuming undefined behavior doesn't happen, so this type of thing tends to cause [spooky action at a distance.](https://en.wikipedia.org/wiki/Action_at_a_distance_(computer_programming)) – Aiden4 Dec 17 '21 at 20:43