Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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/",
    );
}