Skip to content

Validity of unions #73

Closed
Closed

Description

@RalfJung

Discussing the validity invariant of unions.

One possible choice here is "none, any bit pattern is allowed no matter which types the fields have, and including uninitialized bits".

We could also decide that e.g. a

union Foo { a: bool, b: (bool, u8) }

must start with the first byte being either the bit-pattern of false or the bit-pattern of true, because all fields agree on that invariant.

Notice that we cannot require the union to be valid for some field: for a union like

union Mix {
  f1: (bool, u8),
  f2: (u8, bool),
}

we want to allow a bit pattern like 0x3 0x3, which can occur from code like

let m = Mix { f1: (false, 3) };
m.f2.0 = 3;

There is no demonstrated benefit from disallowing such code, and this kind of code seems perfectly reasonable around unions.

Given that, any validity invariant that wants to restrict the set of allowed bit patterns will be rather complicated. However, such an invariant would enable us to e.g. layout-optimize Option<Foo>, whereas the "anything goes"-invariant would prohibit any kind of layout optimization around unions.

Activity

  1. RalfJung commented on Jan 10, 2019

    @RalfJung
    MemberAuthor

    My personal preference is to allow any bit pattern, mostly to keep things simple. Unions are already complex enough, and they only occur in unsafe code, we should make this as simple to use for the programmer as we can.

    If we ever desparately need to layout-optimize Option<Foo>, I propose we add attributes that let us control this -- something like a stable version of rustc_layout_scalar_valid_range_start.

  2. nikomatsakis commented on Jan 31, 2019

    @nikomatsakis
    Contributor

    My personal preference is to allow any bit pattern, mostly to keep things simple. Unions are already complex enough, and they only occur in unsafe code, we should make this as simple to use for the programmer as we can.

    I feel like @joshtriplett and @cramertj have both, in the past, expressed strong opinions about this.

    I think I agree regarding allowing any bit pattern. That said, I can definitely imagine wanting to be able to create a union that is "as optimized" as the equivalent enum, but that it has no discriminant (because you know you can figure that out from other means).

  3. nikomatsakis commented on Jan 31, 2019

    @nikomatsakis
    Contributor

    @RalfJung

    We could also decide ... must start with the first byte being either the bit-pattern of false or the bit-pattern of true, because all fields agree on that invariant.

    It seems like this would be true only if the Rust compiler decided to lay the fields out at offset zero, right? Personally, I sort of think we should just guarantee that the Rust compiler will do so. Particularly if we decide that unions are an opaque "bag of bits" from the perspective of the compiler, what is the motivation for the compiler to add extra padding into that bag?

    (The same applies to your second example.)

  4. RalfJung commented on Jan 31, 2019

    @RalfJung
    MemberAuthor

    It seems like this would be true only if the Rust compiler decided to lay the fields out at offset zero, right?

    Yes, I've been assuming that to be the base.

    I feel like @joshtriplett and @cramertj have both, in the past, expressed strong opinions about this.

    Yeah I remember that as well. Also @petrochenkov expressed the opposite opinion, namely that unions should have a non-trivial invariant.

    I can definitely imagine wanting to be able to create a union that is "as optimized" as the equivalent enum, but that it has no discriminant (because you know you can figure that out from other means).

    I can imagine wanting to do many things. :) But I feel such needs are better served by an opt-in attribute, than enabled per default.

  5. cramertj commented on Feb 1, 2019

    @cramertj
    Member

    I think I agree regarding allowing any bit pattern. That said, I can definitely imagine wanting to be able to create a union that is "as optimized" as the equivalent enum, but that it has no discriminant (because you know you can figure that out from other means).

    Yep, I would prefer this for non-repr(C) unions. IMO the common case (the FFI stuff) should always use repr(C), so this should be a non-issue for most folks. We could even lint on non-repr(C) unions just as a "be sure you know what you're doing, and that this is uncommon" hint.

  6. RalfJung commented on Feb 1, 2019

    @RalfJung
    MemberAuthor

    Yep, I would prefer this for non-repr(C) unions.

    Could you spell out what you mean by "this"?

  7. gnzlbg commented on Feb 4, 2019

    @gnzlbg
    Contributor

    It seems like this would be true only if the Rust compiler decided to lay the fields out at offset zero, right? Personally, I sort of think we should just guarantee that the Rust compiler will do so.

    I also think that we should guarantee this, but @joshtriplett mentioned some reasons about why we might not want to do that in the discussion about the layout of unions (#13 (comment)). It's unclear to me whether that interchange achieved some consensus, but maybe we should open a different issue to discuss whether we might want to guarantee this particular thing ? That would need amending the layout of unions in the repo.

    EDIT: for repr(C) unions, the fields always start at offset 0 AFAIK.

  8. hanna-kruppe commented on Feb 4, 2019

    @hanna-kruppe

    I also think that we should guarantee this, but @joshtriplett mentioned some reasons about why we might not want to do that in the discussion about the layout of unions (#13 (comment)). It's unclear to me whether that interchange achieved some consensus, but maybe we should open a different issue to discuss whether we might want to guarantee this particular thing ?

    That question can't really be separated from what we're discussing here: these kinds of layout optimizations are only possible if unions have a non-trivial validity invariant relating to the validity invariants of the fields. If we achieve consensus here that unions are just bags of bits, then it should be uncontroversial that there's no reason to place union fields at nonzero offsets. Conversely, the desire for layout optimizations of unions is a reason to want some non-trivial validity invariant for unions. I believe that's why nothing was settled during the previous discussion of union layout.

  9. cramertj commented on Feb 5, 2019

    @cramertj
    Member

    @RalfJung

    Yep, I would prefer this for non-repr(C) unions.
    Could you spell out what you mean by "this"?

    Yeah-- I'd like the opportunity to optimize based on known-invalid bitpatterns for repr(Rust) unions.

  10. RalfJung commented on Feb 5, 2019

    @RalfJung
    MemberAuthor

    @cramertj So what do you think about code like this, which violates the principle that at any time, some variant of the union is valid?

    union Mix {
      f1: (bool, u8),
      f2: (u8, bool),
    }
    
    let m = Mix { f1: (false, 3) };
    m.f2.0 = 3;

    From what I recall, this is something we explicitly want to support. It is also rather hard to argue that this is UB because it never actually performs an operation that "sees" a bad value.

    Given that unions are basically sugar for transmutes, I am really worried about automatically assuming that any of their bit patterns are invalid.

  11. cramertj commented on Feb 5, 2019

    @cramertj
    Member

    oh wow what a mess, good point! I'd still argue that the niches left over from overlapping all variants (e.g. union Mix { f1: NonZeroU32, f2: NonZeroU8 }) could be supported, but that is more complicated than what I originally imagined.

  12. RalfJung commented on Jun 20, 2019

    @RalfJung
    MemberAuthor

    I thought I had written down what the validity invariant could be to justify layout optimizations such as what @cramertj is asking for, but it seems not here... it would be something like:

    Bit/byte i of the union is allowed to have value v iff there is a variant of the union such that bit/byte i of the variant is allowed to have value v. We assume all variants to be "filled up" to the same size with padding, which may have any value.

    Whether it should be bits or bytes is unclear, as are a few other things. For example, this kind of implicitly assumes that validity talks only about the bits, but it might also talk about contents of the memory and then things become even more messy. Also this is very hard to check for in an implementation of our dynamic semantics.

    One thing that everyone seems to agree on though (including the above definition) is that if the union has a field of size 0 (such as is the case for MaybeUninit), then it may contain any value and thus there can be no layout optimizations.

  13. HadrienG2 commented on Jun 20, 2019

    @HadrienG2

    One thing that everyone seems to agree on though (including the above definition) is that if the union has a field of size 0 (such as is the case for MaybeUninit), then it may contain any value and thus there can be no layout optimizations.

    This property certainly is necessary for the current implementation of MaybeUninit to be sound:

    // 100% safe code!
    let opt = Some(MaybeUninit::<NonZeroU8>::zeroed());
    assert!(opt.is_some(),
            "Can be false if union with () is not a layout optimization barrier");
  14. 57 remaining items

  15. RalfJung commented on Jul 6, 2023

    @RalfJung
    MemberAuthor

    Sure, we can write such an algorithm as part of the Rust-to-MiniRust lowering. As I said, that's what we already do for constructing the "chunks".

    But in MiniRust, actually checking whether the type representation for a given type satisfies this property is not possible. I thought that's what you were asking.

  16. digama0 commented on Jul 6, 2023

    @digama0

    Let me put it this way: rustc picks some implementation-defined strategy, maybe including randomness or the phase of the moon, to determine for each type what its layout is. This layout is described by a grammar, which is already written down in minirust - this is Type, as far as I can tell. You can then evaluate given an element of Type whether it satisfies all the local validity requirements.

    We already have Type::inhabited which determines whether a Type "has any elements" without having to do anything like an exhaustive enumeration of values. This would be just like that, but for determining whether a type structurally cannot hold uninit at a given byte, because it's not padding and it is part of an integer value, something like that. (It would then be a theorem that if ty.valid_for(i, UnionByte::Init) is true then ty.decode(bytes).is_some() implies bytes[i] is initialized.)

    Is there something about Type that makes it unsuitable to do this? Everything I can see in the definition would imply that you can write a valid_for() which is basically linear time computable.

  17. RalfJung commented on Jul 7, 2023

    @RalfJung
    MemberAuthor

    We have inhabited since it affects decode for references. It is completely okay to always set inhabited to true.

    Sure, we could add some more syntactic structure for this. I don't see a good reason to do that though since MiniRust doesn't care. This is entirely on the frontend. The Rust-to-MiniRust translation will specify the set of possible layouts to choose for any given union, and that is where we will need such an analysis -- but not inside MiniRust itself.

  18. chorman0773 commented on Jul 10, 2023

    @chorman0773
    Contributor

    "Yes" to what?

    Yes, the bytes are valid. Or, the validity predicate for a union type is valid(b)=true.

    I can't see any rule that could assign a validity invariant that doesn't involve a massive Decision tree. And even then, only something trivial like:

    pub union Foo{
         x: NonZeroU32,
         y: (NonZeroU16, NonZeroU16)
    }

    would actually get assigned any invalid bit patterns.

    At the most, we could limit uninit bytes to where any variant allows an uninit byte, or a padding byte introduced by the union itself (either tail padding, or, for the repr(Rust) union fans, leading padding). I can't see this reasonbly being exploitable, though, especially since if we do read out a scalar (or something that contains scalars), we know it's not uninit.

  19. RalfJung commented on Jul 10, 2023

    @RalfJung
    MemberAuthor

    @scottmcm why did you say it was important here that this type be noundef?

  20. scottmcm commented on Jul 13, 2023

    @scottmcm
    Member

    @RalfJung I don't have any concrete issue. It's just that it's a scalar pair, so today it's passed as ptr noundef nonnull + ptr noundef in LLVM function arguments (https://rust.godbolt.org/z/Wr93ExKod) and I worry that losing that noundef on the second pointer would inhibit optimizations.

  21. scottmcm commented on Jul 13, 2023

    @scottmcm
    Member

    Oh, wait, it's worse than I thought -- if I used a union then it's no longer eligible to be a scalar pair at all, and gets passed by pointer instead https://rust.godbolt.org/z/bWhEGeYs1.

  22. CAD97 commented on Jul 15, 2023

    @CAD97
    Contributor

    That led to me doing some more experimentation and discovering more ways #[repr(transparent)] has fun implications, in that MaybeUninit<(usize, usize)> gets passed as two i64LLVM parameters at the LLVM level, meaning it'd lose any provenance. This is compared to MaybeUninit<(*mut (), *mut ())> which gets passed as two ptrLLVM.

    For the specific case of #[repr(transparent)], presuming we want to preserve ABI transparency, I think the validity effectively has to be exactly the transparently wrapped type's, except that bytes may also store uninit, and with copies only preserving the same bytes as for the wrapped type. (Importantly, this doesn't impact whether provenance is valid.)

    (Apologies if this was asserted upthread; I don't recall it being directly stated, at least not so plainly.)

  23. RalfJung commented on Jul 16, 2023

    @RalfJung
    MemberAuthor

    It's just that it's a scalar pair, so today it's passed as ptr noundef nonnull + ptr noundef in LLVM function arguments (https://rust.godbolt.org/z/Wr93ExKod) and I worry that losing that noundef on the second pointer would inhibit optimizations.

    My hope was always that we'd eventually get the ability to annotate types to add the attributes we want, instead of somehow having to preserve them through a union. (E.g. you'd probably also have situations where you would want to preserve the nonnull.)

    if I used a union then it's no longer eligible to be a scalar pair at all, and gets passed by pointer instead https://rust.godbolt.org/z/bWhEGeYs1.

    A repr(transparent) union should help.

    MaybeUninit<(usize, usize)> gets passed as two i64LLVM parameters at the LLVM level, meaning it'd lose any provenance.

    Well, maybe it would, it's not like LLVM specifies this. Doesn't LLVM itself even sometimes turn ptr loads into i64 loads?

    The much-discussed "byte" type in LLVM would fix this. So we could also document this as a known issue (unlikely to cause trouble in practice) with the LLVM backend caused by an LLVM limitation, and hope LLVM can one day give us a clear answer for how to express "a type of a given fixed size that gets passed in registers and can carry provenance". I think currently they would say i64 is the type to use for that.

  24. scottmcm commented on Jul 20, 2023

    @scottmcm
    Member

    A repr(transparent) union should help.

    I must have misunderstood your point here, because I can't make a two-field transparent union:

    error[E0690]: transparent union needs at most one non-zero-sized field, but has 2
     --> src/lib.rs:4:1
      |
    4 | pub union Foo<T> {
      | ^^^^^^^^^^^^^^^^ needs at most one non-zero-sized field, but has 2
    5 |     ptr: NonNull<T>,
      |     --------------- this field is non-zero-sized
    6 |     len: usize,
      |     ---------- this field is non-zero-sized
    

    https://play.rust-lang.org/?version=nightly&mode=debug&edition=2021&gist=28e02ef2968d6500948188e22b976db8

  25. RalfJung commented on Jul 20, 2023

    @RalfJung
    MemberAuthor

    Ah, interesting. MaybeUninit<(NonNull, usize)> will be a scalar pair. So probably you need a repr(transparent) union with one field that is a pair.

  26. scottmcm commented on Jul 20, 2023

    @scottmcm
    Member

    I don't think that would really help me in #113344, though.

    The union would be convenient -- and way less MIR -- if instead of

    let $len = unsafe { &mut *ptr::addr_of_mut!($this.end_or_len).cast::<usize>() };
    let $end = unsafe { &mut *ptr::addr_of_mut!($this.end_or_len).cast::<NonNull<T>>() };

    I could just write

    let $len = unsafe { &mut $this.end_or_len.len };
    let $end = unsafe { &mut $this.end_or_len.ptr };

    But sticking a pair into a transparent union doesn't make things any easier than just having the *const T field -- it probably makes it overall way harder since accessing the begin pointer becomes messy too.


    I guess what this boils down to is that I want an "unsafe enum", not really a union. (Not that I know what to do with that observation.)

  27. RalfJung commented on Jul 21, 2023

    @RalfJung
    MemberAuthor

    Ah sorry, what I said makes no sense.

    I guess this is a layout computation thing... a union where all fields are scalar of the same size, could be passed around as a scalar.

  28. RalfJung commented on Aug 2, 2023

    @RalfJung
    MemberAuthor

    This issue got too long and just asking about the validity invariant skips some key aspects (such as which bytes are preserved when a union gets copied). Closing in favor of #438, which I think summarizes the remaining open questions here and merges this with #156.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    A-unionsTopic: Related to unionsA-validityTopic: Related to validity invariantsS-pending-designStatus: Resolving this issue requires addressing some open design questions

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions