Soundness fix: respect read_scalar errors in read_from_const_alloc. #353
Soundness fix: respect read_scalar errors in read_from_const_alloc. #353eddyb merged 2 commits intoRust-GPU:mainfrom
read_scalar errors in read_from_const_alloc. #353Conversation
| let value = if offset.bytes() == 0 { | ||
| base_addr | ||
| } else { | ||
| self.tcx | ||
| .dcx() | ||
| .fatal("Non-zero scalar_to_backend ptr.offset not supported") | ||
| // let offset = self.constant_bit64(ptr.offset.bytes()); | ||
| // self.gep(base_addr, once(offset)) | ||
| }; | ||
| if let Primitive::Pointer(_) = layout.primitive() { | ||
| assert_ty_eq!(self, value.ty, ty); | ||
| value | ||
| } else { | ||
| self.tcx | ||
| .dcx() | ||
| .fatal("Non-pointer-typed scalar_to_backend Scalar::Ptr not supported"); | ||
| // unsafe { llvm::LLVMConstPtrToInt(llval, llty) } | ||
| } | ||
| self.const_bitcast(self.const_ptr_byte_offset(base_addr, offset), ty) |
There was a problem hiding this comment.
This drive-by change is the minimal subset of backporting this (not yet landed) upstream PR:
I already have later changes to this code that bring it even closer to that version, but I only did this part for the special-case of "reading a Scalar::Ptr as an integer", because the old assert_ty_eq! would fail (while compiling libcore, IIRC?) even though the whole point is to end in a const_bitcast regardless of what ty is.
LegNeato
left a comment
There was a problem hiding this comment.
More straightforward than the other ones! I agree, a result would be better, but I wouldn't block landing this on it.
| /// returning that constant if its size covers the entirety of `alloc`. | ||
| // | ||
| // FIXME(eddyb) should this use something like `Result<_, PartialRead>`? | ||
| pub fn try_read_from_const_alloc( |
There was a problem hiding this comment.
Yeah, I think this would be better with something like Result<(SpirvValue, Size), ReadConstError>
enum ReadConstError {
PartialRead {
read: Size,
expected: Size,
},
Unsupported(&str),
InvalidLayout(String),
Zombie(String),
}and having the other fns return it too, giving something like:
match read_from_const_alloc_at(...) {
Ok((val, size)) if size == alloc.size() => Ok(val),
Ok((_, size)) => Err(ReadConstError::PartialRead {
read: size,
expected: alloc.size(),
}),
Err(e) => Err(e),
}There was a problem hiding this comment.
There's good reasons for the way this works, for the two kinds of "errors":
- individual errors
read_from_const_alloc_atturns into "zombies"- these are like Rust diagnostics (but deferred in the "zombie" way)
- eventually all of these will go away anyway (worst case showing up as
qptrdiagnostics) Resultis an anti-pattern for diagnostics, because it stops at the first error
(which can be fine for leaves, but anything recursive runs into "error buffering" needs)
Nonepotentially being returned bytry_read_from_const_alloc- this isn't even an error, maybe I should've named it
try_read_whole_const_alloc - the size check prevents the opportunistic replacement of
&CONST_Aw/&CONST_B
(ifCONST_Bis effectively some prefix ofCONST_A, i.e. a truncation) - if we wanted to e.g. make
const_fold_loadmore flexible, this wouldn't be used
(instead,read_from_const_alloc_atwould be called directly and always succeed)
- this isn't even an error, maybe I should've named it
(If you've seen me mention how various work in this area, like #348, or even #350 or #341 which have already landed, is somewhat of a prerequisite, or at least arose during some bug fix, this is it, but I decided this is far too important to block on any other improvements, so this PR contains its most minimal form I can think of - the benefits from extra work is mostly diagnostics, but correctness comes first)
The method
read_from_const_alloc(_at) (suffixed after this PR) is responsible for reading the components of a SPIR-V constant of some typetyat anoffsetin some constantalloc(i.e. a miri/CTFE memory allocation, so a mix of plain bytes, symbolic pointers, and uninitialized memory).The first problem was it using
&mutforoffset, and its mutation being relied on for both auto-advancing (e.g. between the components of a SPIR-V vector/matrix), but also for some ad-hoc checks.So the first commit in this PR refactors it to:
offset(structs/arrays adding field/element sub-offsets to it)Sizefor the constant value that was read, alongside that valuety, this is guaranteed (and checked) to equal the size ofty(i.e.
cx.lookup_type(ty).sizeof(cx) == Some(read_size))ty, this mimics Rustmem::size_of_val,(if
tyis, or ends in[T], this will fit as manyTelements as possible inalloc.size(),after
offset, so it'll almost always be the wholealloc, minus at most a gap smaller thanT)create_const_allocfunction (which used to check the final offset w/ anassert_eq!) with anOption-returningtry_read_from_const_allocthat checks the readSizeagainstalloc.size()&CONSTbecause of pointer casts(e.g. if
ARRAY[i]is equivalent to*ARRAY.as_ptr().add(i), and.as_ptr()is a&[T; N] -> *Tcast,you really don't want that to become
*(const { &{ARRAY[0]} } as *const T).add(i)and UB fori > 0)read_from_const_allocinconst_bitcast(the main way&CONSTgets a type) alreadyfits the conditional nature of
try_read_from_const_alloc(and other refactors break w/o such a check)&CONSTuse ofcreate_const_allocwas for the initializer ofstatics, and that can alwaysunwrapthetry_read_from_const_alloc(initializerallocis always the size of thestatic's type)Most of that refactor isn't, strictly speaking, necessary right now (other than making the code less fragile/error-prone), but it's a much cleaner solution than all the workarounds I had previously come up with, downstream of the soundness fix (and e.g. #348 + calling
const_bitcastfrompointercastin more cases).The big soundness issue, however, was that
read_from_const_alloc, for primitive (int/float/pointer) leaves, would callalloc.read_scalar(offset, ...), and treatErr(_)as "undefvalue at that location".But a whole
undefvalue is a very specific case, while the returnedAllocErrorcan indicate:undef(i.e.
allochas a pointer that starts just before/afteroffset, but not atoffsetexactly)Unsoundness arises from spurious
undef(OpUndefin SPIR-V) being misused instead of reporting an error, because it's designed to be ignored by optimizations (or even routine transformations like control-flow structurization), and treated like it can take on any value (i.e. it makes it UB to care about the exact value).Even worse, Rust-GPU is prone to attempt to represent constant data as e.g.
[u32; N], and if thealloccontains any pointers, reading them asu32will result inErr(AllocError::ReadPointerAsInt(_)), and before this PR the pointers would silently be ignored and turned into uninitialized memory.So the second commit in this PR actually handles the
AllocError, and only uses a plainundefwhen all bytes are uninitialized, all other cases being errors - with the caveat that doing more work to produce the correct constant may be possible in some cases, but I haven't put too much effort into it.For now, the one special-case is that it does try to turn "whole pointer attempted to be read as an
usize" errors into ptr->intconst_bitcasts (of the actual pointers) instead, which doesn't do much in terms of debuggability, just yet, but future work to improveconst_bitcastdoes help here.In theory,
OpSpecConstantOpwould let us represent e.g. only some bits beingOpUndef/some pointer, by mixing constants using bitwise ops (e.g.(undef << 24) | ((ptr as u32) >> 8)), but it's more likely we'll first get more untyped constant data, than ever need this.