Skip to content

Surprising behavior of aliasing model: references that are transmuted into existence might not have the expected properties #593

Open

Description

@RalfJung

This code does not have UB under Stacked Borrows and Tree Borrows:

fn main() {
    let mut data = 1;
    let x = &mut data;
    let mut y = &mut 2;
    unsafe { std::ptr::copy_nonoverlapping(&raw const x, &raw mut y, 1); }
    *x = 3;
    *y = 4;
    *x = 5;
    *y = 6;
}

This is expected behavior. The copy_nonoverlapping makes y a perfect copy of x, those two just contain the same pointer. Mutable references are unique in the sense that no "other" pointer is allowed to be used while they are live -- but here, there's no "other" pointer, it's twice the same pointer.

Within the framework that SB and TB are built in, I don't see any way to avoid that. I don't think *x = 3 should mutate x itself, so it follows that x and y just are and remain the same pointer.

I am filing this issue mostly to remind us that when we move towards making decisions around the aliasing model, we should explicitly discuss this case. But maybe someone has an idea for how a model could make this UB?

Activity

  1. added
    A-aliasing-modelTopic: Related to the aliasing model (e.g. Stacked/Tree Borrows)
    on Dec 11, 2025
  2. changed the title [-]Surprising behavior of aliasing model: raw pointers that are transmuted into existence might not be "unique"[/-] [+]Surprising behavior of aliasing model: mutable references that are transmuted into existence might not be "unique"[/+] on Dec 13, 2025
  3. ais523 commented on Feb 6, 2026

    @ais523

    I think it's OK for dereferencing a unique reference to mutate the reference itself (which would of course allow solving the specific example you gave by causing a dereference of a unique reference to do a degenerate typed copy of the reference into itself). After all, DerefMut takes &mut self and you can't write through an & &mut T, which are both outcomes that you would expect if writing through a reference mutated the reference. Treating a dereference of a unique reference as a mutation of the reference itself leads to interesting consequences – while looking at the consequences, they started to form an aliasing model based primarily around the idea of "doing anything with a mutable reference changes which provenance is required to access the memory holding its target, and changes the provenance of the reference itself to match" – this gives you most of a Rust aliasing model for free (e.g. writing to memory will break the provenance of any shared references to that memory, causing future uses of them to become undefined behavior).

    However, while working on it, I found another case of effectively the same phenomenon which is harder to fix: shared references that are transmuted into existence might not freeze their targets (this does not have UB in Stacked Borrows or Tree Borrows):

    use core::cell::Cell;
    use core::ptr;
    
    fn main() {
        let a = Cell::new(4i32);
        let b = &a;
        let mut c: &i32 = &0;
        unsafe { ptr::copy(&raw const b as *const &i32, &raw mut c, 1); }
        dbg!(c); // 4
        a.set(5);
        dbg!(c); // 5
    }

    This one can't be fixed using a "mutating through a reference also mutates the reference" principle because &Cell<i32> is Copy and mutating through it wouldn't be expected to mutate its copies, so you can't have changes through it break the provenance of copies of it like you can with mutable references.

  4. theemathas commented on Feb 6, 2026

    @theemathas

    As a corollary of the above, this means that the assertion in the below code cannot be optimized out. This seems very unfortunate.

    fn main() {
        let mut x: &i32 = &0;
        foo(&mut x);
        let a = *x;
        bar();
        let b = *x;
        assert_eq!(a, b);
    }

    Here's the full code where the assertion fails without UB occurring.

  5. RalfJung commented on Feb 6, 2026

    @RalfJung
    MemberAuthor

    At least for that specific example, we could get the optimization if we say that the validity invariant of an &i32 reference includes not only dereferenceability but also the requirement of this reference being in the "frozen" state (in TB terms).

  6. ais523 commented on Feb 6, 2026

    @ais523

    Would that be compatible with transmuting raw pointers that arrived via FFI into references (and would it be a problem if it weren't)? I don't know how often people transmute the pointers rather than borrowing them (but there is a guarantee that they have the same representation, which might encourage people to do it as a transmute).

  7. RalfJung commented on Feb 6, 2026

    @RalfJung
    MemberAuthor

    If the FFI does not use cross-lang LTO, you can always pretend that provenance got updated as appropriate during the FFI transition. With cross-lang LTO however, indeed such transmutes could become problematic. That's a pretty fundamental tradeoff -- either we allow code to create shared references that are not frozen, or not even FFI can do that.

  8. changed the title [-]Surprising behavior of aliasing model: mutable references that are transmuted into existence might not be "unique"[/-] [+]Surprising behavior of aliasing model: references that are transmuted into existence might not have the expected properties[/+] on Apr 27, 2026
  9. RalfJung commented on Apr 27, 2026

    @RalfJung
    MemberAuthor

    The shared reference version of this might be something GVN already relies on? Not sure. Cc @dianqk @cjgillot
    (EDIT: Yeah looks like it does.)

    We should probably experiment with requiring shared references to be frozen. However that will make mutable-to-shared-ref transmutes UB, which could itself be a source of problems. (It's also not an entirely trivial check since the TB state is per-location and obviously it only needs to be frozen outside UnsafeCell.)

    OTOH if we do this then we don't need to necessarily retag everything everywhere to do things like this.

  10. added 3 commits that reference this issue on Apr 29, 2026
  11. 12 remaining items

  12. ais523 commented on Aug 29, 2026

    @ais523

    I was referring to the example in this comment which reads 4 from c (which is a non-cell shared reference of type &i32) and later reads 5 from c – my suggestion was to make this UB because it reads (via a shared reference) memory that had changed since the shared reference's provenance was created.

    There might be a misunderstanding here: when I say "read via a shared reference", the "shared reference" is referring to the type which performs the read in surface Rust, not to the type of reference that might be indicated by any tags that exist (for the address in question) on the reference's provenance. Tree Borrows disallows the latter, but not the former; I am suggesting that the former should be disallowed as well.

  13. Jules-Bertholet commented on Aug 29, 2026

    @Jules-Bertholet

    That's not correct reasoning, there are TB states where foreign reads do not cause a Freeze.

    Reserved 🤦 – yes, I suppose it would have to be a special magic variant of a fake read. Which is of course less appealing…

  14. RalfJung commented on Aug 30, 2026

    @RalfJung
    MemberAuthor

    I was referring to the example in this comment which reads 4 from c (which is a non-cell shared reference of type &i32) and later reads 5 from c – my suggestion was to make this UB because it reads (via a shared reference) memory that had changed since the shared reference's provenance was created.

    There might be a misunderstanding here: when I say "read via a shared reference", the "shared reference" is referring to the type which performs the read in surface Rust, not to the type of reference that might be indicated by any tags that exist (for the address in question) on the reference's provenance. Tree Borrows disallows the latter, but not the former; I am suggesting that the former should be disallowed as well.

    Yeah that's what this issue is about -- how we could make the program UB. We agree that it should be disallowed; so far there is no proposal for how it could be disallowed.

    Or are you saying that your proposal should be read as an operational semantics? It is not well-defined. That's what I pointed out to you above: Your proposal uses the phrase "since the shared reference's provenance was created" while there isn't a shared reference being created here. (Potentially we could consider the *x load to construct a shared reference as it's doing a typed &i32 load, but that shared reference is then only used once, so the mutation condition you gave is not violated by the program). So it's not meaningful to ask about when it's provenance was created or so.

    If you think there's an operational semantics here, please write it out in operational terms. Otherwise 3 people reading this will have 5 different ideas of what you actually mean.

  15. ais523 commented on Aug 30, 2026

    @ais523

    I intended it as an operational semantics, but for the purpose of resolving the second example in this thread (the one in this post), not the example in the OP. (It does not fix the example in the OP, but the two examples likely need separate fixes.) The example in question does not contain x, so I think we were talking about different programs (which likely explains the confusion). Repeating the example to avoid confusion:

    use core::cell::Cell;
    use core::ptr;
    
    fn main() {
        let a = Cell::new(4i32);
        let b = &a;
        let mut c: &i32 = &0;
        unsafe { ptr::copy(&raw const b as *const &i32, &raw mut c, 1); }
        dbg!(c); // 4
        a.set(5);
        dbg!(c); // 5
    }

    In this situation, c is (in the source program) a shared reference &i32, and yet it's possible under Shared Borrows or Tree Borrows to read two different values from it without causing any aliasing model violations.

    My opsem proposal was that attempts to use c as though it were a shared reference should be UB if the memory being read has been changed since its provenance was created. In this case, the second dbg!(c) dereferences c, but the memory being dereferenced has changed since its provenance was created (the provenance in question was created by the borrow in the let b = &a; line), so this rule would consider the second dbg! to be undefined behavior (whereas it is defined behavior in the current versions of Shared Borrows and Tree Borrows).

    To define "use X as though it were a shared reference" precisely, even in the presence of transmutes and outside-subobject reads, I would define it as "any read through a reborrow or transitive reborrow of X in which X's type at the time when the relevant reborrow of X occurred was a shared reference type, or any read directly through X in which X's type is a shared reference type" (adjusted for cells in a way that's consistent with how cells are handled in the rest of the aliasing model, and defining "read" to include retag reads in cases where the aliasing model would perform a retag read).

  16. RalfJung commented on Aug 31, 2026

    @RalfJung
    MemberAuthor

    I intended it as an operational semantics, but for the purpose of resolving the second example in this thread (the one in #593 (comment)), not the example in the OP.

    Ah I see.
    That example similarly doesn't construct an &i32 reference though, it constructs an &Cell<i32> reference and then stores it in memory in a location that we later load at type &i32. That load, the *c hidden inside the dbg!, is the first time an &i32 gets crated (ignoring the dummy &0), and it is very short-lived.

    Or are you saying that even &Cell<i32> references should track foreign writes somehow? That on its own already may be problematic, the entire point of those references is that they don't care about foreign accesses. But even then it seems you are saying that when we load a load through a pointer, the type of that pointer matters. That's a highly non-trivial departure from SB/TB, and I am not convinced it'll work very well. Loads occur from places, not pointers, so one would have to define how this kind of information propagates through place evaluation. Currently, the tag (and the pointee type) is the only information we preserve.

    Also your proposal fails with this variant I think:

    use core::cell::Cell;
    use core::ptr;
    
    fn main() {
        let a = Cell::new(4i32);
        let b = &raw const a; // no new provenance being created!
        let mut c: &i32 = &0;
        unsafe { ptr::copy(&raw const b as *const *const i32 as *const &i32, &raw mut c, 1); }
        dbg!(c); // 4
        a.set(5);
        dbg!(c); // 5
    }
  17. ais523 commented on Aug 31, 2026

    @ais523
  18. RalfJung commented on Aug 31, 2026

    @RalfJung
    MemberAuthor

    Your variant example made me realise that this idea is not compatible with Tree Borrows, though: although it considers the behavior of the example in question to be undefined,

    You mean, with your proposal (that I haven't really grasped yet) added? In TB today the code is accepted.

    in Tree Borrows, the creation of the raw pointer doesn't create a new provenance,

    Indeed, that's the point of the variant. In SB, raw pointers create a new provenance, and I think that was one of its biggest flaws. Having to figure out when raw pointers get created from references is very finicky, and I feel quite strongly that this is a question we should simply not have to answer.

  19. ais523 commented on Aug 31, 2026

    @ais523

    Right, I meant the example is undefined under my proposal.

    I think there's a contradiction between desirable features here. Here's a slightly modified version of your variant (which also demonstrates that even if raw pointers didn't share the provenance of the place they were created from, it wouldn't solve the problem):

    use core::cell::Cell;
    use core::ptr;
    
    fn main() {
        let a = Cell::new(4i32);
        let b = &raw const a;
        unsafe { (*b).set(5); }
        let mut c: &i32 = &0;
        unsafe { ptr::copy(&raw const b as *const &i32, &raw mut c, 1); }
        dbg!(c);
        unsafe { (*b).set(6); }
        unsafe { (*b).set(7); }
        if false {
            unsafe { ptr::copy(&raw const b as *const &i32, &raw mut c, 1); }
        }
        dbg!(c);
        unsafe { (*b).set(8); }
    }

    As written, the program reads two different values from c (which is &i32). I consider it undesirable for the aliasing model to consider this code to have defined behavior (because it means that the aliasing model cannot assume that the target of a shared reference will remain immutable unless it can see how the shared reference was created, which in turn prevents a wide range of useful optimizations: notably, async blocks take their own local variables by reference, and thus are unable to see how their own local variables were created).

    Meanwhile, if you replace the if false with if true you obtain a program which does the sequence "write to a cell, transmute-without-retag a pointer to that cell into a shared reference and read through it, then write to the cell again" twice. If the program had created the shared reference by borrowing a dereference of the pointer (rather than transmuting it), the behavior would unambiguously be defined (e.g. this sort of pattern is possible using RefCell). Declaring the if true version of this code to have undefined behavior would therefore effectively be stating that pointer-to-reference transmutes do not produce a usable reference, as that's the only difference between this program and an unambiguously legal one.

    The problem is that these two programs are almost identical: the only difference is whether or not to perform an apparently redundant untyped copy of b into c, so in order for the if false version of the program to have undefined behavior and the if true version to have defined behavior, something has to change the provenance of b or c after the first ptr::copy call and before the second (otherwise the second copy will be a no-op). c is not written to or through during the time period in question, and b is a raw pointer, so it is hard to see how either provenance could change.

    I think "pointer-to-reference transmutes might not produce a usable reference" is the most reasonable possibility here, but all of them look fairly terrible.

  20. RalfJung commented on Aug 31, 2026

    @RalfJung
    MemberAuthor

    I think "pointer-to-reference transmutes might not produce a usable reference" is the most reasonable possibility here, but all of them look fairly terrible.

    That is what I suggested above, right? We say that it's UB to create a shared reference with a non-frozen provenance.

    I think the next step is to implement that in Miri so we can check how commonly code violates this requirement.

  21. ais523 commented on Aug 31, 2026

    @ais523

    Right; I think the only room for choice with this approach is at what specific time the UB happens. Should the following be UB, or would you have to use c more than this minimally in order for the code to be undefined?:

    use core::ptr;
    
    fn main() {
        let a = 1;
        let b = &raw const a;
        let mut c: &i32 = &0;
        unsafe { ptr::copy(&raw const b as *const &i32, &raw mut c, 1); }
        let _ = *c;
    }

    I suspect that this should probably not be UB in order to be consistent with the opsem for closure captures, but the problem with considering this to be not-UB is that you would then need to track that the place *c had been created from an incorrectly tagged shared reference. I guess the easiest implementation would be to say that places that are created from an incorrectly tagged shared reference don't have a usable provenance.

  22. RalfJung commented on Aug 31, 2026

    @RalfJung
    MemberAuthor

    I would start by making such code UB too. This is after all a typed load of c and thus all its validity requirements apply, including the new one about its provenance.

    If there's evidence that this causes unnecessary UB in the wild we can reconsider.

  23. added 2 commits that reference this issue on Oct 1, 2026
  24. Jules-Bertholet commented on Oct 10, 2026

    @Jules-Bertholet

    I'd really like to avoid "&mut T → &T transmute is UB", I think that would be a much too heavy hammer.


    Here's a sketch of a possible semantics:

    The tag of every reference and local variable has an associated "subtag map" (of type tag → tag), which starts out empty. Whenever a load is performed through some reference or local r, each tag associated with the bytes of the loaded value $val$1 is looked up in the map. Each tag $T$ in $val$ which corresponds to a key in the r's subtag map is replaced with the associated value for that key. If there is no entry for $T$ in the map, one is created by retagging the original tag from val. The resulting value of the load, $val\prime$, has all its tags replaced via this process.

    Concretely:

    fn main() {
        let mut data = 1;
    
        let x = &mut data;
        // Tag associated with local variable `x`
        // (let's call it `xloc_tag`)
        // initialized with empty subtag map.
        // Tag associated with the reference stored in variable `x`
        // (let's call it `xref_tag`)
        // also initialized with empty subtag map.
    
        let mut y = &mut 2;
        // Tag associated with local variable `y`
        // (let's call it `yloc_tag`)
        // initialized with empty subtag map.
        // Tag associated with the reference stored in variable `y`
        // (let's call it `yref_tag`)
        // also initialized with empty subtag map.
    
        unsafe { std::ptr::copy_nonoverlapping(&raw const x, &raw mut y, 1); }
        // Now the reference stored in variable `y` is a copy of the one in `x`,
        // so it has tag `xref_tag`.
    
        *x = 3;
        // The load of local variable `x` causes an entry to be inserted into `xloc_tag`'s subtag map,
        // `xref_tag` => `xreftag_xloc`, the latter being retagged from the former.
        // `xreftag_xloc` is used as the tag of the mutable reference resulting from the load.
    
        *y = 4;
        // The load of local variable  `y` causes an entry to be inserted into `yloc_tag`'s subtag map,
        // `xref_tag` => `xreftag_yloc`, the latter being retagged from the former.
        // `xreftag_yloc` is used as the tag of the mutable reference resulting from the load.
        // Assignment via that reference is therefore a foreign write for `xreftag_xloc`,
        // disabling that tag.
    
        *x = 5;
        // The load of local variable `x` engenders a lookup of `xref_tag` in `xloc_tag`'s subtag map.
        // This returns `xreftag_xloc`, which is therefore used as the tag of the mutable reference resulting from the load.
        // However, this tag is disabled, so we have created a disabled mutable reference, which is UB!
    
        *y = 6;
    }

    (There are a lot of holes left to fill before this can become a real proposal, of course. Like "what happens to the subtag map when you reborrow a reference")

    Footnotes

    1. Exempting anything inside MaybeDangling, MaybeUninit, etc. ↩

  25. Jules-Bertholet commented on Oct 11, 2026

    @Jules-Bertholet

    what happens to the subtag map when you reborrow a reference

    Thinking about this more: whenever a subtag map lookup fails, we should walk the chain of ancestor tags, check each of their subtag maps, retag the first result found, and insert that into the child map. Only if nothing is found should the original tag from the loaded value be retagged.

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-aliasing-modelTopic: Related to the aliasing model (e.g. Stacked/Tree Borrows)

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions