Merge pull request #139 from Kijewski/pr-fuzz-parser

Re-add fuzzing
This commit is contained in:
Guillaume Gomez 2024-08-19 14:41:50 +02:00 committed by GitHub
commit 54d0b3511a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 520 additions and 2 deletions

View File

@ -37,6 +37,7 @@ jobs:
package: [
rinja, rinja_actix, rinja_axum, rinja_derive, rinja_derive_standalone,
rinja_parser, rinja_rocket, rinja_warp, testing, examples/actix-web-app,
fuzzing
]
runs-on: ubuntu-latest
steps:
@ -65,7 +66,8 @@ jobs:
set -eu
for PKG in \
rinja rinja_actix rinja_axum rinja_derive rinja_derive_standalone \
rinja_parser rinja_rocket rinja_warp testing examples/actix-web-app
rinja_parser rinja_rocket rinja_warp testing examples/actix-web-app \
fuzzing
do
cd "$PKG"
echo "Testing: $PKG"
@ -79,7 +81,7 @@ jobs:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: "1.71.0"
toolchain: "1.71.1"
- run: cargo check --lib -p rinja --all-features
Audit:

19
fuzzing/Cargo.toml Normal file
View File

@ -0,0 +1,19 @@
[package]
name = "rinja_fuzzing"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
publish = false
[dependencies]
fuzz = { path = "fuzz" }
arbitrary = "1.3.2"
pretty-error-debug = "0.3.0"
thiserror = "1.0.63"
[workspace]
members = [".", "fuzz"]
[profile.release]
debug = 1

31
fuzzing/README.md Normal file
View File

@ -0,0 +1,31 @@
# Rinja Fuzzing
First install `cargo-fuzz` and rust-nightly (once):
```sh
cargo install cargo-fuzz
rustup install nightly
```
Then execute in this folder:
```sh
RUST_BACKTRACE=1 nice cargo +nightly fuzz run <fuzz_target>
```
`fuzz_target` is one out of `all`, `filters`, `html` or `parser`.
The execution won't stop, but continue until you kill it with ctrl+c.
Or until it finds a panic.
If the execution found a panic, then a file with the input scenario is written, e.g.
`fuzz/artifacts/parser/crash-b91ab…`.
To get more information about the failed scenario, run or debug this command with the given path:
```sh
cargo run -- <fuzz_target> fuzz/artifacts/parser/crash-b91ab…
```
Find more information about fuzzing here:
* `cargo fuzz help run`
* <https://rust-fuzz.github.io/book/cargo-fuzz.html>

2
fuzzing/fuzz/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/artifacts/
/corpus/

42
fuzzing/fuzz/Cargo.toml Normal file
View File

@ -0,0 +1,42 @@
[package]
name = "fuzz"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
publish = false
[package.metadata]
cargo-fuzz = true
[dependencies]
rinja = { path = "../../rinja" }
rinja_parser = { path = "../../rinja_parser" }
arbitrary = { version = "1.3.2", features = ["derive"] }
html-escape = "0.2.13"
libfuzzer-sys = "0.4.7"
thiserror = "1.0.63"
[[bin]]
name = "all"
path = "fuzz_targets/all.rs"
test = false
doc = false
[[bin]]
name = "filters"
path = "fuzz_targets/filters.rs"
test = false
doc = false
[[bin]]
name = "html"
path = "fuzz_targets/html.rs"
test = false
doc = false
[[bin]]
name = "parser"
path = "fuzz_targets/parser.rs"
test = false
doc = false

View File

@ -0,0 +1,5 @@
#![no_main]
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
let _ = <fuzz::all::Scenario as fuzz::Scenario>::fuzz(data);
});

View File

@ -0,0 +1,5 @@
#![no_main]
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
let _ = <fuzz::filters::Scenario as fuzz::Scenario>::fuzz(data);
});

View File

@ -0,0 +1,5 @@
#![no_main]
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
let _ = <fuzz::html::Scenario as fuzz::Scenario>::fuzz(data);
});

View File

@ -0,0 +1,5 @@
#![no_main]
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
let _ = <fuzz::parser::Scenario as fuzz::Scenario>::fuzz(data);
});

