Skip to content
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

Merged
merged 6 commits into from
Mar 4, 2025

Conversation

chescock
Copy link
Contributor

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:

fn bad_system(query: Query<&mut A>) {
    let mut readonly = query.as_readonly();
    let mut lens: QueryLens<&mut A> = readonly.transmute_lens();
    let other_readonly: Query<&A> = query.as_readonly();
    // `lens` and `other_readonly` alias, and are both alive here!
}

To make Query::as_readonly() zero-cost, we pointer-cast &QueryState<D, F> to &QueryState<D::ReadOnly, F>. This means that the component_access for a read-only query's state may include accesses for the original mutable version, but the Query does not have exclusive access to those components! transmute and join 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::joins that output FilteredEntityRef or FilteredEntityMut to receive access from the other query. Currently they only receive access from self.

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 call QueryState::as_transmuted_state() with NewD: ReadOnlyQueryData, so checking for read-only queries is sufficient to check for as_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 a FilteredAccessMut results in a read-only FilteredAccessMut. 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 passing self.component_access to NewD::set_access. Switching it to joined_component_access also allows a join that outputs FilteredEntity(Ref|Mut) to receive access from other. 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.

@chescock chescock added C-Bug An unexpected or incorrect behavior A-ECS Entities, components, systems, and events P-Unsound A bug that results in undefined compiler behavior S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Feb 21, 2025
@andriyDev andriyDev added this to the 0.16 milestone Mar 3, 2025
@@ -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>,
Copy link
Contributor

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?

Copy link
Contributor Author

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!

Copy link
Contributor

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.

@@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary line?

Copy link
Contributor Author

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!

Copy link
Contributor

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".

Copy link
Contributor Author

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!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah even better!

@alice-i-cecile
Copy link
Member

Previously: #5866

@alice-i-cecile alice-i-cecile added S-Ready-For-Final-Review This PR has been approved by the community. It's ready for a maintainer to consider merging it and removed S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Mar 4, 2025
@alice-i-cecile alice-i-cecile added this pull request to the merge queue Mar 4, 2025
Merged via the queue into bevyengine:main with commit 1f6642d Mar 4, 2025
31 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-ECS Entities, components, systems, and events C-Bug An unexpected or incorrect behavior P-Unsound A bug that results in undefined compiler behavior S-Ready-For-Final-Review This PR has been approved by the community. It's ready for a maintainer to consider merging it
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants