diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68034988ace05..aad1d4f54a588 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -592,3 +592,41 @@ jobs: shell: bash run: | cargo run --package export-content -- --check + + bevy_ecs-fuzz-prepare: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + targets: ${{ steps.list.outputs.targets }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: List fuzz targets + id: list + run: | + targets=$(ls crates/bevy_ecs/fuzz/fuzz_targets/*.rs | xargs -n1 basename -s .rs | jq -R -s -c 'split("\n") | map(select(. != ""))') + echo "targets=$targets" >> $GITHUB_OUTPUT + + bevy_ecs-fuzz: + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: bevy_ecs-fuzz-prepare + strategy: + fail-fast: false + matrix: + target: ${{ fromJson(needs.bevy_ecs-fuzz-prepare.outputs.targets) }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 + with: + toolchain: ${{ env.NIGHTLY_TOOLCHAIN }} + - name: Install cargo-fuzz + run: cargo install cargo-fuzz + - name: Run fuzz target + working-directory: crates/bevy_ecs/fuzz + env: + FUZZ_TARGET: ${{ matrix.target }} + run: cargo +nightly fuzz run "$FUZZ_TARGET" -- -runs=200000 diff --git a/.gitignore b/.gitignore index b5b92ed77cbeb..6b9753df9cb70 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ assets/serialized_worlds/load_scene_example-new.scn.ron # Generated by "examples/large_scenes" compressed_texture_cache +/crates/bevy_ecs/fuzz/corpus diff --git a/Cargo.toml b/Cargo.toml index 9592d2d1e1bcb..20787f6986423 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,8 @@ members = [ exclude = [ # Integration tests are not part of the workspace "tests-integration", + # Fuzz testing crates + "crates/bevy_ecs/fuzz", ] [workspace.lints.clippy] diff --git a/crates/bevy_ecs/fuzz/Cargo.toml b/crates/bevy_ecs/fuzz/Cargo.toml new file mode 100644 index 0000000000000..8ffef8d4fa3dd --- /dev/null +++ b/crates/bevy_ecs/fuzz/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "bevy_ecs_fuzz" +version = "0.0.0" +publish = false +edition = "2024" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +arbitrary = { version = "1", features = ["derive"] } +bevy_ecs = { path = "..", default-features = false, features = ["std"] } + +[profile.release] +debug = true + +[[bin]] +name = "world_lifecycle" +path = "fuzz_targets/world_lifecycle.rs" + +[[bin]] +name = "query_system" +path = "fuzz_targets/query_system.rs" + +[[bin]] +name = "command_queue" +path = "fuzz_targets/command_queue.rs" + +[[bin]] +name = "observers" +path = "fuzz_targets/observers.rs" + +[[bin]] +name = "hierarchy" +path = "fuzz_targets/hierarchy.rs" + +[[bin]] +name = "schedule" +path = "fuzz_targets/schedule.rs" + +[[bin]] +name = "messages" +path = "fuzz_targets/messages.rs" diff --git a/crates/bevy_ecs/fuzz/README.md b/crates/bevy_ecs/fuzz/README.md new file mode 100644 index 0000000000000..fc022514b6c55 --- /dev/null +++ b/crates/bevy_ecs/fuzz/README.md @@ -0,0 +1,25 @@ +# bevy_ecs fuzz testing + +Fuzz testing for `bevy_ecs`. The fuzzer feeds random byte sequences into test harnesses that exercise ECS operations, looking for crashes, panics, and logic bugs. + +## How it works + +Each fuzz target defines an enum of **operations** (spawn, despawn, insert, remove, query, etc.). The fuzzer generates random bytes, which the `arbitrary` crate decodes into a `Vec`. The harness then executes each operation in sequence against a real `World`. + +**First level of verification**: no operation sequence should cause a panic, crash, or memory error (AddressSanitizer). + +**Second level of verification**: where possible, the harness maintains a **shadow state**, a simple model of what the ECS state should look like. After operations, assertions compare the shadow against the real `World`. + +## Running + +Requires nightly Rust and `cargo-fuzz`: + +```sh +cargo install cargo-fuzz + +# Run a single target indefinitely (Ctrl+C to stop) +cargo +nightly fuzz run world_lifecycle + +# Run for a fixed number of iterations +cargo +nightly fuzz run query_system -- -runs=10000 +``` diff --git a/crates/bevy_ecs/fuzz/fuzz_targets/command_queue.rs b/crates/bevy_ecs/fuzz/fuzz_targets/command_queue.rs new file mode 100644 index 0000000000000..7526829ce07fc --- /dev/null +++ b/crates/bevy_ecs/fuzz/fuzz_targets/command_queue.rs @@ -0,0 +1,183 @@ +#![no_main] +#![allow(dead_code)] + +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; + +use arbitrary::Arbitrary; +use bevy_ecs::prelude::*; +use bevy_ecs::world::CommandQueue; +use bevy_ecs_fuzz::*; +use libfuzzer_sys::fuzz_target; + +#[derive(Debug, Arbitrary)] +pub enum CommandOp { + PushSmall(u8), + PushMedium(u32, u32), + PushLarge(u64, u64, u64, u64), + PushZst, + + PushSpawnA(CompA), + PushSpawnB(CompB), + + PushInsertResource(u64), + + PushRecursiveSpawn, + + Apply, + + AppendAndApply, + + DropQueue, +} + +#[derive(Debug, Arbitrary)] +struct CommandFuzzInput { + ops: Vec, +} + +struct DropToken(Arc); +impl Drop for DropToken { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::Relaxed); + } +} + +struct SmallCmd(u8, DropToken); +impl Command for SmallCmd { + type Out = (); + fn apply(self, _world: &mut World) {} +} + +struct MediumCmd(u32, u32, DropToken); +impl Command for MediumCmd { + type Out = (); + fn apply(self, _world: &mut World) {} +} + +struct LargeCmd(u64, u64, u64, u64, DropToken); +impl Command for LargeCmd { + type Out = (); + fn apply(self, _world: &mut World) {} +} + +struct ZstCmd(DropToken); +impl Command for ZstCmd { + type Out = (); + fn apply(self, _world: &mut World) {} +} + +struct SpawnACmd(CompA); +impl Command for SpawnACmd { + type Out = (); + fn apply(self, world: &mut World) { + world.spawn(self.0); + } +} + +struct SpawnBCmd(CompB); +impl Command for SpawnBCmd { + type Out = (); + fn apply(self, world: &mut World) { + world.spawn(self.0); + } +} + +#[derive(Resource)] +struct FuzzResource(u64); + +struct InsertResourceCmd(u64); +impl Command for InsertResourceCmd { + type Out = (); + fn apply(self, world: &mut World) { + world.insert_resource(FuzzResource(self.0)); + } +} + +struct RecursiveSpawnCmd; +impl Command for RecursiveSpawnCmd { + type Out = (); + fn apply(self, world: &mut World) { + world.commands().queue(|world: &mut World| { + world.spawn(CompA(999)); + }); + world.flush(); + } +} + +fuzz_target!(|input: CommandFuzzInput| { + if input.ops.len() > 256 { + return; + } + + let mut world = World::new(); + let mut queue = CommandQueue::default(); + + let drop_count = Arc::new(AtomicU32::new(0)); + let mut total_pushed: u32 = 0; + + let token = || DropToken(drop_count.clone()); + + for op in &input.ops { + match op { + CommandOp::PushSmall(v) => { + total_pushed += 1; + queue.push(SmallCmd(*v, token())); + } + CommandOp::PushMedium(a, b) => { + total_pushed += 1; + queue.push(MediumCmd(*a, *b, token())); + } + CommandOp::PushLarge(a, b, c, d) => { + total_pushed += 1; + queue.push(LargeCmd(*a, *b, *c, *d, token())); + } + CommandOp::PushZst => { + total_pushed += 1; + queue.push(ZstCmd(token())); + } + + CommandOp::PushSpawnA(a) => { + queue.push(SpawnACmd(a.clone())); + } + CommandOp::PushSpawnB(b) => { + queue.push(SpawnBCmd(b.clone())); + } + CommandOp::PushInsertResource(v) => { + queue.push(InsertResourceCmd(*v)); + } + CommandOp::PushRecursiveSpawn => { + queue.push(RecursiveSpawnCmd); + } + + CommandOp::Apply => { + queue.apply(&mut world); + assert!(queue.is_empty(), "Queue not empty after apply"); + } + + CommandOp::AppendAndApply => { + let mut secondary = CommandQueue::default(); + total_pushed += 1; + secondary.push(SmallCmd(0, token())); + queue.append(&mut secondary); + queue.apply(&mut world); + assert!(queue.is_empty(), "Queue not empty after append+apply"); + } + + CommandOp::DropQueue => { + drop(queue); + queue = CommandQueue::default(); + } + } + } + + drop(queue); + + assert_eq!( + drop_count.load(Ordering::Relaxed), + total_pushed, + "Command consume count mismatch: consumed={}, pushed={}", + drop_count.load(Ordering::Relaxed), + total_pushed, + ); +}); diff --git a/crates/bevy_ecs/fuzz/fuzz_targets/hierarchy.rs b/crates/bevy_ecs/fuzz/fuzz_targets/hierarchy.rs new file mode 100644 index 0000000000000..dc125a1b907c4 --- /dev/null +++ b/crates/bevy_ecs/fuzz/fuzz_targets/hierarchy.rs @@ -0,0 +1,289 @@ +#![no_main] + +use std::collections::{HashMap, HashSet}; + +use arbitrary::Arbitrary; +use bevy_ecs::prelude::*; +use bevy_ecs_fuzz::*; +use libfuzzer_sys::fuzz_target; + +#[derive(Debug, Arbitrary)] +pub enum HierarchyOp { + SpawnEmpty, + SpawnWithA(CompA), + Despawn(u8), + + SetParent(u8, u8), + RemoveParent(u8), + AddChild(u8, u8), + SpawnChild(u8), + DetachAllChildren(u8), + + InsertA(u8, CompA), + RemoveA(u8), + + CheckInvariants, +} + +#[derive(Debug, Arbitrary)] +struct HierarchyFuzzInput { + ops: Vec, +} + +struct Shadow { + alive: Vec, + parent_of: HashMap, +} + +impl Shadow { + fn new() -> Self { + Self { + alive: Vec::new(), + parent_of: HashMap::new(), + } + } + + fn resolve(&self, idx: u8) -> Option { + if self.alive.is_empty() { + None + } else { + Some(self.alive[(idx as usize) % self.alive.len()]) + } + } + + fn spawn(&mut self, e: Entity) { + self.alive.push(e); + } + + fn despawn(&mut self, idx: u8) -> Option { + if self.alive.is_empty() { + return None; + } + let i = (idx as usize) % self.alive.len(); + let e = self.alive[i]; + let mut visited = HashSet::new(); + self.despawn_recursive(e, &mut visited); + Some(e) + } + + fn despawn_recursive(&mut self, e: Entity, visited: &mut HashSet) { + if !visited.insert(e) { + return; // Already visited, avoid cycles + } + + let children: Vec = self + .parent_of + .iter() + .filter(|(_, parent)| **parent == e) + .map(|(child, _)| *child) + .collect(); + + for child in children { + self.despawn_recursive(child, visited); + } + + if let Some(pos) = self.alive.iter().position(|&x| x == e) { + self.alive.swap_remove(pos); + } + self.parent_of.remove(&e); + } + + fn set_parent(&mut self, child: Entity, parent: Entity) { + if child == parent { + return; + } + self.parent_of.insert(child, parent); + } + + fn remove_parent(&mut self, child: Entity) { + self.parent_of.remove(&child); + } + + fn children_of(&self, parent: Entity) -> Vec { + self.parent_of + .iter() + .filter(|(_, p)| **p == parent) + .map(|(c, _)| *c) + .collect() + } +} + +fn check_hierarchy_invariants(world: &mut World, shadow: &Shadow) { + for (&child, &parent) in &shadow.parent_of { + if !shadow.alive.contains(&child) || !shadow.alive.contains(&parent) { + continue; + } + + let child_ref = world.entity(child); + let child_of = child_ref.get::(); + assert!( + child_of.is_some(), + "Entity {child:?} should have ChildOf({parent:?}) but doesn't" + ); + assert_eq!( + child_of.unwrap().parent(), + parent, + "Entity {child:?} has wrong parent: expected {parent:?}, got {:?}", + child_of.unwrap().parent() + ); + } + + for &e in &shadow.alive { + if shadow.parent_of.contains_key(&e) { + continue; + } + let entity_ref = world.entity(e); + assert!( + entity_ref.get::().is_none(), + "Entity {e:?} should NOT have ChildOf but does: {:?}", + entity_ref.get::() + ); + } + + for &e in &shadow.alive { + let entity_ref = world.entity(e); + if let Some(child_of) = entity_ref.get::() { + let parent = child_of.parent(); + if let Ok(parent_ref) = world.get_entity(parent) { + if let Some(children) = parent_ref.get::() { + assert!( + children.iter().any(|c| c == e), + "Entity {e:?} has ChildOf({parent:?}) but parent's Children doesn't contain it" + ); + } else { + panic!( + "Entity {e:?} has ChildOf({parent:?}) but parent has no Children component" + ); + } + } + } + } + + for &e in &shadow.alive { + let entity_ref = world.entity(e); + if let Some(children) = entity_ref.get::() { + let child_list: Vec = children.iter().collect(); + let unique: HashSet = child_list.iter().copied().collect(); + assert_eq!( + child_list.len(), + unique.len(), + "Entity {e:?} has duplicate children" + ); + + for &child in &child_list { + if let Ok(child_ref) = world.get_entity(child) { + let child_of = child_ref.get::(); + assert!( + child_of.is_some(), + "Entity {child:?} is in {e:?}'s Children but has no ChildOf" + ); + assert_eq!( + child_of.unwrap().parent(), + e, + "Entity {child:?} is in {e:?}'s Children but ChildOf points to {:?}", + child_of.unwrap().parent() + ); + } + } + } + } +} + +fuzz_target!(|input: HierarchyFuzzInput| { + if input.ops.len() > 256 { + return; + } + + let mut world = World::new(); + let mut shadow = Shadow::new(); + + for op in &input.ops { + match op { + HierarchyOp::SpawnEmpty => { + let e = world.spawn_empty().id(); + shadow.spawn(e); + } + HierarchyOp::SpawnWithA(a) => { + let e = world.spawn(a.clone()).id(); + shadow.spawn(e); + } + + HierarchyOp::Despawn(idx) => { + if let Some(e) = shadow.despawn(*idx) { + world.despawn(e); + } + } + + HierarchyOp::SetParent(child_idx, parent_idx) => { + if let (Some(child), Some(parent)) = + (shadow.resolve(*child_idx), shadow.resolve(*parent_idx)) + && child != parent + { + shadow.set_parent(child, parent); + world.entity_mut(child).insert(ChildOf(parent)); + } + } + + HierarchyOp::RemoveParent(idx) => { + if let Some(e) = shadow.resolve(*idx) { + shadow.remove_parent(e); + world.entity_mut(e).remove::(); + } + } + + HierarchyOp::AddChild(parent_idx, child_idx) => { + if let (Some(parent), Some(child)) = + (shadow.resolve(*parent_idx), shadow.resolve(*child_idx)) + && child != parent + { + shadow.set_parent(child, parent); + world.entity_mut(parent).add_child(child); + } + } + + HierarchyOp::SpawnChild(parent_idx) => { + if let Some(parent) = shadow.resolve(*parent_idx) { + world.entity_mut(parent).with_child(CompA(0)); + let entity_ref = world.entity(parent); + if let Some(children) = entity_ref.get::() + && let Some(last_child) = children.last() + && !shadow.alive.contains(last_child) + { + shadow.spawn(*last_child); + shadow.set_parent(*last_child, parent); + } + } + } + + HierarchyOp::DetachAllChildren(idx) => { + if let Some(parent) = shadow.resolve(*idx) { + let children = shadow.children_of(parent); + for child in children { + shadow.remove_parent(child); + } + world.entity_mut(parent).detach_all_children(); + } + } + + HierarchyOp::InsertA(idx, a) => { + if let Some(e) = shadow.resolve(*idx) { + world.entity_mut(e).insert(a.clone()); + } + } + HierarchyOp::RemoveA(idx) => { + if let Some(e) = shadow.resolve(*idx) { + world.entity_mut(e).remove::(); + } + } + + HierarchyOp::CheckInvariants => { + check_world_invariants(&mut world, &shadow.alive); + check_hierarchy_invariants(&mut world, &shadow); + } + } + } + + // Always check invariants at the end + check_world_invariants(&mut world, &shadow.alive); + check_hierarchy_invariants(&mut world, &shadow); +}); diff --git a/crates/bevy_ecs/fuzz/fuzz_targets/messages.rs b/crates/bevy_ecs/fuzz/fuzz_targets/messages.rs new file mode 100644 index 0000000000000..78a18cbf3afae --- /dev/null +++ b/crates/bevy_ecs/fuzz/fuzz_targets/messages.rs @@ -0,0 +1,294 @@ +#![no_main] +#![allow(dead_code)] + +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; + +use arbitrary::Arbitrary; +use bevy_ecs::message::{MessageCursor, MessageRegistry, Messages}; +use bevy_ecs::prelude::*; +use libfuzzer_sys::fuzz_target; + +#[derive(Debug, Arbitrary)] +pub enum MessageOp { + WriteSmall(u32), + WriteLarge(u64, u64), + WriteBatch(Vec), + + Update, + + ReadCursor(u8), + ReadAndVerifyOrder(u8), + GetMessage(u8), + + Drain, + ClearCursor(u8), + + CheckLen, + + TriggerEvent(u32), + TriggerEntityEvent(u8, u32), +} + +#[derive(Debug, Arbitrary)] +struct MessageFuzzInput { + ops: Vec, +} + +#[derive(Message, Debug, Clone, PartialEq)] +struct SmallMsg(u32); + +#[derive(Message, Debug, Clone, PartialEq)] +struct LargeMsg(u64, u64); + +#[derive(Event, Debug, Clone)] +struct FuzzEvent(u32); + +#[derive(EntityEvent, Debug, Clone)] +struct FuzzEntityEvent { + entity: Entity, + value: u32, +} + +struct Shadow { + written_small: Vec, + written_ids: Vec, + update_count: u32, + update_boundaries: Vec, + total_writes: usize, + expected_event_count: u32, + expected_entity_event_count: u32, +} + +impl Shadow { + fn new() -> Self { + Self { + written_small: Vec::new(), + written_ids: Vec::new(), + update_count: 0, + update_boundaries: Vec::new(), + total_writes: 0, + expected_event_count: 0, + expected_entity_event_count: 0, + } + } + + fn write_small(&mut self, v: u32) -> usize { + let id = self.total_writes; + self.total_writes += 1; + self.written_small.push(v); + self.written_ids.push(id); + id + } + + fn update(&mut self) { + self.update_count += 1; + self.update_boundaries.push(self.written_small.len()); + } + + fn drain(&mut self) { + self.written_small.clear(); + self.written_ids.clear(); + self.update_boundaries.clear(); + } + + fn surviving_small_messages(&self) -> &[u32] { + if self.update_boundaries.len() < 2 { + &self.written_small + } else { + let cutoff = self.update_boundaries[self.update_boundaries.len() - 2]; + &self.written_small[cutoff..] + } + } +} + +fuzz_target!(|input: MessageFuzzInput| { + if input.ops.len() > 256 { + return; + } + + let mut world = World::new(); + MessageRegistry::register_message::(&mut world); + MessageRegistry::register_message::(&mut world); + + let mut shadow = Shadow::new(); + let mut alive: Vec = Vec::new(); + + let mut cursors: Vec> = Vec::new(); + { + let messages = world.resource::>(); + for _ in 0..4 { + cursors.push(messages.get_cursor()); + } + } + + let event_count = Arc::new(AtomicU32::new(0)); + let entity_event_count = Arc::new(AtomicU32::new(0)); + + { + let c = event_count.clone(); + world.add_observer(move |_: On| { + c.fetch_add(1, Ordering::Relaxed); + }); + } + { + let c = entity_event_count.clone(); + world.add_observer(move |_: On| { + c.fetch_add(1, Ordering::Relaxed); + }); + } + + for op in &input.ops { + match op { + MessageOp::WriteSmall(v) => { + let id = world.write_message(SmallMsg(*v)); + let expected_id = shadow.write_small(*v); + if let Some(id) = id { + assert_eq!( + id.id, expected_id, + "Message ID mismatch: got {}, expected {}", + id.id, expected_id + ); + } + } + + MessageOp::WriteLarge(a, b) => { + world.write_message(LargeMsg(*a, *b)); + } + + MessageOp::WriteBatch(values) => { + if values.len() <= 64 { + let msgs: Vec = values.iter().map(|v| SmallMsg(*v)).collect(); + let messages = world.resource_mut::>(); + let batch_ids = messages.into_inner().write_batch(msgs); + let count = batch_ids.count(); + assert_eq!(count, values.len(), "Batch write count mismatch"); + for v in values { + shadow.write_small(*v); + } + } + } + + MessageOp::Update => { + shadow.update(); + world + .resource_mut::>() + .into_inner() + .update(); + world + .resource_mut::>() + .into_inner() + .update(); + } + + MessageOp::ReadCursor(cursor_idx) => { + let ci = (*cursor_idx as usize) % cursors.len(); + let messages = world.resource::>(); + let read: Vec = cursors[ci].read(messages).cloned().collect(); + for msg in &read { + assert!( + shadow.written_small.contains(&msg.0), + "Read message {:?} that was never written", + msg + ); + } + } + + MessageOp::ReadAndVerifyOrder(cursor_idx) => { + let ci = (*cursor_idx as usize) % cursors.len(); + let messages = world.resource::>(); + let read_with_ids: Vec<_> = cursors[ci] + .read_with_id(messages) + .map(|(msg, id)| (msg.clone(), id.id)) + .collect(); + for window in read_with_ids.windows(2) { + assert!( + window[0].1 < window[1].1, + "Message IDs not monotonic: {} >= {}", + window[0].1, + window[1].1 + ); + } + } + + MessageOp::GetMessage(id_idx) => { + let messages = world.resource::>(); + let id = (*id_idx as usize) % (shadow.total_writes.max(1)); + if let Some((msg, msg_id)) = messages.get_message(id) { + assert_eq!(msg_id.id, id, "get_message returned wrong ID"); + assert!( + shadow.written_small.contains(&msg.0), + "get_message returned unknown message {:?}", + msg + ); + } + } + + MessageOp::Drain => { + let messages = world.resource_mut::>(); + let drained: Vec = messages.into_inner().drain().collect(); + for msg in &drained { + assert!( + shadow.written_small.contains(&msg.0), + "Drained message {:?} that was never written", + msg + ); + } + shadow.drain(); + } + + MessageOp::CheckLen => { + let messages = world.resource::>(); + let surviving = shadow.surviving_small_messages(); + assert_eq!( + messages.len(), + surviving.len(), + "Messages len mismatch: actual={}, expected={}", + messages.len(), + surviving.len() + ); + assert_eq!( + messages.is_empty(), + surviving.is_empty(), + "Messages is_empty mismatch" + ); + } + + MessageOp::ClearCursor(cursor_idx) => { + let ci = (*cursor_idx as usize) % cursors.len(); + let messages = world.resource::>(); + cursors[ci].clear(messages); + let count = cursors[ci].len(messages); + assert_eq!(count, 0, "Cursor not empty after clear"); + } + + MessageOp::TriggerEvent(v) => { + shadow.expected_event_count += 1; + world.trigger(FuzzEvent(*v)); + } + + MessageOp::TriggerEntityEvent(entity_idx, v) => { + if alive.is_empty() { + alive.push(world.spawn_empty().id()); + } + let e = alive[(*entity_idx as usize) % alive.len()]; + shadow.expected_entity_event_count += 1; + world.trigger(FuzzEntityEvent { + entity: e, + value: *v, + }); + } + } + } + + assert_eq!( + event_count.load(Ordering::Relaxed), + shadow.expected_event_count, + "FuzzEvent trigger count mismatch" + ); + assert_eq!( + entity_event_count.load(Ordering::Relaxed), + shadow.expected_entity_event_count, + "FuzzEntityEvent trigger count mismatch" + ); +}); diff --git a/crates/bevy_ecs/fuzz/fuzz_targets/observers.rs b/crates/bevy_ecs/fuzz/fuzz_targets/observers.rs new file mode 100644 index 0000000000000..11a1f7ee123f6 --- /dev/null +++ b/crates/bevy_ecs/fuzz/fuzz_targets/observers.rs @@ -0,0 +1,422 @@ +#![no_main] +#![allow(dead_code)] + +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; + +use arbitrary::Arbitrary; +use bevy_ecs::prelude::*; +use bevy_ecs_fuzz::*; +use libfuzzer_sys::fuzz_target; + +#[derive(Debug, Arbitrary)] +pub enum ObserverOp { + SpawnEmpty, + SpawnA(CompA), + SpawnB(CompB), + SpawnAB(CompA, CompB), + Despawn(u8), + InsertA(u8, CompA), + InsertB(u8, CompB), + InsertC(u8, CompC), + RemoveA(u8), + RemoveB(u8), + RemoveC(u8), + ReplaceA(u8, CompA), + + TriggerCustom(u32), + + DespawnObserver(u8), + + CheckCounts, +} + +#[derive(Debug, Arbitrary)] +struct ObserverFuzzInput { + ops: Vec, +} + +#[derive(Resource, Clone)] +struct ObserverCounts { + add_a: Arc, + insert_a: Arc, + discard_a: Arc, + remove_a: Arc, + add_b: Arc, + insert_b: Arc, + remove_b: Arc, + custom: Arc, +} + +impl Default for ObserverCounts { + fn default() -> Self { + Self { + add_a: Arc::new(AtomicU32::new(0)), + insert_a: Arc::new(AtomicU32::new(0)), + discard_a: Arc::new(AtomicU32::new(0)), + remove_a: Arc::new(AtomicU32::new(0)), + add_b: Arc::new(AtomicU32::new(0)), + insert_b: Arc::new(AtomicU32::new(0)), + remove_b: Arc::new(AtomicU32::new(0)), + custom: Arc::new(AtomicU32::new(0)), + } + } +} + +#[derive(Event)] +struct CustomEvent(u32); + +struct Shadow { + alive: Vec, + observer_entities: Vec, + has_a: std::collections::HashSet, + has_b: std::collections::HashSet, + has_c: std::collections::HashSet, + expected_add_a: u32, + expected_insert_a: u32, + expected_discard_a: u32, + expected_remove_a: u32, + expected_add_b: u32, + expected_insert_b: u32, + expected_remove_b: u32, + expected_custom: u32, +} + +impl Shadow { + fn new() -> Self { + Self { + alive: Vec::new(), + observer_entities: Vec::new(), + has_a: std::collections::HashSet::new(), + has_b: std::collections::HashSet::new(), + has_c: std::collections::HashSet::new(), + expected_add_a: 0, + expected_insert_a: 0, + expected_discard_a: 0, + expected_remove_a: 0, + expected_add_b: 0, + expected_insert_b: 0, + expected_remove_b: 0, + expected_custom: 0, + } + } + + fn resolve(&self, idx: u8) -> Option { + if self.alive.is_empty() { + None + } else { + Some(self.alive[(idx as usize) % self.alive.len()]) + } + } + + fn resolve_observer(&self, idx: u8) -> Option<(usize, Entity)> { + if self.observer_entities.is_empty() { + None + } else { + let i = (idx as usize) % self.observer_entities.len(); + Some((i, self.observer_entities[i])) + } + } + + fn despawn(&mut self, idx: u8) -> Option { + if self.alive.is_empty() { + return None; + } + let i = (idx as usize) % self.alive.len(); + let e = self.alive[i]; + + if self.has_a.remove(&e) { + self.expected_discard_a += 1; + self.expected_remove_a += 1; + } + if self.has_b.remove(&e) { + self.expected_remove_b += 1; + } + self.has_c.remove(&e); + + self.alive.swap_remove(i); + Some(e) + } + + fn spawn(&mut self, e: Entity, a: bool, b: bool) { + self.alive.push(e); + if a { + self.has_a.insert(e); + self.expected_add_a += 1; + self.expected_insert_a += 1; + } + if b { + self.has_b.insert(e); + self.expected_add_b += 1; + self.expected_insert_b += 1; + } + } + + fn insert_a(&mut self, e: Entity) { + if !self.has_a.contains(&e) { + self.expected_add_a += 1; + } else { + self.expected_discard_a += 1; + } + self.expected_insert_a += 1; + self.has_a.insert(e); + } + + fn insert_b(&mut self, e: Entity) { + if !self.has_b.contains(&e) { + self.expected_add_b += 1; + } + self.expected_insert_b += 1; + self.has_b.insert(e); + } + + fn remove_a(&mut self, e: Entity) { + if self.has_a.remove(&e) { + self.expected_discard_a += 1; + self.expected_remove_a += 1; + } + } + + fn remove_b(&mut self, e: Entity) { + if self.has_b.remove(&e) { + self.expected_remove_b += 1; + } + } +} + +fuzz_target!(|input: ObserverFuzzInput| { + if input.ops.len() > 256 { + return; + } + + let mut world = World::new(); + let counts = ObserverCounts::default(); + world.insert_resource(counts.clone()); + + let mut shadow = Shadow::new(); + + { + let c = counts.add_a.clone(); + let e = world + .add_observer(move |_: On| { + c.fetch_add(1, Ordering::Relaxed); + }) + .id(); + shadow.observer_entities.push(e); + } + { + let c = counts.insert_a.clone(); + let e = world + .add_observer(move |_: On| { + c.fetch_add(1, Ordering::Relaxed); + }) + .id(); + shadow.observer_entities.push(e); + } + { + let c = counts.discard_a.clone(); + let e = world + .add_observer(move |_: On| { + c.fetch_add(1, Ordering::Relaxed); + }) + .id(); + shadow.observer_entities.push(e); + } + { + let c = counts.remove_a.clone(); + let e = world + .add_observer(move |_: On| { + c.fetch_add(1, Ordering::Relaxed); + }) + .id(); + shadow.observer_entities.push(e); + } + + { + let c = counts.add_b.clone(); + let e = world + .add_observer(move |_: On| { + c.fetch_add(1, Ordering::Relaxed); + }) + .id(); + shadow.observer_entities.push(e); + } + { + let c = counts.insert_b.clone(); + let e = world + .add_observer(move |_: On| { + c.fetch_add(1, Ordering::Relaxed); + }) + .id(); + shadow.observer_entities.push(e); + } + { + let c = counts.remove_b.clone(); + let e = world + .add_observer(move |_: On| { + c.fetch_add(1, Ordering::Relaxed); + }) + .id(); + shadow.observer_entities.push(e); + } + + { + let c = counts.custom.clone(); + let e = world + .add_observer(move |_: On| { + c.fetch_add(1, Ordering::Relaxed); + }) + .id(); + shadow.observer_entities.push(e); + } + + let e = world + .add_observer(|_: On, mut commands: Commands| { + commands.spawn_empty(); + }) + .id(); + shadow.observer_entities.push(e); + + let initial_observer_count = shadow.observer_entities.len(); + + for op in &input.ops { + match op { + ObserverOp::SpawnEmpty => { + let e = world.spawn_empty().id(); + shadow.spawn(e, false, false); + } + ObserverOp::SpawnA(a) => { + let e = world.spawn(a.clone()).id(); + shadow.spawn(e, true, false); + } + ObserverOp::SpawnB(b) => { + let e = world.spawn(b.clone()).id(); + shadow.spawn(e, false, true); + } + ObserverOp::SpawnAB(a, b) => { + let e = world.spawn((a.clone(), b.clone())).id(); + shadow.spawn(e, true, true); + } + + ObserverOp::Despawn(idx) => { + if let Some(e) = shadow.despawn(*idx) { + world.despawn(e); + } + } + + ObserverOp::InsertA(idx, a) => { + if let Some(e) = shadow.resolve(*idx) { + shadow.insert_a(e); + world.entity_mut(e).insert(a.clone()); + } + } + ObserverOp::InsertB(idx, b) => { + if let Some(e) = shadow.resolve(*idx) { + shadow.insert_b(e); + world.entity_mut(e).insert(b.clone()); + } + } + ObserverOp::InsertC(idx, c) => { + if let Some(e) = shadow.resolve(*idx) { + shadow.has_c.insert(e); + world.entity_mut(e).insert(c.clone()); + } + } + + ObserverOp::RemoveA(idx) => { + if let Some(e) = shadow.resolve(*idx) { + shadow.remove_a(e); + world.entity_mut(e).remove::(); + } + } + ObserverOp::RemoveB(idx) => { + if let Some(e) = shadow.resolve(*idx) { + shadow.remove_b(e); + world.entity_mut(e).remove::(); + } + } + ObserverOp::RemoveC(idx) => { + if let Some(e) = shadow.resolve(*idx) { + shadow.has_c.remove(&e); + world.entity_mut(e).remove::(); + } + } + + ObserverOp::ReplaceA(idx, a) => { + if let Some(e) = shadow.resolve(*idx) { + shadow.insert_a(e); + world.entity_mut(e).insert(a.clone()); + } + } + + ObserverOp::TriggerCustom(v) => { + shadow.expected_custom += 1; + world.trigger(CustomEvent(*v)); + } + + ObserverOp::DespawnObserver(idx) => { + if let Some((i, e)) = shadow.resolve_observer(*idx) { + if world.get_entity(e).is_ok() { + world.despawn(e); + } + shadow.observer_entities.swap_remove(i); + } + } + + ObserverOp::CheckCounts => { + check_world_invariants(&mut world, &shadow.alive); + if shadow.observer_entities.len() == initial_observer_count { + check_observer_counts(&counts, &shadow); + } + } + } + } + + check_world_invariants(&mut world, &shadow.alive); + if shadow.observer_entities.len() == initial_observer_count { + check_observer_counts(&counts, &shadow); + } +}); + +fn check_observer_counts(counts: &ObserverCounts, shadow: &Shadow) { + assert_eq!( + counts.add_a.load(Ordering::Relaxed), + shadow.expected_add_a, + "Add count mismatch" + ); + assert_eq!( + counts.insert_a.load(Ordering::Relaxed), + shadow.expected_insert_a, + "Insert count mismatch" + ); + assert_eq!( + counts.discard_a.load(Ordering::Relaxed), + shadow.expected_discard_a, + "Discard count mismatch" + ); + assert_eq!( + counts.remove_a.load(Ordering::Relaxed), + shadow.expected_remove_a, + "Remove count mismatch" + ); + assert_eq!( + counts.add_b.load(Ordering::Relaxed), + shadow.expected_add_b, + "Add count mismatch" + ); + assert_eq!( + counts.insert_b.load(Ordering::Relaxed), + shadow.expected_insert_b, + "Insert count mismatch" + ); + assert_eq!( + counts.remove_b.load(Ordering::Relaxed), + shadow.expected_remove_b, + "Remove count mismatch" + ); + assert_eq!( + counts.custom.load(Ordering::Relaxed), + shadow.expected_custom, + "CustomEvent count mismatch" + ); +} diff --git a/crates/bevy_ecs/fuzz/fuzz_targets/query_system.rs b/crates/bevy_ecs/fuzz/fuzz_targets/query_system.rs new file mode 100644 index 0000000000000..31a24ae66aa3b --- /dev/null +++ b/crates/bevy_ecs/fuzz/fuzz_targets/query_system.rs @@ -0,0 +1,256 @@ +#![no_main] + +use std::collections::HashSet; + +use arbitrary::Arbitrary; +use bevy_ecs::prelude::*; +use bevy_ecs_fuzz::*; +use libfuzzer_sys::fuzz_target; + +#[derive(Debug, Arbitrary)] +pub enum QueryOp { + SpawnA(CompA), + SpawnB(CompB), + SpawnAB(CompA, CompB), + SpawnABC(CompA, CompB, CompC), + SpawnSparse(CompSparse), + Despawn(u8), + InsertA(u8, CompA), + InsertB(u8, CompB), + InsertC(u8, CompC), + InsertSparse(u8, CompSparse), + RemoveA(u8), + RemoveB(u8), + RemoveC(u8), + RemoveSparse(u8), + + QueryA, + QueryAB, + QueryAWithB, + QueryAWithoutB, + QueryOptionA, + QueryHasA, + QuerySparse, + QueryEntityA, +} + +#[derive(Debug, Arbitrary)] +struct QueryFuzzInput { + ops: Vec, +} + +struct ShadowState { + alive: Vec, + has_a: HashSet, + has_b: HashSet, + has_c: HashSet, + has_sparse: HashSet, +} + +impl ShadowState { + fn new() -> Self { + Self { + alive: Vec::new(), + has_a: HashSet::new(), + has_b: HashSet::new(), + has_c: HashSet::new(), + has_sparse: HashSet::new(), + } + } + + fn spawn(&mut self, e: Entity, a: bool, b: bool, c: bool, sparse: bool) { + self.alive.push(e); + if a { + self.has_a.insert(e); + } + if b { + self.has_b.insert(e); + } + if c { + self.has_c.insert(e); + } + if sparse { + self.has_sparse.insert(e); + } + } + + fn despawn(&mut self, idx: u8) -> Option { + if self.alive.is_empty() { + return None; + } + let i = (idx as usize) % self.alive.len(); + let e = self.alive[i]; + self.alive.swap_remove(i); + self.has_a.remove(&e); + self.has_b.remove(&e); + self.has_c.remove(&e); + self.has_sparse.remove(&e); + Some(e) + } + + fn resolve(&self, idx: u8) -> Option { + if self.alive.is_empty() { + None + } else { + Some(self.alive[(idx as usize) % self.alive.len()]) + } + } +} + +fuzz_target!(|input: QueryFuzzInput| { + if input.ops.len() > 256 { + return; + } + + let mut world = World::new(); + let mut shadow = ShadowState::new(); + + let mut cached_query_a = world.query::<(Entity, &CompA)>(); + + for op in &input.ops { + match op { + QueryOp::SpawnA(a) => { + let e = world.spawn(a.clone()).id(); + shadow.spawn(e, true, false, false, false); + } + QueryOp::SpawnB(b) => { + let e = world.spawn(b.clone()).id(); + shadow.spawn(e, false, true, false, false); + } + QueryOp::SpawnAB(a, b) => { + let e = world.spawn((a.clone(), b.clone())).id(); + shadow.spawn(e, true, true, false, false); + } + QueryOp::SpawnABC(a, b, c) => { + let e = world.spawn((a.clone(), b.clone(), c.clone())).id(); + shadow.spawn(e, true, true, true, false); + } + QueryOp::SpawnSparse(s) => { + let e = world.spawn(s.clone()).id(); + shadow.spawn(e, false, false, false, true); + } + QueryOp::Despawn(idx) => { + if let Some(e) = shadow.despawn(*idx) { + world.despawn(e); + } + } + QueryOp::InsertA(idx, a) => { + if let Some(e) = shadow.resolve(*idx) { + world.entity_mut(e).insert(a.clone()); + shadow.has_a.insert(e); + } + } + QueryOp::InsertB(idx, b) => { + if let Some(e) = shadow.resolve(*idx) { + world.entity_mut(e).insert(b.clone()); + shadow.has_b.insert(e); + } + } + QueryOp::InsertC(idx, c) => { + if let Some(e) = shadow.resolve(*idx) { + world.entity_mut(e).insert(c.clone()); + shadow.has_c.insert(e); + } + } + QueryOp::InsertSparse(idx, s) => { + if let Some(e) = shadow.resolve(*idx) { + world.entity_mut(e).insert(s.clone()); + shadow.has_sparse.insert(e); + } + } + QueryOp::RemoveA(idx) => { + if let Some(e) = shadow.resolve(*idx) { + world.entity_mut(e).remove::(); + shadow.has_a.remove(&e); + } + } + QueryOp::RemoveB(idx) => { + if let Some(e) = shadow.resolve(*idx) { + world.entity_mut(e).remove::(); + shadow.has_b.remove(&e); + } + } + QueryOp::RemoveC(idx) => { + if let Some(e) = shadow.resolve(*idx) { + world.entity_mut(e).remove::(); + shadow.has_c.remove(&e); + } + } + QueryOp::RemoveSparse(idx) => { + if let Some(e) = shadow.resolve(*idx) { + world.entity_mut(e).remove::(); + shadow.has_sparse.remove(&e); + } + } + + QueryOp::QueryA => { + let mut q = world.query::<(Entity, &CompA)>(); + let results: HashSet = q.iter(&world).map(|(e, _)| e).collect(); + assert_eq!(results, shadow.has_a, "Query<&CompA> mismatch"); + } + QueryOp::QueryAB => { + let mut q = world.query::<(Entity, &CompA, &CompB)>(); + let results: HashSet = q.iter(&world).map(|(e, _, _)| e).collect(); + let expected: HashSet = + shadow.has_a.intersection(&shadow.has_b).copied().collect(); + assert_eq!(results, expected, "Query<(&CompA, &CompB)> mismatch"); + } + QueryOp::QueryAWithB => { + let mut q = world.query_filtered::<(Entity, &CompA), With>(); + let results: HashSet = q.iter(&world).map(|(e, _)| e).collect(); + let expected: HashSet = + shadow.has_a.intersection(&shadow.has_b).copied().collect(); + assert_eq!(results, expected, "Query<&CompA, With> mismatch"); + } + QueryOp::QueryAWithoutB => { + let mut q = world.query_filtered::<(Entity, &CompA), Without>(); + let results: HashSet = q.iter(&world).map(|(e, _)| e).collect(); + let expected: HashSet = + shadow.has_a.difference(&shadow.has_b).copied().collect(); + assert_eq!(results, expected, "Query<&CompA, Without> mismatch"); + } + QueryOp::QueryOptionA => { + let mut q = world.query::<(Entity, Option<&CompA>)>(); + let alive_set: HashSet = shadow.alive.iter().copied().collect(); + for (e, opt_a) in q.iter(&world) { + // Only validate entities we track (world has extra resource entities) + if alive_set.contains(&e) { + assert_eq!( + opt_a.is_some(), + shadow.has_a.contains(&e), + "Option<&CompA> mismatch for {e:?}" + ); + } + } + } + QueryOp::QueryHasA => { + let mut q = world.query::<(Entity, Has)>(); + let alive_set: HashSet = shadow.alive.iter().copied().collect(); + for (e, has) in q.iter(&world) { + if alive_set.contains(&e) { + assert_eq!( + has, + shadow.has_a.contains(&e), + "Has mismatch for {e:?}" + ); + } + } + } + QueryOp::QuerySparse => { + let mut q = world.query::<(Entity, &CompSparse)>(); + let results: HashSet = q.iter(&world).map(|(e, _)| e).collect(); + assert_eq!(results, shadow.has_sparse, "Query<&CompSparse> mismatch"); + } + QueryOp::QueryEntityA => { + let results: HashSet = + cached_query_a.iter(&world).map(|(e, _)| e).collect(); + assert_eq!( + results, shadow.has_a, + "Cached Query<&CompA> mismatch (archetype cache invalidation bug?)" + ); + } + } + } + + check_world_invariants(&mut world, &shadow.alive); +}); diff --git a/crates/bevy_ecs/fuzz/fuzz_targets/schedule.rs b/crates/bevy_ecs/fuzz/fuzz_targets/schedule.rs new file mode 100644 index 0000000000000..96d09df7763ff --- /dev/null +++ b/crates/bevy_ecs/fuzz/fuzz_targets/schedule.rs @@ -0,0 +1,292 @@ +#![no_main] + +use std::sync::Arc; + +use arbitrary::Arbitrary; +use bevy_ecs::prelude::*; +use bevy_ecs::schedule::{LogLevel, ScheduleBuildSettings, ScheduleLabel, SingleThreadedExecutor}; +use libfuzzer_sys::fuzz_target; + +#[derive(Debug, Arbitrary)] +pub enum ScheduleOp { + AddSystem(u8), + AddSystemInSet(u8, u8), + + OrderBefore(u8, u8), + OrderAfter(u8, u8), + SetOrderBefore(u8, u8), + SetOrderAfter(u8, u8), + ChainSystems(u8, u8, u8), + + AddRunCondition(u8, bool), + AddSetRunCondition(u8, bool), + + AmbiguousWith(u8, u8), + + Build, + BuildAndRun, +} + +#[derive(Debug, Arbitrary)] +struct ScheduleFuzzInput { + ops: Vec, +} + +#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum FuzzSet { + S0, + S1, + S2, + S3, + S4, + S5, + S6, + S7, +} + +const FUZZ_SETS: [FuzzSet; 8] = [ + FuzzSet::S0, + FuzzSet::S1, + FuzzSet::S2, + FuzzSet::S3, + FuzzSet::S4, + FuzzSet::S5, + FuzzSet::S6, + FuzzSet::S7, +]; + +fn resolve_set(idx: u8) -> FuzzSet { + FUZZ_SETS[(idx as usize) % FUZZ_SETS.len()] +} + +#[derive(ScheduleLabel, Debug, Clone, PartialEq, Eq, Hash)] +struct FuzzSchedule; + +#[derive(Resource, Default, Clone)] +struct ExecutionLog { + order: Arc>>, +} + +fn sys_0(log: Res) { + log.order.lock().unwrap().push(0); +} +fn sys_1(log: Res) { + log.order.lock().unwrap().push(1); +} +fn sys_2(log: Res) { + log.order.lock().unwrap().push(2); +} +fn sys_3(log: Res) { + log.order.lock().unwrap().push(3); +} +fn sys_4(log: Res) { + log.order.lock().unwrap().push(4); +} +fn sys_5(log: Res) { + log.order.lock().unwrap().push(5); +} +fn sys_6(log: Res) { + log.order.lock().unwrap().push(6); +} +fn sys_7(log: Res) { + log.order.lock().unwrap().push(7); +} +fn sys_8(log: Res) { + log.order.lock().unwrap().push(8); +} +fn sys_9(log: Res) { + log.order.lock().unwrap().push(9); +} +fn sys_10(log: Res) { + log.order.lock().unwrap().push(10); +} +fn sys_11(log: Res) { + log.order.lock().unwrap().push(11); +} +fn sys_12(log: Res) { + log.order.lock().unwrap().push(12); +} +fn sys_13(log: Res) { + log.order.lock().unwrap().push(13); +} +fn sys_14(log: Res) { + log.order.lock().unwrap().push(14); +} +fn sys_15(log: Res) { + log.order.lock().unwrap().push(15); +} + +type SystemFn = fn(Res); + +const SYSTEMS: [SystemFn; 16] = [ + sys_0, sys_1, sys_2, sys_3, sys_4, sys_5, sys_6, sys_7, sys_8, sys_9, sys_10, sys_11, sys_12, + sys_13, sys_14, sys_15, +]; + +fn resolve_system(idx: u8) -> (u8, SystemFn) { + let i = (idx as usize) % SYSTEMS.len(); + (i as u8, SYSTEMS[i]) +} + +fuzz_target!(|input: ScheduleFuzzInput| { + if input.ops.len() > 128 { + return; + } + + let mut world = World::new(); + let log = ExecutionLog::default(); + world.insert_resource(log.clone()); + + let mut schedule = Schedule::new(FuzzSchedule); + schedule.set_executor(SingleThreadedExecutor::new()); + schedule.set_build_settings(ScheduleBuildSettings { + ambiguity_detection: LogLevel::Ignore, + hierarchy_detection: LogLevel::Warn, + ..Default::default() + }); + + let mut added_systems: [bool; 16] = [false; 16]; + let mut orderings: Vec<(u8, u8)> = Vec::new(); + + for op in &input.ops { + match op { + ScheduleOp::AddSystem(idx) => { + let (i, sys) = resolve_system(*idx); + if !added_systems[i as usize] { + added_systems[i as usize] = true; + schedule.add_systems(sys); + } + } + + ScheduleOp::AddSystemInSet(sys_idx, set_idx) => { + let (i, sys) = resolve_system(*sys_idx); + let set = resolve_set(*set_idx); + if !added_systems[i as usize] { + added_systems[i as usize] = true; + schedule.add_systems(sys.in_set(set)); + } + } + + ScheduleOp::OrderBefore(a_idx, b_idx) => { + let (a, sys_a) = resolve_system(*a_idx); + let (b, sys_b) = resolve_system(*b_idx); + if a != b && !added_systems[a as usize] && !added_systems[b as usize] { + added_systems[a as usize] = true; + added_systems[b as usize] = true; + schedule.add_systems((sys_a.before(sys_b), sys_b)); + orderings.push((a, b)); + } + } + + ScheduleOp::OrderAfter(a_idx, b_idx) => { + let (a, sys_a) = resolve_system(*a_idx); + let (b, sys_b) = resolve_system(*b_idx); + if a != b && !added_systems[a as usize] && !added_systems[b as usize] { + added_systems[a as usize] = true; + added_systems[b as usize] = true; + schedule.add_systems((sys_a.after(sys_b), sys_b)); + orderings.push((b, a)); + } + } + + ScheduleOp::SetOrderBefore(a_idx, b_idx) => { + let set_a = resolve_set(*a_idx); + let set_b = resolve_set(*b_idx); + if set_a != set_b { + schedule.configure_sets(set_a.before(set_b)); + } + } + + ScheduleOp::SetOrderAfter(a_idx, b_idx) => { + let set_a = resolve_set(*a_idx); + let set_b = resolve_set(*b_idx); + if set_a != set_b { + schedule.configure_sets(set_a.after(set_b)); + } + } + + ScheduleOp::ChainSystems(a_idx, b_idx, c_idx) => { + let (a, sys_a) = resolve_system(*a_idx); + let (b, sys_b) = resolve_system(*b_idx); + let (c, sys_c) = resolve_system(*c_idx); + if a != b + && b != c + && a != c + && !added_systems[a as usize] + && !added_systems[b as usize] + && !added_systems[c as usize] + { + added_systems[a as usize] = true; + added_systems[b as usize] = true; + added_systems[c as usize] = true; + schedule.add_systems((sys_a, sys_b, sys_c).chain()); + orderings.push((a, b)); + orderings.push((b, c)); + } + } + + ScheduleOp::AddRunCondition(sys_idx, val) => { + let (i, sys) = resolve_system(*sys_idx); + let v = *val; + if !added_systems[i as usize] { + added_systems[i as usize] = true; + schedule.add_systems(sys.run_if(move || v)); + } + } + + ScheduleOp::AddSetRunCondition(set_idx, val) => { + let set = resolve_set(*set_idx); + let v = *val; + schedule.configure_sets(set.run_if(move || v)); + } + + ScheduleOp::AmbiguousWith(a_idx, b_idx) => { + let (a, sys) = resolve_system(*a_idx); + let set = resolve_set(*b_idx); + if !added_systems[a as usize] { + added_systems[a as usize] = true; + schedule.add_systems(sys.ambiguous_with(set)); + } + } + + ScheduleOp::Build => { + let _ = schedule.initialize(&mut world); + } + + ScheduleOp::BuildAndRun => { + match schedule.initialize(&mut world) { + Ok(()) => { + log.order.lock().unwrap().clear(); + schedule.run(&mut world); + + let execution = log.order.lock().unwrap().clone(); + for &(before, after) in &orderings { + let pos_before = execution.iter().position(|&x| x == before); + let pos_after = execution.iter().position(|&x| x == after); + if let (Some(pb), Some(pa)) = (pos_before, pos_after) { + assert!( + pb < pa, + "Ordering violation: system {} ran at position {} \ + but should run before system {} at position {}", + before, + pb, + after, + pa, + ); + } + } + + let mut seen = [false; 16]; + for &idx in &execution { + assert!(!seen[idx as usize], "System {} executed twice", idx,); + seen[idx as usize] = true; + } + } + Err(_) => { + // Ignore errors, some are expected with random constraints + } + } + } + } + } +}); diff --git a/crates/bevy_ecs/fuzz/fuzz_targets/world_lifecycle.rs b/crates/bevy_ecs/fuzz/fuzz_targets/world_lifecycle.rs new file mode 100644 index 0000000000000..6b65da63ca774 --- /dev/null +++ b/crates/bevy_ecs/fuzz/fuzz_targets/world_lifecycle.rs @@ -0,0 +1,156 @@ +#![no_main] + +use arbitrary::Arbitrary; +use bevy_ecs::prelude::*; +use bevy_ecs_fuzz::*; +use libfuzzer_sys::fuzz_target; + +#[derive(Debug, Arbitrary)] +pub enum WorldOp { + SpawnEmpty, + SpawnA(CompA), + SpawnB(CompB), + SpawnAB(CompA, CompB), + SpawnABC(CompA, CompB, CompC), + SpawnSparse(CompSparse), + SpawnMarker, + SpawnAll(CompA, CompB, CompC, CompSparse), + + Despawn(u8), + + InsertA(u8, CompA), + InsertB(u8, CompB), + InsertC(u8, CompC), + InsertSparse(u8, CompSparse), + InsertMarker(u8), + + RemoveA(u8), + RemoveB(u8), + RemoveC(u8), + RemoveSparse(u8), + RemoveMarker(u8), + + CheckInvariants, +} + +#[derive(Debug, Arbitrary)] +struct FuzzInput { + ops: Vec, +} + +fuzz_target!(|input: FuzzInput| { + if input.ops.len() > 256 { + return; + } + + let mut world = World::new(); + let mut alive: Vec = Vec::new(); + + for op in &input.ops { + match op { + WorldOp::SpawnEmpty => { + alive.push(world.spawn_empty().id()); + } + WorldOp::SpawnA(a) => { + alive.push(world.spawn(a.clone()).id()); + } + WorldOp::SpawnB(b) => { + alive.push(world.spawn(b.clone()).id()); + } + WorldOp::SpawnAB(a, b) => { + alive.push(world.spawn((a.clone(), b.clone())).id()); + } + WorldOp::SpawnABC(a, b, c) => { + alive.push(world.spawn((a.clone(), b.clone(), c.clone())).id()); + } + WorldOp::SpawnSparse(s) => { + alive.push(world.spawn(s.clone()).id()); + } + WorldOp::SpawnMarker => { + alive.push(world.spawn(Marker).id()); + } + WorldOp::SpawnAll(a, b, c, s) => { + alive.push( + world + .spawn((a.clone(), b.clone(), c.clone(), s.clone(), Marker)) + .id(), + ); + } + + WorldOp::Despawn(idx) => { + if let Some((i, e)) = resolve_idx(*idx, &alive) { + world.despawn(e); + alive.swap_remove(i); + } + } + + WorldOp::InsertA(idx, a) => { + if let Some((_, e)) = resolve_idx(*idx, &alive) { + world.entity_mut(e).insert(a.clone()); + } + } + WorldOp::InsertB(idx, b) => { + if let Some((_, e)) = resolve_idx(*idx, &alive) { + world.entity_mut(e).insert(b.clone()); + } + } + WorldOp::InsertC(idx, c) => { + if let Some((_, e)) = resolve_idx(*idx, &alive) { + world.entity_mut(e).insert(c.clone()); + } + } + WorldOp::InsertSparse(idx, s) => { + if let Some((_, e)) = resolve_idx(*idx, &alive) { + world.entity_mut(e).insert(s.clone()); + } + } + WorldOp::InsertMarker(idx) => { + if let Some((_, e)) = resolve_idx(*idx, &alive) { + world.entity_mut(e).insert(Marker); + } + } + + WorldOp::RemoveA(idx) => { + if let Some((_, e)) = resolve_idx(*idx, &alive) { + world.entity_mut(e).remove::(); + } + } + WorldOp::RemoveB(idx) => { + if let Some((_, e)) = resolve_idx(*idx, &alive) { + world.entity_mut(e).remove::(); + } + } + WorldOp::RemoveC(idx) => { + if let Some((_, e)) = resolve_idx(*idx, &alive) { + world.entity_mut(e).remove::(); + } + } + WorldOp::RemoveSparse(idx) => { + if let Some((_, e)) = resolve_idx(*idx, &alive) { + world.entity_mut(e).remove::(); + } + } + WorldOp::RemoveMarker(idx) => { + if let Some((_, e)) = resolve_idx(*idx, &alive) { + world.entity_mut(e).remove::(); + } + } + + WorldOp::CheckInvariants => { + check_world_invariants(&mut world, &alive); + } + } + } + + // Always check invariants at the end + check_world_invariants(&mut world, &alive); +}); + +pub fn resolve_idx(idx: u8, alive: &[Entity]) -> Option<(usize, Entity)> { + if alive.is_empty() { + None + } else { + let i = (idx as usize) % alive.len(); + Some((i, alive[i])) + } +} diff --git a/crates/bevy_ecs/fuzz/src/lib.rs b/crates/bevy_ecs/fuzz/src/lib.rs new file mode 100644 index 0000000000000..fc1d76351885d --- /dev/null +++ b/crates/bevy_ecs/fuzz/src/lib.rs @@ -0,0 +1,42 @@ +use arbitrary::Arbitrary; +use bevy_ecs::prelude::*; + +#[derive(Component, Clone, Debug, Arbitrary)] +pub struct CompA(pub u32); + +#[derive(Component, Clone, Debug, Arbitrary)] +pub struct CompB(pub u64); + +#[derive(Component, Clone, Debug, Arbitrary)] +pub struct CompC(pub i16); + +#[derive(Component, Clone, Debug, Arbitrary)] +#[component(storage = "SparseSet")] +pub struct CompSparse(pub u8); + +#[derive(Component, Clone, Debug)] +pub struct Marker; + +impl<'a> Arbitrary<'a> for Marker { + fn arbitrary(_u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { + Ok(Marker) + } + fn size_hint(_depth: usize) -> (usize, Option) { + (0, Some(0)) + } +} + +pub fn check_world_invariants(world: &mut World, shadow: &[Entity]) { + assert!( + world.entities().count_spawned() as usize >= shadow.len(), + "World has fewer spawned entities than tracked: world={}, tracked={}", + world.entities().count_spawned(), + shadow.len(), + ); + for &e in shadow { + assert!( + world.entities().contains_spawned(e), + "Tracked entity {e:?} is not spawned in world" + ); + } +}