Repository navigation
Description
We have previously discussed that we want to make offset(0) always-defined, but never FCP's this decision. We also discussed zero-sized memory accesses, without reaching a clear conclusion. We never discussed the case of zero-offset offset_from.
I propose we resolve all these questions around zero-sized offsets/accesses as follows:
ptr.offset(0)is always defined.ptr1.offset_from(ptr2)is always allowed whenptr1.addr() == ptr2.addr().- Zero-sized accesses (reads and writes) are always allowed with every pointer.
We also adjust the definition of "dereferenceable for n bytes" to say that every pointer is dereferenceable for 0 bytes. This implies that any aligned non-null pointer is valid as a reference to a zero-sized type.
Together these ensure "provenance monotonicity": if something is allowed on a pointer without provenance, then adding arbitrary provenance to the pointer can never introduce UB. We also achieve that for ptr: *const (), ptr.offset(_) is allowed if and only if ptr.read() is allowed (because they are both always allowed).
For offset_from specifically, we need to deal with ptr::invalid::<u8>(A).offset_from(ptr::invalid::<u8>(A)) being allowed for A != 0 (and maybe even for A == 0, see the next section). This means it must still be allowed when we add arbitrary provenance to both pointers, including arbitrary different provenance.
If ptr::invalid returns a provenance-less pointer, then we already allow zero-sized offset/read/write on such provenance-less pointers, and offset_from as well if both pointers have no provenance and the same address, so the proposal is almost the obvious result of applying "provenance monotonicity closure" to the current semantics: keep everything allowed that we currently allow, keep ptr::invalid unchanged, and allow enough additional cases such that provenance monotonicity holds.
Null pointer
This proposal is almost, but not exactly, the provenance monotonicity closure of the current semantics. There is one further change that does not fall out of provenance monotonicity closure:
ptr::null::<()>().read() is allowed, or more generally zero-sized reads/writes with the null pointer are allowed. Those could remain forbidden without violating provenance monotonicity. We allow them for consistency with ptr::null::<T>().offset(0), which we decided to allow, and which in particular C++ (but not C) allows -- having more UB than C++ without a good reason seems like a bad call, and if offset considers 0 bytes to be "in-bounds at the null pointer", then it seems only fair that reads/writes do the same. References still must be non-null, and we assume that null is never in-bounds of a non-zero-sized allocation, so non-zero-sized accesses at null remain UB regardless of whether that memory is mapped or not. This issue is concerned with zero-sized offsets/accesses only, so changing the rules for any other kind of access is out of scope.
There is one downside to this: we can no longer infer "nonnull" from a read/write having happened on a pointer, unless we know that the size of the access was non-zero. However, we can infer "nonnull" for references in general, and we have NonNull to express non-null-ness of raw pointers, so code can still steer the compiler in the right direction if it has to. Furthermore, the size is generally known post-monomorphization, so all the non-null reasoning LLVM does based on a pointer being used for memory accesses is still valid.
Alternative proposal
All that said, there is an alternative proposal that achieves provenance monotonicity. It considers there to exist some dedicated provenance that covers "zero-sized accesses at every location". Let's call this the "zero-sized provenance". ptr::invalid (and thus ptr::null) would be changed to return a pointer with that special provenance. Int-to-ptr transmute would still yield a pointer without provenance. Zero-sized accesses are then allowed if
- the pointer has the zero-sized provenance
- or the pointer has a regular provenance of a live allocation and the address is in-bounds of that allocation
We haven't discussed offset or offset_from under this proposal, but presumably we'd use similar rules: offset(0) is UB on pointers without provenance but allowed on pointers with the zero-sized provenance. On pointers with regular provenance it requires the pointer to be in-bounds. offset_from would be UB if either pointer has no provenance; if they both have the zero-sized provenance then it's allowed if they have the same address; otherwise they must both have regular (non-zero-sized) provenance of some live allocation and be in-bounds of that allocation.
This proposal:
- allows the optimization discussed in the meeting, assuming allocations cannot shrink
- achieves provenance monotonicity
- allows
ptr::null::<T>().offset(0) - for
ptr: *const (), allowsptr.offset(_)if and only ifptr.read()is allowed
However,
- it achieves provenance monotonicity by making more things UB (for instance,
transmute::<_, *const ()>(4usize).read()), which all else being equal seems worse than achieving it by making more things defined - it needs a new intrinsic to create pointers with the zero-sized provenance
There's a variant of this proposal which has not one zero-sized provenance but one such provenance for each address; in that case the pointer is in-bounds for 0 bytes only if it points to that address. This is equivalent to having a zero-sized allocation at every address exist at program startup. This model disallows even more code (for instance, ptr::invalid::<()>(4).byte_add(4).read() becomes UB).
Summary
Overall this means we have a design space of (at least) 6 models:
- zero-sized-accesses-are-nops
- zero-sized-provenance-global
- zero-sized-provenance-per-address
and each of them with or without allowing zero-sized null pointer reads/writes. (I'm assuming it as a given here that we do want to allow zero-sized null pointer offsets and offset_from of the null pointer with itself.)
The two zero-sized-provenance models avoid having a "lattice" of provenance with a non-trivial bottom element: the bottom, "no provenance", just doesn't allow anything at all. OTOH it needs the new concept of a zero-sized provenance (or memory pre-initialized with many zero-sized allocations), making the theory more complicated as well. The main proposal being suggested here make the "bottom" provenance support zero-sized accesses anywhere, thus making the provenance lattice less canonical but avoiding a dedicated "zero-sized provenance".
In the meeting people generally weren't convinced by the one optimization example we have that is enabled by the extra UB in the zero-sized-provenance models (in particular since it is incompatible with shrinking allocations). Using zero-sized accesses/dereferenceability as an optimization signal is not clearly a good idea, and when it comes to UB, implicit/unintended signals are dangerous. Provenance as a concept is already deeply mysterious to many programmers; making it more complicated by introducing imaginary provenance for the zero-sized case (with the penalty of misunderstandings being UB) does not seem advisable. The example could be rewritten to use n.max(4) as the length, which would make the assumption that the length is at least 4 explicit (but of course, the programmer might not know that they have to write this to get all the optimizations).
So, because of all of that, I think we should go with the model that has the least UB. I'll wait a bit before starting FCP to see whether people have any new points they'd like to see added to this summary.
Activity
RalfJung commentedon Nov 4, 2023
It's been a week and there were no comments. Let's go then.
@rfcbot merge
rfcbot commentedon Nov 4, 2023
Team member @RalfJung has proposed to merge this. The next step is review by the rest of the tagged team members:
Concerns:
offset_from-different-allocationsresolved by Decide on zero-sized offsets and memory accesses #472 (comment)Once a majority of reviewers approve (and at most 2 approvals are outstanding), this will enter its final comment period. If you spot a major issue that hasn't been raised at any point in this process, please speak up!
See this document for info about what commands tagged team members can give me.
digama0 commentedon Nov 4, 2023
I have a concern about the case
Shouldn't this say that
ptr1andptr2have the same provenance, or at least are from the same allocation? Because I would assume the compiler wants to deduce that from this comparison, and IIUC this is in the documentation.digama0 commentedon Nov 4, 2023
not sure if I know how to do this:
@rfcbot concern offset_from-different-allocations
rfcbot commentedon Nov 4, 2023
🔔 This is now entering its final comment period, as per the review above. 🔔
chorman0773 commentedon Nov 4, 2023
Nice race condition.
RalfJung commentedon Nov 5, 2023
That would be equivalent to not having any special case for when the two pointers are the same. I thought if we have a special case for offset 0 then we would want a matching special case for offset_from -- but maybe that's not a good idea?
The restriction arises because:
const. But inconst, two pointers from different allocations can never have the same address, so this new case changes nothing.ptrdiffoperation with suitable restrictions does not "leak" the base address of a pointer, which could be helpful for analyses. I am honestly unsure if this is preserved under my proposed change, so maybe you are right and we should keep theoffset_fromspec unchanged...Can we use "ptr1.offset(n) is well-defined with result ptr2" if and only if "ptr2.offset_from(ptr1) is well-defined with result n" as the defining property? I originally thought no, but I just realized this is a lot more subtle. It depends on our notion of equality on pointers. If it's "full equality including having the same provenance", then that property is already busted, since we allow
ptr2.offset_from(ptr1)when both are in the same allocation even if they have different aliasing tags. So we probably want to consider "pointers with their provenance reduced to just the allocation", or some such thing? I honestly don't remember what I was thinking... But if we go with that, then what would the spec be that achieves that property? I think it has to be "they must be to the same allocation, and either at the same offset or both in-bounds". That's a weird spec though? Or maybe not, for the "no provenance" case that's already what we do! What do you all think?But I tend to agree, we want to keep the "same-allocation" restriction.
digama0 commentedon Nov 5, 2023
Is it legal to use
offset_fromwhen the pointers have the null provenance? My initial reading was that your argument was that assuming this is the case, provenance monotonicity forces us to allowoffset_fromfor pointers with arbitrary different provenance. But maybe we can just say thatoffset_fromon null provenance is always UB, even when the pointers have the same value.26 remaining items
RalfJung commentedon Feb 3, 2024
I guess there is a point to be made for specifying the intrinsic behind
offset_fromthe way we document above (so we have provenance monotonicity in the op.sem), but then in the public library function that exposes the intrinsic, add some extra library UB for when the two pointers have different provenance. That would let us change the implementation later if we found a better way.However, what should Miri do in that case? Usually we only implement language UB. Given that it's hard to reason about provenance, that would make this problem quite easy to miss. To represent this properly we'd need some sort of intrinsic that the library function can call to inform Miri that it'd like some extra checks on the provenance...
Auto merge of rust-lang#117658 - RalfJung:ptr-dangling, r=m-ou-se
Auto merge of rust-lang#117658 - RalfJung:ptr-dangling, r=m-ou-se
Auto merge of #117658 - RalfJung:ptr-dangling, r=m-ou-se
Auto merge of rust-lang#117329 - RalfJung:offset-by-zero, r=oli-obk,s…
Auto merge of rust-lang#117329 - RalfJung:offset-by-zero, r=oli-obk,s…
Auto merge of #117329 - RalfJung:offset-by-zero, r=oli-obk,scottmcm
Auto merge of #117329 - RalfJung:offset-by-zero, r=oli-obk,scottmcm
Auto merge of #117329 - RalfJung:offset-by-zero, r=oli-obk,scottmcm
Rollup merge of rust-lang#124921 - RalfJung:offset-from-same-addr, r=…
Rollup merge of rust-lang#124921 - RalfJung:offset-from-same-addr, r=…
Rollup merge of rust-lang#124921 - RalfJung:offset-from-same-addr, r=…
Unrolled build for rust-lang#124921
Rollup merge of #124921 - RalfJung:offset-from-same-addr, r=oli-obk
Auto merge of #117329 - RalfJung:offset-by-zero, r=oli-obk,scottmcm