Dynx and sealed traits

As described here, every dyn-safe trait Trait gets an "accompanying" dynx Trait struct and an impl Trait for dynx Trait impl for that struct. This can have some surprising interactions with unsafe code -- if you have a trait that can only be safely implemented by types that meet certain criteria, the impl for a dynx type may not meet those criteria. This can lead to undefined behavior. The question then is: whose fault is that? In other words, is it the language's fault, for adding impls you didn't expect, or the code author's fault, for not realizing those impls would be there (or perhaps for not declaring that their trait had additional safety requirements, e.g. by making the trait unsafe).

A dynx Trait type is basically an implicit struct that implements Trait. For some traits, notably "sealed" traits, this could potentially be surprising.

Consider a trait PodTrait that is meant to be implemented only for "plain old data" types, like u8 and u32. By leveraging the "sealed trait" pattern, one can make it so that users cannot provide any impls of this trait:

#![allow(unused)]
fn main() {
pub mod public_api {
    mod sealed {
        pub trait Supertrait { }
    }

    /// **Guarantee.** Every type `T` that implements `PodTrait` meets
    /// this condition:
    ///
    /// * Given a `&T`, there are `byte_len` bytes of data behind the
    ///   pointer that safely be zeroed
    pub trait PodTrait: sealed::Supertrait {
        fn byte_len(&self) -> usize;
    }
    impl PodTrait for u8 {
        fn byte_len(&self) -> usize { 1 }
    }
    impl PodTrait for u32 {
        fn byte_len(&self) -> usize { 4 }
    }
    ...
}
}

This trait could then be used to build safe abstractions that leverage unsafe reasoning:

#![allow(unused)]
fn main() {
pub mod public_api {
    ...
    
    pub fn zero(pod: &mut impl PodTrait) {
        let n = pod.byte_len();

        // OK: We have inspected every impl of `PodTrait`
        // and we know that `byte_len` accurately
        // describes the length of a pointer.
        unsafe {
            <*mut _>::write_bytes(pod, 0, n)
        }
    }
    
    ...
}
}

Unfortunately, this reasoning is not sound if combined with a dynx type:

#![allow(unused)]
fn main() {
pub mod public_api {
    ...

    trait GetPodTrait {
        fn get(&self) -> impl PodTrait;
    }
}

fn method(x: &dyn GetPodTrait) {
    // `y` will be a `dynx GetPodTrait`, which will be
    // a `Box<u8>` or `Box<u32>`
    let mut y = x.get();
    
    // Calling `zero` is trying to zero the memory *referenced* by
    // the box, but it will actually zero the box pointer itself.
    // Bad!
    public_api::zero(&mut y);
}
}

What went wrong here? The problem is implementing PodTrait carries an additional proof obligation beyond those that the Rust type checker is aware of. As the comment says, one must guarantee that byte_len, invoked on an &impl PodTrait, correctly describes the number of bytes in that memory (and that it can safely be zeroed). This invariant was manually verified for the u8 and u32 impls, but it does not hold for the impl PodTrait for dynx PodTrait generated by the compiler.

There are a few ways to resolve this conundrum:

  • Declare that PodTrait should have been declared as unsafe, and that -> impl Foo is not dyn safe if Foo is an unsafe trait.
    • One could argue that the original code was wrong to declare PodTrait as safe; if it were declared as unsafe, and we used that to suppress the dynx impl (after all, we can't know whether it satisfies the extra conditions), the above code would no longer compile.
    • However, many Rust devs consider unafe to be more of a tool for the user -- if you have private functions and types, you are supposed to be able to reason fully about what they do without needing internal unsafe annotations (there hasn't been a formal decision about whether this is a valid principle, but it has many adherents, and it's possible we should make it one).
    • Furthermore, this means that unsafe traits can't be used with dynx, which could in some cases be useful.
      • We could have an opt-in for this case.
  • Some form of opt-in for dyn compatibility (aka, dyn safety).
    • Ignoring backwards compatiblility, we could require traits to "opt-in" to being dyn-compatible (e.g., one might write dyn trait Foo instead of just having a trait Foo that doesn't make use of various prohibited features). In that case, the user is "opting in" to the generation of the dynx impl; e.g., a dyn trait PodTrait declaration would be simply broken, because that dyn trait declaration implies the generation of an impl PodTrait for dynx PodTrait, and the safety criteria are not met (this is subtle, but then the code itself is subtle).
      • We could leverage editions in various ways to resolve the backwards compatibility issues.
    • Arguments in favor of opt-in:
      • The fact that the compiler implicitly decides whether your trait is dyn compatible or not is a common source of user confusion -- it's easy to not realize you are promising people dyn compatibility, and also easy to not realize that you've lost it via some change to the crate.
      • Furthermore, there are some soundness holes (e.g., #57893) that are much harder to fix because there is no declaration of dyn compatibility. In those cases, if we knew the trait was meant to be dyn compatible, we could easily enforce stricter rules that would prevent unsoundness. But detecting whether the trait would meet those stricter rules is difficult, and so it is hard to determine automatically whether the trait should be dyn compatible or not.
      • Finally, some form of explicit "opt-in" to dyn compatibility might allow us to close various ergonomic holes, such as automatically providing an impl of Trait for Box<dyn Trait>, &dyn Trait, and other pointer types (perhaps all pointer types).
    • Arguments against:
      • Backwards compatibility. We currently determine automatically if traits are "dyn compatible" or not. That code has to keep working.
      • Arguably, traits should be dyn compatible by default, and you should "opt out" to make use of the extended set of features that purely static traits provide (we could, of course, do that over an edition).
  • If a trait SomeTrait includes an -> impl Foo method, make the trait dyn safe only if SomeTrait could itself have declared a struct that implements Foo
    • This is a rather wacky rule, but the idea is that if something is "sealed", then either (a) the trait SomeTrait is outside the sealed abstraction, and so won't be able to implement it; or (b) it's your own fault, because it's inside the sealed abstraction.
    • In this case, the offending code is inside the sealed abstraction, so we'd still have the bug.
  • Introduce a "sealed" keyword and declare that code is wrong.
    • We would rather not entangle these designs; also, like the "unsafe"-based solution, combining sealed + dyn compatible would likely make sense.