Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
70 changes: 70 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -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 }}/
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ packages/compiler/sourcemap.mjs

# Rust
/target
/fuzz/target
Cargo.lock
3 changes: 3 additions & 0 deletions fuzz/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
target
corpus
artifacts
44 changes: 44 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
116 changes: 116 additions & 0 deletions fuzz/README.md
Original file line number Diff line number Diff line change
@@ -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/<target>/`.

## 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 <target> fuzz/artifacts/<target>/<artifact>

# 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/<target>/minimized-from-<original-hash>
```

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/<target>/minimized-from-<hash>
# or, if it's valid UTF-8:
cat fuzz/artifacts/<target>/minimized-from-<hash>
```

## Reproducing a crash

To confirm a specific artifact still crashes the target:

```sh
cargo +nightly fuzz run <target> fuzz/artifacts/<target>/<artifact>
```

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):** `<D}`
**Artifact:** `fuzz/artifacts/transform_no_panic/minimized-from-31fa1c120335eb994219313c5f26a18a09cb6fc6`

The oxc Astro parser (`parse_astro_jsx_expression_container` →
`parse_astro_jsx_element`) has no depth limit on malformed nested JSX. The
input `<D}` triggers unbounded allocation until the OOM killer fires.

**Upstream:** needs a depth limit in the `withastro/oxc` Astro JSX parser.

### 2. lightningcss integer overflow / stack overflow

**Target:** `css_scope_no_panic`
**Minimized input (~525 bytes):** `fuzz/artifacts/css_scope_no_panic/minimized-from-7788f3ffd0ed1cc8c2c58f7ee4435169fa29b8bd`
**Original crash:** `fuzz/artifacts/css_scope_no_panic/crash-7788f3ffd0ed1cc8c2c58f7ee4435169fa29b8bd`

Deeply nested `{` CSS causes a stack overflow in `StyleRule::to_css_base ↔
CssRuleList::to_css` (mutual recursion, 200+ frames on macOS). On Linux CI
the same input surfaces as `attempt to add with overflow`.

**Upstream:** needs recursion depth limiting in the lightningcss printer.
10 changes: 10 additions & 0 deletions fuzz/fuzz_targets/css_scope_no_panic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#![no_main]

use astro_codegen::ScopedStyleStrategy;
use astro_codegen::css_scoping::scope_css;
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &str| {
// CSS scoper must never panic on any input.
let _ = scope_css(data, "astro-xxxx", ScopedStyleStrategy::Where);
});
16 changes: 16 additions & 0 deletions fuzz/fuzz_targets/transform_no_panic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#![no_main]

use astro_codegen::{TransformOptions, transform};
use libfuzzer_sys::fuzz_target;
use oxc_allocator::Allocator;
use oxc_parser::{ParseOptions, Parser};
use oxc_span::SourceType;

fuzz_target!(|data: &str| {
let allocator = Allocator::default();
let ret = Parser::new(&allocator, data, SourceType::astro())
.with_options(ParseOptions::default())
.parse_astro();
// Compiler must never panic on any input, valid or not.
let _ = transform(&allocator, data, TransformOptions::default(), &ret.root);
});
46 changes: 46 additions & 0 deletions fuzz/fuzz_targets/transform_valid_js_output.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#![no_main]

use astro_codegen::{TransformOptions, transform};
use libfuzzer_sys::{Corpus, fuzz_target};
use oxc_allocator::Allocator;
use oxc_parser::{ParseOptions, Parser};
use oxc_span::SourceType;

/// For any Astro input that parses without errors, the compiler's JavaScript output
/// must itself be valid JavaScript. If it isn't, the compiler has a bug.
///
/// This is analogous to the ezno parser's roundtrip test: if parsing succeeds,
/// the printed form must also parse successfully.
fn do_fuzz(data: &str) -> 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) });
Loading