fuzz/tests/seeds.rs
//! **The committed seeds stay valid**, on a plain stable `cargo test` (no libFuzzer, no
//! instrumentation): `cargo test --manifest-path fuzz/Cargo.toml --no-default-features`.
//!
//! Every file in `corpus/<target>/` is run through the target's entry function (a panic
//! fails the test), and every `ok-*` seed must be ACCEPTED: a seed that no longer parses
//! starts the fuzzer at the shallow end, which is a silent loss of coverage.
//!
//! `write_seeds` (ignored) regenerates the `ok-*` seeds from the throwaway keys in
//! `src/lib.rs`: `cargo test ... -- --ignored write_seeds`.
use std::path::{Path, PathBuf};
type Entry = fn(&[u8]) -> bool;
const TARGETS: &[(&str, Entry)] = &[
("object_decode", gunnar_fuzz::object_decode),
("pack_scan", gunnar_fuzz::pack_scan),
("envelope_open", gunnar_fuzz::envelope_open),
("crypt_files", gunnar_fuzz::crypt_files),
("commit_signature", gunnar_fuzz::commit_signature),
("sshsig_event", gunnar_fuzz::sshsig_event),
("receive_preamble", gunnar_fuzz::receive_preamble),
("upload_request", gunnar_fuzz::upload_request),
];
fn corpus(target: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("corpus")
.join(target)
}
#[test]
fn every_target_has_a_bin_and_accepted_seeds() {
let manifest =
std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")).unwrap();
let mut wrong = Vec::new();
for (target, entry) in TARGETS {
if !manifest.contains(&format!("path = \"fuzz_targets/{target}.rs\"")) {
wrong.push(format!("{target}: no [[bin]] in fuzz/Cargo.toml"));
}
let Ok(dir) = std::fs::read_dir(corpus(target)) else {
wrong.push(format!("{target}: no corpus/{target}/"));
continue;
};
let mut ok_seeds = 0;
for file in dir.flatten() {
let name = file.file_name().to_string_lossy().to_string();
let bytes = std::fs::read(file.path()).unwrap();
let accepted = entry(&bytes);
if name.starts_with("ok-") {
ok_seeds += 1;
if !accepted {
wrong.push(format!("{target}: seed {name} is no longer accepted"));
}
}
}
if ok_seeds == 0 {
wrong.push(format!(
"{target}: no ok-* seed, so nothing proves the parser's deep end is reached"
));
}
}
assert!(
wrong.is_empty(),
"the fuzz corpus is not what the targets need:\n {}",
wrong.join("\n ")
);
}
/// The parsers refuse what is not theirs: garbage is accepted by none, and the empty input
/// by none but `crypt_files` (an empty file is a valid, empty member list).
#[test]
fn garbage_is_accepted_by_no_target() {
for (target, entry) in TARGETS {
if *target != "crypt_files" {
assert!(!entry(b""), "{target} accepted the empty input");
}
assert!(!entry(b"\xff\x00garbage\x01"), "{target} accepted garbage");
}
}
fn pkt(payload: &[u8]) -> Vec<u8> {
let mut out = format!("{:04x}", payload.len() + 4).into_bytes();
out.extend_from_slice(payload);
out
}
const OID: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
const OID2: &str = "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391";
fn commit_text() -> String {
format!(
"tree {OID}\nparent {OID2}\nauthor Fuzz Seed <seed@mock.invalid> 1767225600 +0000\ncommitter Fuzz Seed <seed@mock.invalid> 1767225600 +0000\n\na seed commit\n"
)
}
#[test]
#[ignore = "regenerates the ok-* seeds; run with --ignored write_seeds"]
fn write_seeds() {
use std::io::Write as _;
let put = |target: &str, name: &str, bytes: &[u8]| {
std::fs::create_dir_all(corpus(target)).unwrap();
std::fs::write(corpus(target).join(name), bytes).unwrap();
};
// objects
put("object_decode", "ok-commit", commit_text().as_bytes());
put(
"object_decode",
"ok-tag",
format!("object {OID2}\ntype commit\ntag v1.0.0\ntagger Fuzz Seed <seed@mock.invalid> 1767225600 +0000\n\na seed tag\n").as_bytes(),
);
let mut tree = b"100644 README.md\0".to_vec();
tree.extend_from_slice(&[0x11; 20]);
tree.extend_from_slice(b"40000 src\0");
tree.extend_from_slice(&[0x22; 20]);
put("object_decode", "ok-tree", &tree);
// a one-blob pack, handed over 7 bytes at a time
let mut pack = b"PACK".to_vec();
pack.extend_from_slice(&2u32.to_be_bytes());
pack.extend_from_slice(&1u32.to_be_bytes());
let content = b"hello, fuzzer\n";
pack.push(0x80 | (3 << 4) | (content.len() as u8 & 0x0f));
pack.push((content.len() >> 4) as u8);
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
z.write_all(content).unwrap();
pack.extend_from_slice(&z.finish().unwrap());
pack.extend_from_slice(&[0xab; 20]);
let mut framed = vec![6u8];
framed.extend_from_slice(&pack);
put("pack_scan", "ok-one-blob", &framed);
// envelopes, sealed with the throwaway ring
let ring = gunnar_fuzz::fuzz_ring();
put(
"envelope_open",
"ok-v2",
&gunnar_crypt::envelope::seal(ring.newest(), b"a sealed seed\n"),
);
put(
"envelope_open",
"ok-v1",
&gunnar_crypt::envelope::seal_v1(ring.newest(), b"a v1 seed\n"),
);
put(
"envelope_open",
"ok-v2-small-chunks",
&gunnar_crypt::envelope::seal_with_chunk(ring.newest(), 16, &[0x5a; 100]),
);
// crypt files
put("crypt_files", "ok-keyring", ring.to_text().as_bytes());
put(
"crypt_files",
"ok-door-get",
gunnar_crypt::door::VERB.as_bytes(),
);
// a commit signed with the throwaway key, gpgsig header as git writes it
let key = gunnar_fuzz::fuzz_key();
let payload = commit_text();
let sig = key
.sign("git", ssh_key::HashAlg::Sha512, payload.as_bytes())
.unwrap();
let pem = sig.to_pem(ssh_key::LineEnding::LF).unwrap();
let header: String = pem.trim_end().lines().collect::<Vec<_>>().join("\n ");
let (head, body) = payload.split_once("\n\n").unwrap();
put(
"commit_signature",
"ok-signed-commit",
format!("{head}\ngpgsig {header}\n\n{body}").as_bytes(),
);
// a forge event signature, and an authorized-key line
let (_, event_pem) = gunnar_core::sshsig::sign(
&key,
gunnar_core::forge::FORGE_NAMESPACE,
gunnar_fuzz::EVENT_BYTES,
)
.unwrap();
put("sshsig_event", "ok-event", event_pem.as_bytes());
put(
"sshsig_event",
"authorized-key",
key.public_key().to_openssh().unwrap().as_bytes(),
);
// receive-pack: create refs/heads/main
let zero = "0".repeat(40);
let mut pre = pkt(format!("{zero} {OID2} refs/heads/main\0report-status side-band-64k object-format=sha1 agent=git/2.53.0\n").as_bytes());
pre.extend_from_slice(b"0000");
put("receive_preamble", "ok-create", &pre);
// upload-pack v2 arguments
put(
"upload_request",
"ok-fetch",
format!("thin-pack\nofs-delta\nwant {OID2}\nhave {OID}\ndone").as_bytes(),
);
put(
"upload_request",
"ok-ls-refs",
b"peel\nsymrefs\nref-prefix refs/heads/\nref-prefix refs/tags/",
);
}