| //! Every fuzz target, run over its seeds and over inputs drawn from a fixed
//! seed, so the harnesses and what they check stay working on every
//! platform without a fuzzer. This is a smoke test, not a campaign: that
//! is `scripts/fuzz.sh`. A failure here reproduces exactly, since the
//! inputs are the same every run.
#![expect(
unused_crate_dependencies,
reason = "an integration test sees all of its crate's dependencies; this one drives npro-test only through npro-fuzz"
)]
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use npro_core::random::SeededRandom;
use npro_fuzz::Target;
/// Inputs drawn per target. Miri interprets the tests, thousands of times
/// slower, so it gets a taste of each.
const DRAWN: usize = if cfg!(miri) { 24 } else { 4000 };
/// The longest drawn input.
const MAX_LEN: u64 = 300;
fn repo() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
}
/// The directories `scripts/fuzz.sh` gives libFuzzer as seeds for `t`.
fn seed_dirs(t: Target) -> Vec<PathBuf> {
let mut dirs = vec![repo().join("fuzz/seeds").join(t.name())];
if t == Target::Transcript {
dirs.push(repo().join("crates/npro-test/transcripts"));
}
dirs
}
/// The seed files in `dir`, sorted, leaving out its README.
fn seed_files(dir: &Path) -> io::Result<Vec<PathBuf>> {
let mut paths = fs::read_dir(dir)?
.map(|e| e.map(|e| e.path()))
.collect::<io::Result<Vec<_>>>()?;
paths.retain(|p| p.is_file() && p.extension().is_none_or(|x| x != "md"));
paths.sort();
Ok(paths)
}
/// A number below `n`, or 0 if `n` is.
fn below(r: &mut SeededRandom, n: u64) -> u64 {
r.next_u64().checked_rem(n).unwrap_or(0)
}
/// An index below `n`, or 0 if `n` is.
fn index_below(r: &mut SeededRandom, n: usize) -> usize {
let n = u64::try_from(n).unwrap_or(u64::MAX);
usize::try_from(below(r, n)).unwrap_or(0)
}
const fn byte(r: &mut SeededRandom) -> u8 {
let [b, ..] = r.next_u64().to_le_bytes();
b
}
fn bytes(r: &mut SeededRandom) -> Vec<u8> {
let n = below(r, MAX_LEN.saturating_add(1));
(0..n).map(|_| byte(r)).collect()
}
/// Text: random bytes are almost never UTF-8, so draw characters from
/// every length of encoding, then sometimes break it the ways a peer
/// might: a changed byte, a lost byte, or the end cut off.
fn text(r: &mut SeededRandom) -> Vec<u8> {
let mut t = String::new();
for _ in 0..below(r, 60) {
// the first code point needing 1, 2, 3 and 4 bytes past the top
let top = match below(r, 4) {
0 => 0x80,
1 => 0x800,
2 => 0x1_0000,
_ => 0x11_0000,
};
if let Some(c) = u32::try_from(below(r, top)).ok().and_then(char::from_u32) {
t.push(c);
}
}
let mut t = t.into_bytes();
let at = index_below(r, t.len());
match below(r, 4) {
0 => {
let b = byte(r);
if let Some(x) = t.get_mut(at) {
*x = b;
}
}
1 if at < t.len() => {
t.remove(at);
}
2 => t.truncate(at),
_ => {}
}
t
}
/// A seed with a few bytes changed, inserted or removed.
fn mutated(r: &mut SeededRandom, seed: &[u8]) -> Vec<u8> {
let mut m = seed.to_vec();
for _ in 0..=below(r, 4) {
let at = index_below(r, m.len().saturating_add(1));
match below(r, 3) {
0 => {
let b = byte(r);
if let Some(x) = m.get_mut(at) {
*x = b;
}
}
1 => m.insert(at, byte(r)),
_ if at < m.len() => {
m.remove(at);
}
_ => {}
}
}
m
}
/// Runs `t` over its seeds, then over `DRAWN` inputs from `draw`.
fn smoke(
t: Target,
mut draw: impl FnMut(&mut SeededRandom, &[Vec<u8>]) -> Vec<u8>,
) -> io::Result<()> {
let mut seeds = Vec::new();
for dir in seed_dirs(t) {
let files = seed_files(&dir)?;
if files.is_empty() {
return Err(io::Error::other(format!("no seeds in {}", dir.display())));
}
for f in files {
seeds.push(fs::read(&f)?);
}
}
for s in &seeds {
t.run(s);
}
let mut r = SeededRandom::new(1);
for _ in 0..DRAWN {
t.run(&draw(&mut r, &seeds));
}
Ok(())
}
/// A drawn input: the first byte is the target's choice of split, then
/// the bytes under test.
fn split_then(r: &mut SeededRandom, body: Vec<u8>) -> Vec<u8> {
let mut v = vec![byte(r)];
v.extend(body);
v
}
#[test]
fn utf8() {
smoke(Target::Utf8, |r, _| {
let body = text(r);
split_then(r, body)
})
.unwrap();
}
#[test]
fn sha1() {
smoke(Target::Sha1, |r, _| {
let body = bytes(r);
split_then(r, body)
})
.unwrap();
}
#[test]
fn base64() {
smoke(Target::Base64, |r, _| bytes(r)).unwrap();
}
#[test]
#[cfg_attr(
miri,
ignore = "hundreds of kilobytes of transcripts: native runs keep it"
)]
fn transcript() {
smoke(Target::Transcript, |r, seeds| {
let seed = seeds
.get(index_below(r, seeds.len()))
.map_or(&[][..], Vec::as_slice);
mutated(r, seed)
})
.unwrap();
}
#[test]
fn every_target_has_a_smoke_test() {
// the tests above, by name: a new target needs its own
let tested = ["utf8", "sha1", "base64", "transcript"];
assert_eq!(Target::ALL.map(Target::name), tested);
}
|