71
fuzzing/fuzz/src/all.rs Normal file
View File

@ -0,0 +1,71 @@
macro_rules! this_file {
($(crate :: $mod:ident :: $ty:ident;)*) => {
use std::fmt;
use arbitrary::{Arbitrary, Unstructured};
#[derive(Debug)]
pub enum Scenario<'a> {
$($ty(crate::$mod::Scenario<'a>),)*
}
impl<'a> super::Scenario<'a> for Scenario<'a> {
type RunError = RunError;
fn new(data: &'a [u8]) -> Result<Self, arbitrary::Error> {
let mut data = Unstructured::new(data);
let target = Arbitrary::arbitrary(&mut data)?;
let data = Arbitrary::arbitrary_take_rest(data)?;
match target {
$(Target::$ty => Ok(Self::$ty(crate::$mod::Scenario::new(data)?)),)*
}
}
fn run(&self) -> Result<(), Self::RunError> {
match self {
$(Self::$ty(scenario) => scenario.run().map_err(RunError::$ty),)*
}
}
}
#[derive(Arbitrary)]
enum Target {
$($ty,)*
}
pub enum RunError {
$($ty(<crate::$mod::Scenario<'static> as crate::Scenario<'static>>::RunError),)*
}
impl fmt::Display for RunError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
$(Self::$ty(err) => err.fmt(f),)*
}
}
}
impl fmt::Debug for RunError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
$(Self::$ty(err) => err.fmt(f),)*
}
}
}
impl std::error::Error for RunError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
$(RunError::$ty(err) => Some(err),)*
}
}
}
};
}
this_file! {
crate::filters::Filters;
crate::html::Html;
crate::parser::Parser;
}

View File

@ -0,0 +1,88 @@
use arbitrary::{Arbitrary, Unstructured};
use rinja::filters;
#[derive(Arbitrary, Debug, Clone, Copy)]
pub enum Scenario<'a> {
Text(Text<'a>),
}
impl<'a> super::Scenario<'a> for Scenario<'a> {
type RunError = rinja::Error;
fn new(data: &'a [u8]) -> Result<Self, arbitrary::Error> {
Self::arbitrary_take_rest(Unstructured::new(data))
}
fn run(&self) -> Result<(), Self::RunError> {
match *self {
Self::Text(text) => run_text(text),
}
}
}
fn run_text(filter: Text<'_>) -> Result<(), rinja::Error> {
let Text { input, filter } = filter;
let _ = match filter {
TextFilter::Capitalize => filters::capitalize(input)?.to_string(),
TextFilter::Center(a) => filters::center(input, a)?.to_string(),
TextFilter::Indent(a) => filters::indent(input, a)?.to_string(),
TextFilter::Linebreaks => filters::linebreaks(input)?.to_string(),
TextFilter::LinebreaksBr => filters::linebreaksbr(input)?.to_string(),
TextFilter::Lowercase => filters::lowercase(input)?.to_string(),
TextFilter::ParagraphBreaks => filters::paragraphbreaks(input)?.to_string(),
TextFilter::Safe(e) => match e {
Escaper::Html => filters::safe(input, filters::Html)?.to_string(),
Escaper::Text => filters::safe(input, filters::Text)?.to_string(),
},
TextFilter::Title => filters::title(input)?.to_string(),
TextFilter::Trim => filters::trim(input)?.to_string(),
TextFilter::Truncate(a) => filters::truncate(input, a)?.to_string(),
TextFilter::Uppercase => filters::uppercase(input)?.to_string(),
TextFilter::Urlencode => filters::urlencode(input)?.to_string(),
TextFilter::UrlencodeStrict => filters::urlencode_strict(input)?.to_string(),
};
Ok(())
}
#[derive(Arbitrary, Debug, Clone, Copy)]
pub struct Text<'a> {
input: &'a str,
filter: TextFilter,
}
#[derive(Arbitrary, Debug, Clone, Copy)]
enum TextFilter {
Capitalize,
Center(usize),
Indent(usize),
Linebreaks,
LinebreaksBr,
Lowercase,
ParagraphBreaks,
Safe(Escaper),
Title,
Trim,
Truncate(usize),
Uppercase,
Urlencode,
UrlencodeStrict,
}
#[derive(Arbitrary, Debug, Clone, Copy)]
enum Escaper {
Html,
Text,
}
// TODO:
// abs
// escape,
// filesizeformat
// fmt
// format
// into_f64
// into_isize
// join
// json
// json_pretty
// wordcount

