Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -592,3 +592,39 @@
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

Check notice

Code scanning / zizmor

action functionality is already included by the runner Note

action functionality is already included by the runner
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

with:
toolchain: ${{ env.NIGHTLY_TOOLCHAIN }}
- name: Install cargo-fuzz
run: cargo install cargo-fuzz
- name: Run fuzz target ${{ matrix.target }}
working-directory: crates/bevy_ecs/fuzz
run: cargo +nightly fuzz run ${{ matrix.target }} -- -runs=10000

Check warning

Code scanning / zizmor

code injection via template expansion Warning

code injection via template expansion
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
44 changes: 44 additions & 0 deletions crates/bevy_ecs/fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
25 changes: 25 additions & 0 deletions crates/bevy_ecs/fuzz/README.md
Original file line number Diff line number Diff line change
@@ -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<Op>`. 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
```
183 changes: 183 additions & 0 deletions crates/bevy_ecs/fuzz/fuzz_targets/command_queue.rs
Original file line number Diff line number Diff line change
@@ -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<CommandOp>,
}

struct DropToken(Arc<AtomicU32>);
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,
);
});
Loading
Loading