Skip to content
Draft
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
25 changes: 25 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: fuzz

on: [push, pull_request]

env:
CARGO_TERM_COLOR: always

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab

- name: Install Rust toolchain
run: rustup default nightly

- name: Install cargo-afl
run: cargo install cargo-afl

- name: Configure AFL
run: cargo afl config --build --force && cargo afl system-config

- name: Canary
run: fuzz/canary.sh
6 changes: 6 additions & 0 deletions fuzz/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.idea
/target
Cargo.lock
/wip
**/*.rs.bk
*.log
21 changes: 21 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[package]
name = "emit_fuzz"
version = "0.0.0"
edition = "2024"
publish = false

[workspace]

[features]
force = []

[[bin]]
name = "timestamp"
path = "timestamp/main.rs"

[dependencies.emit]
path = "../"

[dependencies.afl]
version = "~0.17"
optional = true
48 changes: 48 additions & 0 deletions fuzz/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Fuzzing

This directory contains some fuzz testing infrastructure for `emit`, using AFL++.

## Adding a new test case

1. Add a `fuzz/$TARGET_NAME/main.rs`, where `$TARGET_NAME` is a name for the test, like `timestamp`. See existing cases for the content to include in this file.
2. Add a `[[bin]]` to `fuzz/Cargo.toml` with the new target included.
3. Add an entry to the `fuzz/quickcheck.sh` to run it.

## Running fuzz cases

You'll need to install `cargo.afl`, which can be done through Cargo:

```shell
cargo install -f argo-afl
```

Then, configure your system for fuzzing:

```shell
cargo afl config --build --force
cargo afl system-config
```

Now you can build the fuzz targets:

```shell
cargo afl build --manifest-path fuzz/Cargo.toml --features afl
```

And run one interactively:

```shell
cargo afl fuzz -i fuzz/$TARGET_NAME/in -o target/fuzz_$TARGET_NAME target/debug/fuzz_$TARGET_NAME
```

where `$TARGET_NAME` is the name of the fuzz test you want to run. This should show you an AFL TUI.

## Reproducing crashes

Crashes are saved into the `target` directory and are re-tested by the regular unit tests on each fuzz case. Just run:

```shell
cargo test -p emit_fuzz
```

and any crashes will be re-tested.
16 changes: 16 additions & 0 deletions fuzz/canary.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/bin/bash
set -e

# Linux only
# This is just a quick sanity check for CI. For the simple parsers we're fuzzing, crashes tend to be found _very_ quickly

cargo test --manifest-path fuzz/Cargo.toml
cargo afl build --manifest-path fuzz/Cargo.toml --features afl

# Add new fuzz cases here
AFL_NO_UI=1 timeout 17s cargo afl fuzz -i fuzz/timestamp/in -o fuzz/target/timestamp fuzz/target/debug/timestamp > /dev/null 2>&1 &

echo "waiting for fuzz run..."
sleep 20s

cargo test --manifest-path fuzz/Cargo.toml --features force
81 changes: 81 additions & 0 deletions fuzz/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
use std::{fs, io::Read, panic};

pub fn main(fuzz_fn: impl Fn(&[u8]) + panic::RefUnwindSafe) {
#[cfg(feature = "afl")]
{
afl::fuzz!(|input: &[u8]| { fuzz_fn(input) });
}
#[cfg(not(feature = "afl"))]
{
let _ = fuzz_fn;

panic!("must be run with the `afl` feature")
}
}

pub fn initial_cases(fuzz_target: &str, fuzz_fn: impl Fn(&[u8]) + panic::RefUnwindSafe) {
let dir = format!("{}/{fuzz_target}/in", env!("CARGO_MANIFEST_DIR"),);

println!("running cases in {dir}");

let mut any = false;
for input in fs::read_dir(&dir).expect("failed to read inputs directory") {
any = true;

let input = input.expect("invalid file").path();

println!("input: {:?}", input);

let mut f = fs::File::open(input).expect("failed to open");
let mut input = Vec::new();
f.read_to_end(&mut input).expect("failed to read file");

fuzz_fn(&input);
}

assert!(any, "no test cases were executed");
}

pub fn repro_cases(fuzz_target: &str, fuzz_fn: impl Fn(&[u8]) + panic::RefUnwindSafe) {
let dir = format!(
"{}/target/{fuzz_target}/default",
env!("CARGO_MANIFEST_DIR"),
);

println!("running cases in {dir}");

if let Ok(crashes) = fs::read_dir(format!("{dir}/crashes")) {
let mut failed = false;
for crash in crashes {
let crash = crash.expect("invalid file").path();

if let Some("README.txt") = crash.file_name().and_then(|name| name.to_str()) {
continue;
}

println!("\n-----\nrepro: {crash:?}");

let mut f = fs::File::open(crash).expect("failed to open");
let mut crash = Vec::new();
f.read_to_end(&mut crash).expect("failed to read file");

println!("repro: {crash:?}");
println!("repro: {:?}", String::from_utf8_lossy(&crash));

if let Err(_) = panic::catch_unwind(|| fuzz_fn(&crash)) {
failed = true;
}

println!("-----");
}

if failed {
panic!("some cases failed (see output above for details)");
}
} else {
#[cfg(feature = "force")]
{
assert!(fs::exists(&dir).expect("failed to get file info"), "the {fuzz_target} target didn't execute; this probably means the fuzzing harness is broken");
}
}
}
1 change: 1 addition & 0 deletions fuzz/timestamp/in/1
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
2026-05-30T22:32:09.000Z
27 changes: 27 additions & 0 deletions fuzz/timestamp/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
fn main() {
emit_fuzz::main(de);
}

pub fn de(input: &[u8]) {
// Just make sure we don't panic
let Ok(input) = std::str::from_utf8(input) else {
return;
};

let _ = emit::Timestamp::try_from_str(input);
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn initial() {
emit_fuzz::initial_cases("timestamp", de);
}

#[test]
fn repro() {
emit_fuzz::repro_cases("timestamp", de);
}
}
100 changes: 100 additions & 0 deletions src/buf.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
use core::fmt;

/// Strip leading ASCII whitespace from a byte slice.
pub(crate) fn trim_start(s: &[u8]) -> &[u8] {
let start = s
.iter()
.position(|&b| !b.is_ascii_whitespace())
.unwrap_or(s.len());
&s[start..]
}

/// Strip trailing ASCII whitespace from a byte slice.
pub(crate) fn trim_end(s: &[u8]) -> &[u8] {
let end = s
.iter()
.rposition(|&b| !b.is_ascii_whitespace())
.map(|i| i + 1)
.unwrap_or(0);
&s[..end]
}

/// Strip leading and trailing ASCII whitespace from a byte slice.
pub(crate) fn trim(s: &[u8]) -> &[u8] {
trim_end(trim_start(s))
}

/// Find the first occurrence of any needle byte in the haystack, returning
/// the matched position and its associated skip count.
///
/// Each needle entry is a `(byte, skip)` pair. The `skip` value is returned
/// as `usize` in the result tuple.
pub(crate) fn find(haystack: &[u8], needle: &[(u8, u8)]) -> Option<(usize, usize)> {
needle
.iter()
.filter_map(|(n, cs)| {
haystack
.iter()
.position(|&b| b == *n)
.map(|c| (c, *cs as usize))
})
.next()
}

pub(super) struct Buffer<const N: usize> {
value: [u8; N],
idx: usize,
}

impl<const N: usize> Buffer<N> {
pub(super) fn new() -> Self {
Buffer {
value: [0; N],
idx: 0,
}
}

#[cfg(any(feature = "sval", feature = "serde"))]
pub(super) fn reset(&mut self) {
self.idx = 0;
}

#[cfg(any(feature = "sval", feature = "serde"))]
pub(super) fn as_bytes(&self) -> &[u8] {
&self.value[..self.idx]
}

pub(super) fn push_str(&mut self, s: &str) -> bool {
let s = s.as_bytes();
let next_idx = self.idx + s.len();

if next_idx <= self.value.len() {
self.value[self.idx..next_idx].copy_from_slice(s);
self.idx = next_idx;

true
} else {
false
}
}

pub(super) fn buffer(&mut self, value: impl fmt::Display) -> Result<&[u8], fmt::Error> {
use fmt::Write as _;

self.idx = 0;

write!(self, "{}", value)?;

Ok(&self.value[..self.idx])
}
}

impl<const N: usize> fmt::Write for Buffer<N> {
fn write_str(&mut self, s: &str) -> fmt::Result {
if self.push_str(s) {
Ok(())
} else {
Err(fmt::Error)
}
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ pub use self::{
value::Value,
};

mod buf;
mod macro_hooks;

#[cfg(feature = "std")]
Expand Down
Loading
Loading