| Author | Andy Green <andy@warmcat.com> 2026-10-03 19:32 UTC | | Committer | Andy Green <andy@warmcat.com> 2026-10-05 06:32 UTC | | Tree | eaf0f59bc2022fbb0832ff9ed96b1f46cd118d9c Raw Patch | | | npro-fuzz: fuzz harnesses with oracles, smoke-tested everywhere | npro-fuzz: fuzz harnesses with oracles, smoke-tested everywhere
A fuzz target is only as good as what it checks. Add a publish = false
crate holding each target as a safe fn(&[u8]) that drives the code under
test and checks it against an oracle, reporting a disagreement, or any
panic in the code under test, as a panic:
- utf8: Utf8Validator against core::str::from_utf8. Fed a byte at a
time it must refuse at exactly the first byte no well-formed text
could have next; fed in pieces of 0 to 16 bytes, empty ones included,
it must refuse in the piece holding that byte and go on refusing.
- sha1: Sha1 fed in pieces against Sha1::digest of the whole.
- base64: encode into a buffer with room to spare, which must use
exactly encoded_len and decode back through a strict decoder; and
into buffers one short and empty, which must be refused with nothing
written.
- transcript: npro-test's reader, whose results must keep its limits.
Where a target splits its input, the first byte chooses how, so a
fuzzer explores the split like any other part of the input.
The smoke tests run every target over its seeds in fuzz/seeds/ (and
the vendored transcripts) and over 4000 inputs drawn from SeededRandom,
in plain cargo test on every builder, so the harnesses stay working
without a fuzzer. Under Miri they draw 24.
Checked by planting bugs: utf8 accepting surrogates, past U+10FFFF or
an overlong e0 form, or refusing a valid continuation byte, and base64
with a wrong symbol or padding, each fail the smoke tests.
npro-fuzz is admitted to deny.toml as a workspace crate. Its path
dependencies on npro-core and npro-test have no version, which cargo
deny counts as wildcards; allow-wildcard-paths lets those through for
crates that are not published, and only for them.
The libFuzzer targets that drive these harnesses follow separately.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kg5Eemy68ZaqDBcUJQG6J
|
diff --git a/Cargo.lock b/Cargo.lock
index febbb52..3c2e2c6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -11,5 +11,13 @@ name = "npro-core"
version = "0.0.2"
[[package]]
+name = "npro-fuzz"
+version = "0.0.2"
+dependencies = [
+ "npro-core",
+ "npro-test",
+]
+
+[[package]]
name = "npro-test"
version = "0.0.2"
diff --git a/crates/npro-fuzz/Cargo.toml b/crates/npro-fuzz/Cargo.toml
new file mode 100644
index 0000000..c3450b1
--- /dev/null
+++ b/crates/npro-fuzz/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "npro-fuzz"
+description = "Fuzz harnesses for npro: each target's input handling and its oracle, for libFuzzer and for the smoke tests"
+publish = false
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+homepage.workspace = true
+repository.workspace = true
+
+[dependencies]
+npro-core = { path = "../npro-core" }
+npro-test = { path = "../npro-test" }
+
+[dev-dependencies]
+npro-core = { path = "../npro-core", features = ["replay"] }
+
+[lints]
+workspace = true
diff --git a/crates/npro-fuzz/src/lib.rs b/crates/npro-fuzz/src/lib.rs
new file mode 100644
index 0000000..82a53f6
--- /dev/null
+++ b/crates/npro-fuzz/src/lib.rs
@@ -0,0 +1,79 @@
+//! Fuzz harnesses for npro, not published.
+//!
+//! Each target is a function taking the fuzzer's bytes. It drives the code
+//! under test with them and checks the result against an oracle: another
+//! way of getting the same answer, or a property the answer must have. A
+//! disagreement is reported as a panic, which is how libFuzzer learns of a
+//! finding; so is any panic in the code under test, since nothing reachable
+//! from network data may panic.
+//!
+//! The same functions run in two places:
+//!
+//! - the libFuzzer targets in the separate `fuzz/` workspace, one per
+//! [`Target`], for coverage-guided campaigns (`scripts/fuzz.sh`);
+//! - this crate's smoke tests, part of every `cargo test`, which run each
+//! target over its seeds and inputs from a fixed seed on every platform.
+//!
+//! Where a target needs to choose something beyond the bytes under test,
+//! such as where to split them, the choice is taken from the input's first
+//! byte, so libFuzzer explores it like any other part of the input.
+
+#![forbid(unsafe_code)]
+
+mod targets;
+
+pub use targets::{base64, sha1, transcript, utf8};
+
+/// A fuzz target: its name is the libFuzzer target's, the seed directory's
+/// under `fuzz/seeds/`, and the corpus's under `corpus-<name>`.
+///
+/// ```
+/// use npro_fuzz::Target;
+///
+/// for t in Target::ALL {
+/// t.run(b"");
+/// }
+/// assert_eq!(Target::Utf8.name(), "utf8");
+/// ```
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Target {
+ /// [`utf8`]: incremental UTF-8 validation against `core::str`.
+ Utf8,
+ /// [`sha1`]: SHA-1 fed in pieces against SHA-1 in one go.
+ Sha1,
+ /// [`base64`]: encoding against decoding, and buffers of every size.
+ Base64,
+ /// [`transcript`]: npro-test's transcript reader.
+ Transcript,
+}
+
+impl Target {
+ /// Every target.
+ pub const ALL: [Self; 4] = [Self::Utf8, Self::Sha1, Self::Base64, Self::Transcript];
+
+ /// The target's name.
+ #[must_use]
+ pub const fn name(self) -> &'static str {
+ match self {
+ Self::Utf8 => "utf8",
+ Self::Sha1 => "sha1",
+ Self::Base64 => "base64",
+ Self::Transcript => "transcript",
+ }
+ }
+
+ /// Runs the target over one input.
+ ///
+ /// # Panics
+ ///
+ /// On a finding: the code under test panicked, or disagreed with the
+ /// target's oracle.
+ pub fn run(self, data: &[u8]) {
+ match self {
+ Self::Utf8 => utf8(data),
+ Self::Sha1 => sha1(data),
+ Self::Base64 => base64(data),
+ Self::Transcript => transcript(data),
+ }
+ }
+}
diff --git a/crates/npro-fuzz/src/targets.rs b/crates/npro-fuzz/src/targets.rs
new file mode 100644
index 0000000..4ea70d6
--- /dev/null
+++ b/crates/npro-fuzz/src/targets.rs
@@ -0,0 +1,313 @@
+//! The targets, and what each checks.
+
+use core::fmt;
+use core::ops::Range;
+
+use npro_core::base64::{self as b64, encoded_len};
+use npro_core::sha1::Sha1;
+use npro_core::utf8::Utf8Validator;
+use npro_test::{MAX_BYTES, MAX_STEPS, Transcript};
+
+/// Reports a finding to whatever is driving the target: libFuzzer, which
+/// treats a panic as a crash and keeps the input, or a smoke test.
+#[allow(
+ clippy::panic,
+ reason = "a panic is how a fuzz target reports a finding to libFuzzer"
+)]
+#[cold]
+fn finding(target: &str, what: fmt::Arguments<'_>) -> ! {
+ panic!("{target}: {what}")
+}
+
+/// The input's first byte, which chooses how a target splits the rest,
+/// and the rest.
+fn control(data: &[u8]) -> (u8, &[u8]) {
+ data.split_first().map_or((0, data), |(c, rest)| (*c, rest))
+}
+
+/// `bytes` split into pieces of 0 to 16 bytes, the sizes drawn from a
+/// small generator seeded by `ctl`. Pieces are what arrives in one read,
+/// so empty ones are included: a reader can be handed nothing.
+struct Pieces<'a> {
+ rest: &'a [u8],
+ state: u32,
+}
+
+impl<'a> Pieces<'a> {
+ fn new(ctl: u8, bytes: &'a [u8]) -> Self {
+ Self {
+ rest: bytes,
+ state: u32::from(ctl),
+ }
+ }
+}
+
+impl<'a> Iterator for Pieces<'a> {
+ type Item = &'a [u8];
+
+ fn next(&mut self) -> Option<&'a [u8]> {
+ if self.rest.is_empty() {
+ return None;
+ }
+ // the LCG from C's rand(): period 2^32, so sizes of 0 do not
+ // repeat for long, and every split is reachable from some ctl
+ self.state = self.state.wrapping_mul(1_103_515_245).wrapping_add(12_345);
+ let want = usize::try_from((self.state >> 16) % 17).unwrap_or(1);
+ let (piece, rest) = self.rest.split_at(want.min(self.rest.len()));
+ self.rest = rest;
+ Some(piece)
+ }
+}
+
+/// How text ends, as validation sees it.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum Utf8End {
+ /// Well-formed and between characters.
+ Complete,
+ /// Well-formed so far, but inside a character.
+ Partial,
+ /// The byte at this index cannot continue any well-formed text.
+ InvalidAt(usize),
+}
+
+/// The oracle: how `text` ends according to `core::str`.
+fn utf8_end_by_core(text: &[u8]) -> Utf8End {
+ let e = match core::str::from_utf8(text) {
+ Ok(_) => return Utf8End::Complete,
+ Err(e) => e,
+ };
+ if e.error_len().is_none() {
+ return Utf8End::Partial;
+ }
+ // core reports where the last good character ends. The validator
+ // refuses at the first byte no well-formed text could have next,
+ // which is the first index whose prefix core calls broken rather
+ // than unfinished.
+ let from = e.valid_up_to();
+ let refused = (from..text.len()).find(|&i| {
+ text.get(..=i)
+ .is_some_and(|p| core::str::from_utf8(p).is_err_and(|e| e.error_len().is_some()))
+ });
+ Utf8End::InvalidAt(refused.unwrap_or(from))
+}
+
+/// Incremental UTF-8 validation, as ws text arrives, against `core::str`
+/// over the whole text.
+///
+/// The first byte chooses how the rest is split. It is validated a byte at
+/// a time, which must refuse it at exactly the byte core's answer implies,
+/// and in pieces, which must refuse it in the piece holding that byte, and
+/// go on refusing every piece after, empty ones included.
+pub fn utf8(data: &[u8]) {
+ let (ctl, text) = control(data);
+ let expect = utf8_end_by_core(text);
+
+ let mut v = Utf8Validator::new();
+ let mut bytewise = None;
+ for (i, b) in text.iter().enumerate() {
+ if v.feed(core::slice::from_ref(b)).is_err() {
+ bytewise = Some(Utf8End::InvalidAt(i));
+ break;
+ }
+ }
+ let bytewise = bytewise.unwrap_or(if v.at_boundary() {
+ Utf8End::Complete
+ } else {
+ Utf8End::Partial
+ });
+ if bytewise != expect {
+ finding(
+ "utf8",
+ format_args!("fed a byte at a time it ends {bytewise:?}, core says {expect:?}"),
+ );
+ }
+
+ let mut v = Utf8Validator::new();
+ let mut refused: Option<Range<usize>> = None;
+ let mut at = 0usize;
+ for piece in Pieces::new(ctl, text) {
+ let span = at..at.saturating_add(piece.len());
+ match (v.feed(piece), &refused) {
+ (Err(_), None) => refused = Some(span.clone()),
+ (Ok(()), Some(r)) => finding(
+ "utf8",
+ format_args!("accepted {span:?} after refusing {r:?}"),
+ ),
+ (Err(_), Some(_)) | (Ok(()), None) => {}
+ }
+ at = span.end;
+ }
+ let ok = match (expect, &refused) {
+ (Utf8End::InvalidAt(i), Some(r)) => r.contains(&i),
+ (Utf8End::Complete, None) => v.at_boundary(),
+ (Utf8End::Partial, None) => !v.at_boundary(),
+ (Utf8End::InvalidAt(_), None) | (Utf8End::Complete | Utf8End::Partial, Some(_)) => false,
+ };
+ if !ok {
+ finding(
+ "utf8",
+ format_args!(
+ "fed in pieces it refused {refused:?} and ends at a boundary: {}, core says {expect:?}",
+ v.at_boundary()
+ ),
+ );
+ }
+}
+
+/// SHA-1 fed in pieces, as a ws key or a body arrives, against SHA-1 of
+/// the whole message at once. The first byte chooses the split.
+pub fn sha1(data: &[u8]) {
+ let (ctl, msg) = control(data);
+ let whole = Sha1::digest(msg);
+
+ let mut h = Sha1::new();
+ for piece in Pieces::new(ctl, msg) {
+ h.update(piece);
+ }
+ let pieces = h.finish();
+
+ if pieces != whole {
+ finding(
+ "sha1",
+ format_args!("in pieces {pieces:02x?}, in one go {whole:02x?}"),
+ );
+ }
+}
+
+/// What the encoder must leave alone: no base64 symbol is this byte.
+const UNTOUCHED: u8 = 0xa5;
+
+/// The value of a base64 symbol, RFC 4648 4's alphabet.
+fn sextet(c: u8) -> Option<u8> {
+ let (base, value) = match c {
+ b'A'..=b'Z' => (b'A', 0),
+ b'a'..=b'z' => (b'a', 26),
+ b'0'..=b'9' => (b'0', 52),
+ b'+' => return Some(62),
+ b'/' => return Some(63),
+ _ => return None,
+ };
+ c.checked_sub(base)?.checked_add(value)
+}
+
+/// The oracle: a strict RFC 4648 decoder, refusing anything the encoder
+/// should never produce, so a wrong symbol or padding shows as `None`.
+fn decode(text: &[u8]) -> Option<Vec<u8>> {
+ if text.len() % 4 != 0 {
+ return None;
+ }
+ let groups = text.len() / 4;
+ let mut out = Vec::with_capacity(groups.checked_mul(3)?);
+ for (n, g) in text.chunks_exact(4).enumerate() {
+ let last = n.checked_add(1)? == groups;
+ // padding symbols, bytes decoded, and the bits padding stands in for
+ let (pad, have, shift) = match *g {
+ [_, _, b'=', b'='] if last => (2, 1, 12),
+ [_, _, _, b'='] if last => (1, 2, 6),
+ _ => (0, 3, 0),
+ };
+ let mut v = 0u32;
+ for &c in g.get(..4usize.checked_sub(pad)?)? {
+ v = (v << 6) | u32::from(sextet(c)?);
+ }
+ v <<= shift;
+ let bytes = v.to_be_bytes();
+ let decoded = bytes.get(1..1usize.checked_add(have)?)?;
+ // the bits padding stands in for must be zero, as the encoder
+ // writes them
+ if bytes
+ .get(1usize.checked_add(have)?..)?
+ .iter()
+ .any(|&b| b != 0)
+ {
+ return None;
+ }
+ out.extend_from_slice(decoded);
+ }
+ Some(out)
+}
+
+/// base64 encoding, as the ws accept and keys use it.
+///
+/// The whole input is encoded into a buffer with room to spare, which must
+/// use exactly `encoded_len` bytes and leave the rest alone, and decode back
+/// to the input; and into buffers one byte short and empty, which must be
+/// refused with nothing written.
+pub fn base64(data: &[u8]) {
+ let Some(len) = encoded_len(data.len()) else {
+ finding(
+ "base64",
+ format_args!("no encoded length for {} bytes", data.len()),
+ );
+ };
+ let mut buf = vec![UNTOUCHED; len.saturating_add(4)];
+
+ for short in [Some(0), len.checked_sub(1)].into_iter().flatten() {
+ if short >= len {
+ continue;
+ }
+ buf.fill(UNTOUCHED);
+ let Some(dst) = buf.get_mut(..short) else {
+ continue;
+ };
+ if b64::encode(data, dst).is_ok() {
+ finding(
+ "base64",
+ format_args!("{} bytes encoded into {short}", data.len()),
+ );
+ }
+ if buf.iter().any(|&b| b != UNTOUCHED) {
+ finding(
+ "base64",
+ format_args!("refused {short} bytes but wrote to them"),
+ );
+ }
+ }
+
+ buf.fill(UNTOUCHED);
+ match b64::encode(data, &mut buf) {
+ Ok(n) if n == len => {}
+ Ok(n) => finding(
+ "base64",
+ format_args!("used {n} bytes, encoded_len says {len}"),
+ ),
+ Err(e) => finding(
+ "base64",
+ format_args!("refused a buffer of {}: {e}", buf.len()),
+ ),
+ }
+ let (text, spare) = buf.split_at(len);
+ if spare.iter().any(|&b| b != UNTOUCHED) {
+ finding("base64", format_args!("wrote past the {len} bytes it used"));
+ }
+ if decode(text).as_deref() != Some(data) {
+ finding(
+ "base64",
+ format_args!("{text:?} does not decode to the input"),
+ );
+ }
+}
+
+/// npro-test's transcript reader, which reads files that come from outside
+/// the tree. Whatever it accepts must respect its limits and its promise
+/// that step times never go backwards.
+pub fn transcript(data: &[u8]) {
+ let Ok(t) = Transcript::parse(data) else {
+ return;
+ };
+ if data.len() > MAX_BYTES {
+ finding("transcript", format_args!("accepted {} bytes", data.len()));
+ }
+ if t.steps.len() > MAX_STEPS {
+ finding(
+ "transcript",
+ format_args!("accepted {} steps", t.steps.len()),
+ );
+ }
+ if t.steps
+ .windows(2)
+ .any(|w| matches!(w, [a, b] if b.t_us < a.t_us))
+ {
+ finding("transcript", format_args!("accepted time going backwards"));
+ }
+}
diff --git a/crates/npro-fuzz/tests/smoke.rs b/crates/npro-fuzz/tests/smoke.rs
new file mode 100644
index 0000000..0092347
--- /dev/null
+++ b/crates/npro-fuzz/tests/smoke.rs
@@ -0,0 +1,198 @@
+//! 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.
+
+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)
+}
+
+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);
+}
diff --git a/deny.toml b/deny.toml
index c798b00..d5f0f59 100644
--- a/deny.toml
+++ b/deny.toml
@@ -23,6 +23,9 @@ confidence-threshold = 0.9
[bans]
multiple-versions = "deny"
wildcards = "deny"
+# path dependencies between the workspace's own crates: cargo deny allows
+# these only in crates that are not published, such as npro-fuzz
+allow-wildcard-paths = true
# The bouncers at the door. Any crate in the graph that is not named here
# fails the gate, however deep in the tree it sits: a crate admitted for one
@@ -35,6 +38,7 @@ allow = [
# the workspace
"npro",
"npro-core",
+ "npro-fuzz",
"npro-test",
# admitted dependencies, each with its entry in docs/dependencies.md:
diff --git a/fuzz/seeds/README.md b/fuzz/seeds/README.md
new file mode 100644
index 0000000..a7fdc31
--- /dev/null
+++ b/fuzz/seeds/README.md
@@ -0,0 +1,19 @@
+# Fuzz seeds
+
+One directory per fuzz target, named as the target is (`npro_fuzz::Target`).
+Every file in it is one input libFuzzer starts its corpus from, and one the
+smoke tests in `crates/npro-fuzz/tests/smoke.rs` run on every `cargo test`.
+Files named `*.md` are left out of both.
+
+Where a target splits its input to feed it in pieces, as `utf8` and `sha1`
+do, the **first byte chooses the split** and the bytes under test follow it.
+A seed for those targets starts with that byte; any value will do.
+
+The `transcript` target also starts from every transcript in
+`crates/npro-test/transcripts/`.
+
+Seeds are small and readable on purpose: each is a case worth starting from,
+named for what it is. What the fuzzer finds goes in its corpus, which is
+kept between runs, not here. When the protocol crates arrive, their
+targets' seeds come from the C library's corpora (`fuzz/fuzz-*/seeds` in
+the C tree), copied with where they came from.
diff --git a/fuzz/seeds/base64/foobar b/fuzz/seeds/base64/foobar
new file mode 100644
index 0000000..f6ea049
--- /dev/null
+++ b/fuzz/seeds/base64/foobar
@@ -0,0 +1 @@
+foobar
\ No newline at end of file
diff --git a/fuzz/seeds/base64/ws-key b/fuzz/seeds/base64/ws-key
new file mode 100644
index 0000000..b66efb8
Binary files /dev/null and b/fuzz/seeds/base64/ws-key differ
diff --git a/fuzz/seeds/sha1/abc b/fuzz/seeds/sha1/abc
new file mode 100644
index 0000000..b8a9906
Binary files /dev/null and b/fuzz/seeds/sha1/abc differ
diff --git a/fuzz/seeds/sha1/two-blocks b/fuzz/seeds/sha1/two-blocks
new file mode 100644
index 0000000..eea2c06
--- /dev/null
+++ b/fuzz/seeds/sha1/two-blocks
@@ -0,0 +1 @@
+�abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq
\ No newline at end of file
diff --git a/fuzz/seeds/sha1/ws-accept b/fuzz/seeds/sha1/ws-accept
new file mode 100644
index 0000000..233eb9f
--- /dev/null
+++ b/fuzz/seeds/sha1/ws-accept
@@ -0,0 +1 @@
+�dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11
\ No newline at end of file
diff --git a/fuzz/seeds/transcript/minimal b/fuzz/seeds/transcript/minimal
new file mode 100644
index 0000000..1fe26f3
--- /dev/null
+++ b/fuzz/seeds/transcript/minimal
@@ -0,0 +1 @@
+{"format": "lws-transcript/1", "case": "x", "side": "client", "t0_us": 1000000000, "t0_wall": 1767225600, "seed": 0, "steps": [{"t": 1000, "tx": "4745"}, {"t": 2000, "close": ""}]}
diff --git a/fuzz/seeds/utf8/ascii b/fuzz/seeds/utf8/ascii
new file mode 100644
index 0000000..6c126ea
Binary files /dev/null and b/fuzz/seeds/utf8/ascii differ
diff --git a/fuzz/seeds/utf8/every-length b/fuzz/seeds/utf8/every-length
new file mode 100644
index 0000000..0ffbae7
--- /dev/null
+++ b/fuzz/seeds/utf8/every-length
@@ -0,0 +1 @@
+�Aé€😀 䏿–‡ नमसà¥�ते
\ No newline at end of file
diff --git a/fuzz/seeds/utf8/limits b/fuzz/seeds/utf8/limits
new file mode 100644
index 0000000..1634f2b
--- /dev/null
+++ b/fuzz/seeds/utf8/limits
@@ -0,0 +1 @@
+ Â€ß¿à €íŸ¿î€€ï¿¿ð�€€ô�¿¿
\ No newline at end of file
diff --git a/fuzz/seeds/utf8/overlong b/fuzz/seeds/utf8/overlong
new file mode 100644
index 0000000..ffc0817
--- /dev/null
+++ b/fuzz/seeds/utf8/overlong
@@ -0,0 +1 @@
+�À¯à€¯ð€€¯
\ No newline at end of file
diff --git a/fuzz/seeds/utf8/partial b/fuzz/seeds/utf8/partial
new file mode 100644
index 0000000..1945141
--- /dev/null
+++ b/fuzz/seeds/utf8/partial
@@ -0,0 +1 @@
+�â‚
\ No newline at end of file
diff --git a/fuzz/seeds/utf8/past-max b/fuzz/seeds/utf8/past-max
new file mode 100644
index 0000000..f7446ed
--- /dev/null
+++ b/fuzz/seeds/utf8/past-max
@@ -0,0 +1 @@
+��
\ No newline at end of file
diff --git a/fuzz/seeds/utf8/surrogate b/fuzz/seeds/utf8/surrogate
new file mode 100644
index 0000000..a3d8b58
--- /dev/null
+++ b/fuzz/seeds/utf8/surrogate
@@ -0,0 +1 @@
+�í €
\ No newline at end of file
|