-
-
Notifications
You must be signed in to change notification settings - Fork 3.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix unsound query transmutes on queries obtained from Query::as_readonly()
#17973
Fix unsound query transmutes on queries obtained from Query::as_readonly()
#17973
Conversation
… query that was converted to read-only.
@@ -132,7 +135,7 @@ impl<D: QueryData, F: QueryFilter> QueryState<D, F> { | |||
/// `NewD` must have a subset of the access that `D` does and match the exact same archetypes/tables | |||
/// `NewF` must have a subset of the access that `F` does and match the exact same archetypes/tables | |||
pub(crate) unsafe fn as_transmuted_state< | |||
NewD: QueryData<State = D::State>, | |||
NewD: ReadOnlyQueryData<State = D::State>, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we restrict this to ReadOnlyQueryData?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After I wrote a safety comment that said "The current state was transmuted from a mutable QueryData
to a read-only one", I realized that as_transmuted_state()
could technically transmute to mutable queries, as well. So I added the bound to prevent that.
The restriction isn't necessary for soundness, since we have to check that when constructing a new Query
with the transmuted state. But it would be hard to use this method safely with mutable QueryData
, and it didn't seem costly to restrict the API of a pub(crate)
method.
I'm happy to revert this line if anyone feels strongly that it shouldn't change!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suppose if this is a problem in the future, we can deal with it then! So I'm fine with this.
crates/bevy_ecs/src/query/state.rs
Outdated
@@ -751,15 +754,27 @@ impl<D: QueryData, F: QueryFilter> QueryState<D, F> { | |||
let mut fetch_state = NewD::get_state(world.components()).expect("Could not create fetch_state, Please initialize all referenced components before transmuting."); | |||
let filter_state = NewF::get_state(world.components()).expect("Could not create filter_state, Please initialize all referenced components before transmuting."); | |||
|
|||
NewD::set_access(&mut fetch_state, &self.component_access); | |||
let mut self_access; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unnecessary line?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's the local variable that holds the clone()
if we need to make one. It's only initialized in the if
, but it needs to be declared outside the if
so that it lives long enough to be borrowed from. And Rust will handle conditionally dropping it!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we can do this with a little weirdness. I drafted it up in the playground: https://play.rust-lang.org/?version=stable&mode=debug&edition=2024&gist=369649ab4f7a305b17055a0fae83fa5a
I personally think this is preferable since it means we get rid of this rather weird interaction. Generally you need to initialize the variable in both branches - not doing so makes me deeply uncomfortable haha. Also the shadowing seems like a mistake. At least with the internal block it's clear that it's "localized".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, using lifetime extension like that is really clever! The &{
part is a little odd, but I can extract the body into a local function to avoid that. I'll make that change!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah even better!
Previously: #5866 |
Objective
Fix unsound query transmutes on queries obtained from
Query::as_readonly()
.The following compiles, and the call to
transmute_lens()
should panic, but does not:To make
Query::as_readonly()
zero-cost, we pointer-cast&QueryState<D, F>
to&QueryState<D::ReadOnly, F>
. This means that thecomponent_access
for a read-only query's state may include accesses for the original mutable version, but theQuery
does not have exclusive access to those components!transmute
andjoin
use that access to ensure that a join is valid, and will incorrectly allow a transmute that includes mutable access.As a bonus, allow
Query::join
s that outputFilteredEntityRef
orFilteredEntityMut
to receive access from theother
query. Currently they only receive access fromself
.Solution
When transmuting or joining from a read-only query, remove any writes before performing checking that the transmute is valid. For joins, be sure to handle the case where one input query was the result of
as_readonly()
but the other has valid mutable access.This requires identifying read-only queries, so add a
QueryData::IS_READ_ONLY
associated constant. Note that we only callQueryState::as_transmuted_state()
withNewD: ReadOnlyQueryData
, so checking for read-only queries is sufficient to check foras_transmuted_state()
.Removing writes requires allocating a new
FilteredAccess
, so only do so if the query is read-only and the state has writes. Otherwise, the existing access is correct and we can continue using a reference to it.Use the new read-only state to call
NewD::set_access
, so that transmuting to aFilteredAccessMut
results in a read-onlyFilteredAccessMut
. Otherwise, it would take the original write access, and then the transmute would panic because it had too much access.Note that
join
was previously passingself.component_access
toNewD::set_access
. Switching it tojoined_component_access
also allows a join that outputsFilteredEntity(Ref|Mut)
to receive access fromother
. The fact that it didn't do that before seems like an oversight, so I didn't try to prevent that change.Testing
Added unit tests with the unsound transmute and join.