Does reading to an inactive variant of a union that has the same layout as another active variant cause UB?

union U{
   addr: * const (),
   val: usize
}
fn main(){
  let p = 0;
  let u = U{addr:(&p as * const i32).cast()};
  let v = unsafe {u.val};  // #1
}

In Rust, does #1 cause UB? Obviously, in the C++ memory model, #1 absolutely causes UB because we access an object whose lifetime has not begun since it is an inactive variant of the union. As we can observe, there are many places in the Rust standard library that use such tricks.

Your question seems to be a relaxed variant of mine. And the answer should be the same. Yes, it is a UB.

~~https://users.rust-lang.org/t/is-it-still-a-ub-if-an-uninitialized-copy-value-was-sent-into-oblivion-immediately-after-read/90839/4~~

I want to say that the Rust standard library uses many such a trick, if such a usage cause UB, how about the standard library?

In your example, single and double do not have the same memory layout.

layout first 4bytes second 4bytes
Single 1bit 2bit 3bit ... 32bit uninitialized
Double 1bit 2bit 3bit ... 32bit 1bit 2bit 3bit ... 32bit

You used single to initialize the first 4bytes and leave the second 4bytes uninitialized, so you cannot read the second 4bytes by using double.1, which reads the uninitialized value, which is UB.

Sorry I was distracted and somehow saw it as a fat/CHERI pointer. In your example, you are doing the transmute between types with the same size.

Transmute's safety depends on the exact source type and destination type Transmutes - The Rustonomicon. For this specific example on a non-CHERI machine, ptr to usize conversion is considered safe, in fact, there is even a nightly std API for that. However, usize to ptr would generally meet with provenance issues. Although usize to ptr transmute itself is not UB, it is almost certainly going to cause UB when you convert it into reference or read it. You can see more about this in strict provenance proposal

So it really depends if what you want is transmute.

If your union has different size and you are reading into unitialized fields, then my previous answer applies.

1 Like

Keep in mind that std can (annoyingly) play by different rules when it comes to undefined behavior, since the code is compiled with a known compiler version and can take advantage of undocumented internal behaviors, and can be updated when those are changed.

Most likely these specific internal hacks are going to be changed at some point due to pointer provenance as @ZhennanWu mentioned.

5 Likes

Do you think the example in this question has UB?

It’s important to note that there is a significant difference between Rust’s unions and C++’s ones:

Reading and writing union fields

Unions have no notion of an "active field". Instead, every union access just interprets the storage at the type of the field used for the access. Reading a union field reads the bits of the union at the field's type. Fields might have a non-zero offset (except when the C representation is used); in that case the bits starting at the offset of the fields are read. It is the programmer's responsibility to make sure that the data is valid at the field's type. Failing to do so results in undefined behavior. For example, reading the value 3 through of a field of the boolean type is undefined behavior. Effectively, writing to and then reading from a union with the C representation is analogous to a transmute from the type used for writing to the type used for reading.

That being said, the specific case of transmuting a pointer to usize is problematic. Not necessarily UB directly, at least as far as Miri’s current take on this is concerned, but (unlike the usize you’d get by casting some_pointer as usize) the thing you get via transmute may no longer be used to turn it back into a pointer and dereference it, so this will be reported as UB by Miri:

union U{
   addr: * const (),
   val: usize
}
fn main(){
  let p = 0;
  let u = U{addr:(&p as * const i32).cast()};
  let v = unsafe {u.val};
  let q = unsafe {
    *(v as *const i32)
  };
  dbg!(q);
}
6 Likes

So, reading an "inactive" field of a union itself is permitted in Rust, right?

There is no notion of an "inactive" field.

But caveats around layout guarantees apply if repr(C) isn't used, and if you wrote data to one field of the union and read it from another, it's essentially a transmute. Right... now I'm just copying information from the reference I quoted, but you won't get a too concise/general "reading is permitted" from me, as obviously it's still unsafe for abovementioned reasons, but with those things mentioned again, the answer is "yes you are permitted to read from such fields that C/C++ might consider 'inactive'".

(member of T-opsem but speaking for myself)

Rust has no concept of an active union variant nor any kind of typed memory like C++ does. All of the following are effectively equivalent and do the same thing (reinterpret the bytes of one type as another):

fn f1(u: u64) -> i64 {
    unsafe { transmute(u) }
}

fn f2(u: u64) -> i64 {
    unsafe { *(&u as *const u64 as *const i64) }
}

fn f3(u: u64) -> i64 {
    #[repr(C)]
    union U {
        u: u64,
        i: i64,
    }

    unsafe { (U { u }).i }
}

The #[repr(C)] is currently technically required for soundness because there's no formal guarantee that a repr(Rust) (the default) union will lay out all fields at the same offset. Because it's not currently guaranteed how fields overlap in a repr(Rust) union, the only truly sound usage that isn't relying on implementation details only accesses what C++ would call the active variant.

5 Likes

So, if the union has the C representation, the following code

    #[repr(C)]
    union U {
        u: u64,
        i: i64,
    }
    unsafe { (U { u }).i }

is totally a well-defined usage in Rust, right?

Yes, that certainly looks very sound and well-defined to me.

The example is indeed reported as UB, even though I always thought that using a union should be equivalent to transmute. If it isn't, it's a very nasty and subtle footgun, since in C unions are commonly used for transmutation (and are perhaps even the only valid way to do it).

All of the following are supposed to be equivalent:

  • mem::transmute<T, R>(val)
  • *(ptr::addr_of!(val) as *const R)
  • val as R, for certain castable types (integers, usize vs pointer, etc)
  • union U { a: T, b: R}, and reading/writing through different fields.

But for pointers, only as-casts and mem::transmute seem to be declared valid. Is there a specific reasoning for this choice, or should it be considered a bug in Miri?

You might be testing the wrong thing, as for me using transmute results in exactly the same UB being reported as using the union does

fn main(){
  let p = 0;
  let u: *const () = (&p as * const i32).cast();
  let v: usize = unsafe { std::mem::transmute(u) };
  let q = unsafe {
    *(v as *const i32)
  };
  dbg!(q);
}
1 Like

Is it complaining due to pointer provenance here?

Indeed, you're right. In this case at least there is a consistent answer to the question which pointer to integer casts preserve provenance: only as-casts.

But the question stands. Why doesn't Miri use the same approach a C in this case, considering any pointer contained in a transmuted struct, via any of the above mechanisms, as escaped? The usize to *const i32 cast below would behave as ptr::from_exposed_addr, reconstructing the exposed provenance via angelic nondeterminism.

My understanding is that it's more of a technical issue rather than a fundamental case of UB. Namely, the C pointer provenance rules are pretty hard to define, work with or check mechanically. For this reason, they are not yet implemented, and ideally wouldn't be implemented at all, if it turns out that strict provenance is sufficient for all practical purposes. For this reason only the MVP of as-casts works with exposed provenances.

Is this correct?

To quickly summarize the extensive discussions: The problem is that you can't detect, at the time of the read, that the bytes came from a pointer.

1 Like

It could be done for transmute and/or for pointers written into unions, but it can't be done for "indirect transmutes" via pointer casts because the previous type is not known. Magicing an expose for transmute only obscures the underlying issue rather than actually addressing it, so the current opinion is that being consistent and transmute remaining a pure reinterpreting of bytes is preferable.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.