Miri: undefined behavior of using a `*const _ as *mut _` pointer

While experimenting with unsafe, pointers, stacked borrows, Miri, etc., I stumbled upon an example that I cannot understand. If I have a let mut x = 0, I can think of three way to get a *mut i32 pointing to it:

  1. &mut x as *mut i32
  2. &mut x as *const i32 as *mut i32
  3. &x as *const i32 as *mut i32

I was expecting only option 1 to be correct, while 2 and 3 to lead to undefined behavior if used to write through the pointer.

Now, let's put option 2 aside for a moment. If I do this

fn main() {
    let mut x = 0;
    {
        let a = &mut x as *mut i32;
        unsafe { *a = 1 }; // OK
    }
    {
        let b = &x as *const i32 as *mut i32;
        unsafe { *b = 1 }; // UB?
    }
}

Miri doesn't complain about undefined behavior, whereas if I reverse the order and do this

fn main() {
    let mut x = 0;
    {
        let b = &x as *const i32 as *mut i32;
        unsafe { *b = 1 }; // UB?
    }
    {
        let a = &mut x as *mut i32;
        unsafe { *a = 1 }; // OK
    }
}

it complains (as I expected) with

error: Undefined Behavior: no item granting write access to tag <untagged> at alloc1363 found in borrow stack.
 --> src/main.rs:6:18
  |
6 |         unsafe { *b = 1 }; // UB?
  |                  ^^^^^^ no item granting write access to tag <untagged> at alloc1363 found in borrow stack.

A few questions.

  • What am I missing about the previous example?
  • What is the use of casting from *const _ to *mut _ if, even though it is not instant UB, it becomes UB for every nontrivial operation on the resulting pointer that takes advantage of the mutability? Is the only sensible use to make a type invariant, or are there other circumstances where casting *const_ as *mut _ makes sense?

See https://internals.rust-lang.org/t/what-is-the-real-difference-between-const-t-and-mut-t-raw-pointers/6127

the TL;DR is that *const T and *mut T differ only superficially (and, arguably, we should've had only single * T instead of the two). What matters for stack borrows is the provenance of the pointer -- if you got a pointer from a writable location (&mut T or &UnsafeCell<T>) you can write through it, even if it passed through *const T at some point.

2 Likes

Note: there is a really subtle source of UB

fn main() {
    let mut x = 0;
    
    unsafe {
        let ptr = &mut x as *const i32 as *mut i32;
        *ptr = 0; // UB
    }
}

This is because reference as *const _ is actually (&*reference) as *const _, so if you can it's always best to go to *mut _ first. Once you have raw pointers, you can freely cast between them with no consequences.

5 Likes

I'm not sure I understand.

Miri disagrees with this claim, as proved by this example

fn main() {
    let mut x = 0;
    {
        let a = &mut x as *const i32 as *mut i32;
        unsafe { *a = 1 }; // UB
    }
}

Moreover, it still doesn't explain the dependence on the order in my previous example.

Exactly, and this contradicts the previous claim

if you got a pointer from a writable location ( &mut T or &UnsafeCell<T> ) you can write through it, even if it passed through *const T at some point.

What you (RustyYato) are saying, is my current understanding of how it's supposed to work.

This still doesn't explain the difference in my first two examples. Why does Miri treat them differently? Are they really different? Or is it a limitation of Miri?

@matklad's statement is almost correct. Once you have a *mut T, casting it to *const T has no effect, you are still allowed to write to it. playground. But going directly from a reference to a *const T creates an intermediate shared reference, which is usually not what you want. Writing T to any memory currently borrowed by a &T is UB.

6 Likes

Oh I see, it's because &mut x as *const i32 as *mut i32 chooses the path &mut x as &i32 as *const i32 as *mut i32 rather than &mut x as *mut i32 as *const i32 as *mut i32. Makes sense. Good to know.

Still.... my first two examples?... :slight_smile:

You're writing to memory through a pointer derived from a shared reference, which is UB. The only case where this isn't UB is going from &UnsafeCell<T> to *mut T and writing to that raw pointer.

edit: your first example looks like a bug in MIRI, please report it

Yes, of course, Miri complaining is what I was expecting. I'm asking why it does not complain here

fn main() {
    let mut x = 0;
    {
        let a = &mut x as *mut i32;
        unsafe { *a = 1 }; // OK
    }
    {
        let b = &x as *const i32 as *mut i32;
        unsafe { *b = 1 }; // UB?
    }
}

I'm not sure, maybe @RalfJung can give more insight, I know he works on MIRI

It fails correctly if you remove the first part.

fn main() {
    let mut x = 0;
    {
        let b = &x as *const i32 as *mut i32;
        unsafe { *b = 2 }; // UB?
    }
    println!("{}", x);
}

I assume this is because when you reach the write through an immutable reference, the tag from the previous unique access is still on the stack below the Shr tag, so the Shr tag is just popped from the stack, revealing a previous tag that does have write access.

2 Likes

Related: Inconsistency between stacked borrows and my mental model

1 Like

I think it will complain if you pass -Zmiri-track-raw-pointers. Without that flag, "all raw pointers are treated equal", so Miri "confuses" a and b in your example. With that flag, however, int-ptr-casts do not work well. Turns out that pointers are complicated...