use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::{Arc, Mutex};
use clap::Args;
use watoots::fuzz::Generator;
use watoots::{Host, ImportedFunction, Manifest, TraceHook, Type, Val};
use watoots_trace::{Header, Recorder, Trace, binary, text};
use crate::{Failure, Outcome, plugin_name, read, read_manifest, write};
#[derive(Args)]
pub(crate) struct FuzzArgs {
component: PathBuf,
#[arg(short, long)]
manifest: Option<PathBuf>,
#[arg(long = "call", value_name = "NAME")]
calls: Vec<String>,
#[arg(long, default_value_t = 64, value_name = "N")]
cases: u64,
#[arg(long, default_value_t = 0, value_name = "N")]
seed: u64,
#[arg(long, default_value_t = 3, value_name = "N")]
calls_per_case: usize,
#[arg(long, default_value = ".", value_name = "DIR")]
out: PathBuf,
#[arg(long, default_value_t = 1, value_name = "N")]
max_crashes: usize,
}
fn check_session(trace: &Trace, wasm: &[u8]) -> Result<(), String> {
let via_text = text::from_text(&text::to_text(trace))
.map_err(|err| format!("the trace does not survive its own text encoding: {err}"))?;
if via_text != *trace {
return Err("the trace does not survive its own text encoding".to_string());
}
let via_binary = binary::from_bytes(&binary::to_bytes(trace))
.map_err(|err| format!("the trace does not survive its own binary encoding: {err}"))?;
if via_binary != *trace {
return Err("the trace does not survive its own binary encoding".to_string());
}
let report =
watoots_trace::replay(trace, wasm).map_err(|err| format!("replay refused: {err}"))?;
if !report.is_faithful() {
return Err(report.describe());
}
Ok(())
}
fn wave_is_stable(ty: &Type, value: &Val) -> Result<(), String> {
let render = |value: &Val| {
watoots::to_wave(value).map_err(|err| format!("no WAVE rendering: {}", err.message()))
};
let parse = |text: &str| {
watoots::from_wave(ty, text)
.map_err(|err| format!("{text:?} does not parse back: {}", err.message()))
};
let once = parse(&render(value)?)?;
let twice = parse(&render(&once)?)?;
if once != twice {
return Err(format!(
"a WAVE round trip is not stable:\n after one: {once:?}\n after two: {twice:?}"
));
}
Ok(())
}
#[derive(Clone, Default)]
struct Unfuzzable(Arc<Mutex<Option<String>>>);
impl Unfuzzable {
fn note(&self, err: &watoots::Error) {
let mut flag = self.0.lock().unwrap_or_else(|err| err.into_inner());
if flag.is_none() {
*flag = Some(err.message().to_string());
}
}
fn take(&self) -> Option<String> {
self.0.lock().unwrap_or_else(|err| err.into_inner()).take()
}
}
struct Session {
trace: Trace,
unfuzzable: Option<String>,
finding: Option<String>,
}
struct Campaign<'a> {
wasm: &'a [u8],
name: &'a str,
manifest_toml: &'a str,
exports: &'a [String],
serve: &'a [ImportedFunction],
calls: usize,
}
fn run_session(campaign: &Campaign<'_>, seed: u64) -> Result<Session, Failure> {
let mut manifest = Manifest::parse(campaign.manifest_toml)?;
manifest.signature.keys.clear();
let recorder = Arc::new(Recorder::new(Header {
component_sha256: Trace::hash_component(campaign.wasm),
plugin: campaign.name.to_string(),
manifest_toml: campaign.manifest_toml.to_string(),
}));
let generator = Arc::new(Mutex::new(Generator::from_seed(seed)));
let unfuzzable = Unfuzzable::default();
let mut builder = Host::builder()
.manifest(manifest)
.trace_hook(Arc::clone(&recorder) as Arc<dyn TraceHook>)
.log_sink(|_record| {});
for imported in campaign.serve {
let generator = Arc::clone(&generator);
let unfuzzable = unfuzzable.clone();
builder = builder.host_func(&imported.interface, &imported.func, move |call| {
let mut generator = generator.lock().unwrap_or_else(|err| err.into_inner());
generator
.values(call.result_types())
.inspect_err(|err| unfuzzable.note(err))
});
}
let host = builder.build()?;
let mut plugin = host.load_binary(campaign.name, campaign.wasm)?;
let mut finding = None;
for _ in 0..campaign.calls {
let export = {
let mut generator = generator.lock().unwrap_or_else(|err| err.into_inner());
let index = match generator.value(&Type::U8) {
Ok(Val::U8(byte)) => usize::from(byte),
_ => 0,
};
&campaign.exports[index % campaign.exports.len()]
};
let params = plugin.export_params(export)?;
let args = {
let mut generator = generator.lock().unwrap_or_else(|err| err.into_inner());
match generator.values(¶ms) {
Ok(args) => args,
Err(err) => {
unfuzzable.note(&err);
break;
}
}
};
for (ty, value) in params.iter().zip(&args) {
if let Err(why) = wave_is_stable(ty, value)
&& finding.is_none()
{
finding = Some(format!("{export}: {why}"));
}
}
if plugin.call(export, &args).is_err() {
break;
}
}
let mut unfuzzable = unfuzzable.take();
let trace = match recorder.finish() {
Ok(trace) => trace,
Err(err) => {
unfuzzable.get_or_insert_with(|| err.message().to_string());
Trace::default()
}
};
Ok(Session {
trace,
unfuzzable,
finding,
})
}
pub(crate) fn fuzz(args: &FuzzArgs) -> Outcome {
let wasm = read(&args.component)?;
let (_, manifest_toml) = read_manifest(args.manifest.as_deref())?;
let name = plugin_name(&args.component);
let shown = args.component.display();
let probe = Host::builder().build()?;
let available = probe.export_functions(&wasm)?;
let exports: Vec<String> = if args.calls.is_empty() {
available.clone()
} else {
for wanted in &args.calls {
if !available.contains(wanted) {
return Err(format!(
"{shown} exports no function {wanted:?}; it exports: {}",
available.join(", ")
)
.into());
}
}
args.calls.clone()
};
if exports.is_empty() {
return Err(format!("{shown} exports no functions to call").into());
}
let serve: Vec<ImportedFunction> = probe
.import_functions(&wasm)?
.into_iter()
.filter(|imported| !imported.interface.starts_with("wasi:"))
.collect();
std::fs::create_dir_all(&args.out)
.map_err(|err| format!("cannot create {}: {err}", args.out.display()))?;
eprintln!(
"fuzzing {shown} — {} case(s) from seed {}, {} call(s) each, over {}",
args.cases,
args.seed,
args.calls_per_case,
exports.join(", ")
);
let campaign = Campaign {
wasm: &wasm,
name: &name,
manifest_toml: &manifest_toml,
exports: &exports,
serve: &serve,
calls: args.calls_per_case,
};
let mut crashes = 0usize;
for case in 0..args.cases {
let seed = args.seed.wrapping_add(case);
let session = run_session(&campaign, seed)?;
if let Some(reason) = session.unfuzzable {
return Err(format!("{shown} cannot be fuzzed: {reason}").into());
}
if session.trace.events.is_empty() {
return Err(
format!("{shown} recorded no crossings; there is nothing here to check").into(),
);
}
let why = match (session.finding, check_session(&session.trace, &wasm)) {
(Some(why), _) | (None, Err(why)) => why,
(None, Ok(())) => continue,
};
let path = args.out.join(format!("crash-{crashes:03}.wave"));
write(&path, text::to_text(&session.trace))?;
println!("crash {crashes} (case {case}, seed {seed}):\n{why}");
println!(" wrote {}", path.display());
println!(
" reproduce: watoots fuzz {shown} {}--seed {seed} --cases 1",
args.manifest
.as_ref()
.map_or_else(String::new, |path| format!("-m {} ", path.display()))
);
println!(
" regression: watoots replay {} --component {shown} --emit-test tests/crash_{crashes:03}.rs",
path.display()
);
crashes += 1;
if args.max_crashes != 0 && crashes >= args.max_crashes {
eprintln!(
"stopping after {crashes} crash(es); --max-crashes 0 runs the whole campaign"
);
break;
}
}
if crashes == 0 {
eprintln!("{} case(s), no findings", args.cases);
return Ok(ExitCode::SUCCESS);
}
Ok(ExitCode::FAILURE)
}