diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..25ef284 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,70 @@ +name: Fuzz + +on: + schedule: + # Run nightly at 2am UTC + - cron: "0 2 * * *" + push: + branches: [main, next, feat/rust] + paths: + - "fuzz/**" + pull_request: + branches: [main, next, feat/rust] + paths: + - "fuzz/**" + workflow_dispatch: + inputs: + fuzz_time: + description: "Seconds to fuzz per target" + default: "300" + required: false + +env: + FUZZ_TIME: ${{ github.event.inputs.fuzz_time || '300' }} + +permissions: + contents: read + +jobs: + fuzz: + name: Fuzz - ${{ matrix.target }} + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + target: + - transform_no_panic + - css_scope_no_panic + - transform_valid_js_output + + steps: + - uses: actions/checkout@v6 + + - name: Install nightly Rust + uses: dtolnay/rust-toolchain@nightly + with: + components: llvm-tools + + - name: Cache cargo + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + fuzz/target/ + key: fuzz-${{ matrix.target }}-cargo-${{ hashFiles('fuzz/Cargo.toml') }} + + - name: Install cargo-fuzz + run: cargo install cargo-fuzz + + - name: Fuzz ${{ matrix.target }} + run: cargo +nightly fuzz run ${{ matrix.target }} -- -max_total_time=${{ env.FUZZ_TIME }} -rss_limit_mb=4096 + + - name: Upload artifacts on failure + if: failure() + uses: actions/upload-artifact@v6 + with: + name: fuzz-artifacts-${{ matrix.target }} + path: fuzz/artifacts/${{ matrix.target }}/ diff --git a/.gitignore b/.gitignore index cdee5bd..3073fc8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ packages/compiler/sourcemap.mjs # Rust /target +/fuzz/target Cargo.lock diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..a092511 --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,3 @@ +target +corpus +artifacts diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..5eba33a --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "astro-compiler-fuzz" +version = "0.0.1" +edition = "2024" +publish = false + +[package.metadata] +cargo-fuzz = true + +[[bin]] +name = "transform_no_panic" +path = "fuzz_targets/transform_no_panic.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "css_scope_no_panic" +path = "fuzz_targets/css_scope_no_panic.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "transform_valid_js_output" +path = "fuzz_targets/transform_valid_js_output.rs" +test = false +doc = false +bench = false + +[dependencies] +libfuzzer-sys = "0.4" +astro_codegen = { path = "../crates/astro_codegen" } +oxc_allocator = { git = "https://github.com/withastro/oxc", branch = "feat/astro" } +oxc_parser = { git = "https://github.com/withastro/oxc", branch = "feat/astro", features = [ + "regular_expression", +] } +oxc_span = { git = "https://github.com/withastro/oxc", branch = "feat/astro" } + +# Prevent this from being included in the normal workspace build. +[workspace] + +[profile.release] +debug = true diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 0000000..276e5b8 --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,116 @@ +# Fuzz Testing + +Fuzz targets for the Astro compiler using [cargo-fuzz](https://github.com/rust-fuzz/cargo-fuzz) (libFuzzer). + +## Targets + +| Target | What it checks | +|--------|---------------| +| `transform_no_panic` | `transform()` never panics on arbitrary input | +| `css_scope_no_panic` | `scope_css()` never panics on arbitrary input | +| `transform_valid_js_output` | valid Astro input → compiler output is valid JS | + +## Prerequisites + +cargo-fuzz requires nightly Rust: + +```sh +rustup toolchain install nightly +cargo install cargo-fuzz +``` + +## Running locally + +```sh +# Run a target (Ctrl-C to stop) +cargo +nightly fuzz run transform_no_panic + +# Run with a memory limit (default is unbounded) +cargo +nightly fuzz run transform_no_panic -- -rss_limit_mb=4096 + +# Run for a fixed time (seconds) +cargo +nightly fuzz run transform_no_panic -- -max_total_time=300 + +# Run with increased verbosity to see coverage progress +cargo +nightly fuzz run transform_no_panic -- -print_final_stats=1 +``` + +When a crash or OOM is found, the input is saved to `fuzz/artifacts//`. + +## Minimizing a crash + +`cargo fuzz tmin` reduces a crashing input to the smallest input that still +triggers the same crash. This makes it easier to understand the root cause and +write a clear bug report. + +```sh +# Minimize a crash artifact +cargo +nightly fuzz tmin fuzz/artifacts// + +# Example: minimize a transform_no_panic OOM +cargo +nightly fuzz tmin transform_no_panic \ + fuzz/artifacts/transform_no_panic/oom-900cd3cf6b6223c1f8772aef650e8f908b043084 + +# The minimized input is written to: +# fuzz/artifacts//minimized-from- +``` + +Tips: +- tmin works by repeatedly trying to remove bytes or replace characters while + checking that the crash still reproduces. It may take a few minutes. +- If tmin gets stuck at a large size, try running it again — libFuzzer uses + randomness and a second pass sometimes finds a shorter path. +- For OOM crashes, tmin tries to find the smallest input that hits the same + allocation limit, not necessarily the exact same code path. +- After minimization, inspect the artifact with `xxd` or `cat` to understand + what the fuzzer found: + ```sh + xxd fuzz/artifacts//minimized-from- + # or, if it's valid UTF-8: + cat fuzz/artifacts//minimized-from- + ``` + +## Reproducing a crash + +To confirm a specific artifact still crashes the target: + +```sh +cargo +nightly fuzz run fuzz/artifacts// +``` + +libFuzzer will run the input once, report the crash, and exit. + +## CI + +The fuzz workflow (`.github/workflows/fuzz.yml`) runs all three targets: +- Nightly at 2am UTC (300 seconds per target) +- On pushes/PRs that touch `fuzz/**` +- Manually via `workflow_dispatch` with configurable `fuzz_time` + +Crash artifacts are uploaded as GitHub Actions artifacts on failure. + +## Known findings + +### 1. Astro parser OOM — `withastro/oxc` + +**Target:** `transform_no_panic`, `transform_valid_js_output` +**Minimized input (3 bytes):** ` Corpus { + let allocator = Allocator::default(); + let ret = Parser::new(&allocator, data, SourceType::astro()) + .with_options(ParseOptions::default()) + .parse_astro(); + + // If the Astro input isn't valid, discard it from the corpus. + if !ret.errors.is_empty() { + return Corpus::Reject; + } + + let result = transform(&allocator, data, TransformOptions::default(), &ret.root); + + // The JS output must parse without errors. + let js_allocator = Allocator::default(); + let js_ret = Parser::new(&js_allocator, &result.code, SourceType::mjs()) + .with_options(ParseOptions::default()) + .parse(); + + if !js_ret.errors.is_empty() { + panic!( + "compiler produced invalid JS for valid Astro input\n\ + input: {data:?}\n\ + output: {:?}\n\ + errors: {:?}", + result.code, js_ret.errors, + ); + } + + Corpus::Keep +} + +fuzz_target!(|data: &str| -> Corpus { do_fuzz(data) });