View File

@ -0,0 +1 @@
../../../../rinja/src/html.rs

View File

@ -0,0 +1,43 @@
#[allow(clippy::module_inception)]
mod html;
use arbitrary::{Arbitrary, Unstructured};
use html_escape::decode_html_entities_to_string;
#[derive(Arbitrary, Debug, Clone, Copy)]
pub enum Scenario<'a> {
String(&'a str),
Char(char),
}
impl<'a> super::Scenario<'a> for Scenario<'a> {
type RunError = std::convert::Infallible;
fn new(data: &'a [u8]) -> Result<Self, arbitrary::Error> {
Self::arbitrary_take_rest(Unstructured::new(data))
}
fn run(&self) -> Result<(), Self::RunError> {
match *self {
Scenario::String(src) => {
let mut dest = String::with_capacity(src.len());
html::write_escaped_str(&mut dest, src).unwrap();
let mut unescaped = String::with_capacity(src.len());
let unescaped = decode_html_entities_to_string(dest, &mut unescaped);
assert_eq!(src, unescaped);
}
Scenario::Char(c) => {
let mut dest = String::with_capacity(6);
html::write_escaped_char(&mut dest, c).unwrap();
let mut src = [0; 4];
let src = c.encode_utf8(&mut src);
let mut unescaped = String::with_capacity(4);
let unescaped = decode_html_entities_to_string(dest, &mut unescaped);
assert_eq!(src, unescaped);
}
}
Ok(())
}
}

99
fuzzing/fuzz/src/lib.rs Normal file
View File

@ -0,0 +1,99 @@
pub mod all;
pub mod filters;
pub mod html;
pub mod parser;
use std::error::Error;
use std::fmt;
pub const TARGETS: &[(&str, TargetBuilder)] = &[
("all", |data| NamedTarget::new::<all::Scenario>(data)),
("filters", |data| {
NamedTarget::new::<filters::Scenario>(data)
}),
("html", |data| NamedTarget::new::<html::Scenario>(data)),
("parser", |data| NamedTarget::new::<parser::Scenario>(data)),
];
pub type TargetBuilder = for<'a> fn(&'a [u8]) -> Result<NamedTarget<'a>, arbitrary::Error>;
pub trait Scenario<'a>: fmt::Debug + Sized {
type RunError: Error + Send + 'static;
fn fuzz(data: &'a [u8]) -> Result<(), FuzzError<Self::RunError>> {
Self::new(data)
.map_err(FuzzError::New)?
.run()
.map_err(FuzzError::Run)
}
fn new(data: &'a [u8]) -> Result<Self, arbitrary::Error>;
fn run(&self) -> Result<(), Self::RunError>;
}
#[derive(Debug, thiserror::Error)]
pub enum FuzzError<RunError: Error + Send + 'static> {
#[error("could not build scenario")]
New(#[source] arbitrary::Error),
#[error("could not run scenario")]
Run(#[source] RunError),
}
#[derive(Debug, Clone, Copy, Default)]
pub struct DisplayTargets;
impl fmt::Display for DisplayTargets {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (idx, &(name, _)) in TARGETS.iter().enumerate() {
match idx {
0 => write!(f, "{name}")?,
_ => write!(f, "|{name}")?,
};
}
Ok(())
}
}
pub struct NamedTarget<'a>(Box<dyn RunScenario<'a> + 'a>);
impl NamedTarget<'_> {
#[inline]
pub fn run(&self) -> Result<(), Box<dyn Error + Send + 'static>> {
self.0.run()
}
}
impl fmt::Debug for NamedTarget<'_> {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.debug(f)
}
}
impl<'a> NamedTarget<'a> {
#[inline]
fn new<S: Scenario<'a> + 'a>(data: &'a [u8]) -> Result<Self, arbitrary::Error> {
Ok(Self(Box::new(S::new(data)?)))
}
}
trait RunScenario<'a> {
fn debug(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result;
fn run(&self) -> Result<(), Box<dyn Error + Send + 'static>>;
}
impl<'a, T: Scenario<'a>> RunScenario<'a> for T {
#[inline]
fn debug(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.fmt(f)
}
#[inline]
fn run(&self) -> Result<(), Box<dyn Error + Send + 'static>> {
match self.run() {
Ok(()) => Ok(()),
Err(err) => Err(Box::new(err)),
}
}
}

View File

@ -0,0 +1,42 @@
use arbitrary::{Arbitrary, Unstructured};
use rinja_parser::{Ast, Syntax, SyntaxBuilder};
#[derive(Debug, Default)]
pub struct Scenario<'a> {
syntax: Syntax<'a>,
src: &'a str,
}
impl<'a> super::Scenario<'a> for Scenario<'a> {
type RunError = rinja_parser::ParseError;
fn new(data: &'a [u8]) -> Result<Self, arbitrary::Error> {
let mut data = Unstructured::new(data);
let syntax = if let Some(syntax) = <Option<[Option<&'a str>; 6]>>::arbitrary(&mut data)? {
SyntaxBuilder {
name: "test",
block_start: syntax[0],
block_end: syntax[1],
expr_start: syntax[2],
expr_end: syntax[3],
comment_start: syntax[4],
comment_end: syntax[5],
}
.to_syntax()
.map_err(|_| arbitrary::Error::IncorrectFormat)?
} else {
Syntax::default()
};
let src = <&str>::arbitrary_take_rest(data)?;
Ok(Self { syntax, src })
}
fn run(&self) -> Result<(), Self::RunError> {
let Scenario { syntax, src } = self;
Ast::from_str(src, None, syntax)?;
Ok(())
}
}

58
fuzzing/src/main.rs Normal file
View File

@ -0,0 +1,58 @@
use std::env::args_os;
use std::fs::OpenOptions;
use std::io::Read;
use std::path::{Path, PathBuf};
use fuzz::{DisplayTargets, TARGETS};
fn main() -> Result<(), Error> {
let mut args = args_os().fuse();
let exe = args.next().map(PathBuf::from);
let name = args.next().and_then(|s| s.into_string().ok());
let path = args.next().map(PathBuf::from);
let empty = args.next().map(|_| ());
let (Some(name), Some(path), None) = (name, path, empty) else {
return Err(Error::Usage(exe));
};
let scenario_builder = TARGETS
.iter()
.find_map(|&(scenario, func)| (scenario == name).then_some(func))
.ok_or(Error::Target(name))?;
let mut data = Vec::new();
match OpenOptions::new().read(true).open(Path::new(&path)) {
Ok(mut f) => {
f.read_to_end(&mut data)
.map_err(|err| Error::Read(err, path))?;
}
Err(err) => return Err(Error::Open(err, path)),
};
let scenario = scenario_builder(&data).map_err(Error::Build)?;
eprintln!("{scenario:#?}");
scenario.run().map_err(Error::Run)?;
println!("Success.");
Ok(())
}
#[derive(thiserror::Error, pretty_error_debug::Debug)]
enum Error {
#[error(
"wrong arguments supplied\nUsage: {} <{DisplayTargets}> <path>",
.0.as_deref().unwrap_or(Path::new("rinja_fuzzing")).display(),
)]
Usage(Option<PathBuf>),
#[error("unknown fuzzing target {:?}\nImplemented targets: {DisplayTargets}", .0)]
Target(String),
#[error("could not open input file {}", .1.display())]
Open(#[source] std::io::Error, PathBuf),
#[error("could not read opened input file {}", .1.display())]
Read(#[source] std::io::Error, PathBuf),
#[error("could not build scenario")]
Build(#[source] arbitrary::Error),
#[error("could not run scenario")]
Run(#[source] Box<dyn std::error::Error + Send + 'static>),
}