libpldm: Add fuzzing for firmware FD responder

This includes a fuzz target fd-fuzz, and infrastructure to run with
either honggfuzz or AFL.

fd-fuzz-input1.dat was crafted from parts of a pldm firmware update
packet capture, as a seed to guide fuzzers.

Signed-off-by: Matt Johnston <matt@codeconstruct.com.au>
Change-Id: I424761a29a22bc964201fd7bd94ddc09a6ac89df
diff --git a/docs/fuzzing.md b/docs/fuzzing.md
new file mode 100644
index 0000000..79c19aa
--- /dev/null
+++ b/docs/fuzzing.md
@@ -0,0 +1,97 @@
+# Fuzzing libpldm
+
+## Firmware FD Responder
+
+`tests/fuzz/fd-fuzz.cpp` exercises the FD responder implementation. It can run
+with various fuzzing engines - either AFL++, honggfuzz, or libfuzzer.
+
+Each fuzz corpus input is split into two parts. The first 1024 bytes is a
+"control" stream which used to randomise certain events in the fuzzer, such as
+returning failure from callbacks, or choosing whether to receive a message or
+run progress.
+
+The remainder of the fuzz input is taken as an stream of `length:data` PLDM
+packet contents, as passed to `pldm_fd_handle_msg()`.
+
+## Build
+
+From the top level libpldm directory, run `./tests/fuzz/fuzz-build.py`. That
+will produce several build variants required for different fuzz engines/stages.
+
+## Honggfuzz
+
+[Honggfuzz](https://github.com/google/honggfuzz) handles running across multiple
+threads itself with a single corpus directory, which is easy to work with. It
+needs to be built from source.
+
+Run with
+
+```
+nice honggfuzz -i corpusdir --linux_perf_branch --dict tests/fuzz/fd.dict  -- ./bhf/tests/fuzz/fd-fuzz
+```
+
+The `--linux_perf_branch` switch is optional, it requires permissions for perf
+counters:
+
+```
+echo 0 | sudo tee /proc/sys/kernel/perf_event_paranoid
+```
+
+Optionally a thread count can be given, 24 threads on a 12 core system seems to
+give best utilisation (`--nthreads 24`).
+
+The corpus directory can be reused between runs with different fuzzers. For a
+totally fresh start, copy in `tests/fuzz/fd-fuzz-input1.dat`, a sample
+handcrafted input.
+
+## AFL++
+
+Running a single instance (just for testing):
+
+```
+afl-fuzz -i fuzzrun/hf11/ -o fuzzrun/out12single ./bfuzz/tests/fuzz/fd-fuzz
+```
+
+AFL++ requires a separate GUI instantiation for each CPU thread. The helper
+[AFL Runner](https://github.com/0xricksanchez/afl_runner) makes that easier.
+
+Running with 20 threads:
+
+```
+nice aflr run  -t bfuzz/tests/fuzz/fd-fuzz -i workdir/out5/m_fd-fuzz/queue -o workdir/out6 -c bcmplog/tests/fuzz/fd-fuzz -s bfuzzasan/tests/fuzz/fd-fuzz -n 20 -x tests/fuzz/fd.dict --session-name fuzz
+```
+
+Kill it with `aflr kill fuzz`.
+
+`aflr tui workdir/out6` could be used to view progress, though its calculations
+may be inaccurate if some runners are idle. Another option is
+`afl-whatsup workdir/out6`.
+
+## Coverage
+
+The coverage provided by a corpus directory can be reported using
+`tests/fuzz/fuzz-coverage.py`.
+
+It will:
+
+- Run a binary compiled with `--coverage` against each corpus file
+- Use [grcov](https://github.com/mozilla/grcov) to aggregate the coverage traces
+  (much faster than lcov).
+- Use `genhtml` to create a report
+
+Typical usage, with corpus in `fuzzrun/corpus`:
+
+```
+./tests/fuzz/fuzz-coverage.py fuzzrun/corpus bnoopt/tests/fuzz/fd-fuzz . bnoopt/ coverage-output
+```
+
+## Reproducing crashes
+
+When the fuzz run encounters a crash, the testcase can be run against the built
+target manually, and stepped through with GDB etc.
+
+```
+env TRACEFWFD=1 ./bnoopt/tests/fuzz/fd-fuzz < crashing.bin
+```
+
+The `printf`s are disabled by default to improve normal fuzzing speed.
diff --git a/tests/fuzz/fd-fuzz-input1.dat b/tests/fuzz/fd-fuzz-input1.dat
new file mode 100644
index 0000000..5993823
--- /dev/null
+++ b/tests/fuzz/fd-fuzz-input1.dat
Binary files differ
diff --git a/tests/fuzz/fd-fuzz.cpp b/tests/fuzz/fd-fuzz.cpp
new file mode 100644
index 0000000..e238f27
--- /dev/null
+++ b/tests/fuzz/fd-fuzz.cpp
@@ -0,0 +1,498 @@
+/* SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later */
+
+/* Fuzzing should always have assertions */
+#ifdef NDEBUG
+#undef NDEBUG
+#endif
+
+#include <libpldm/base.h>
+#include <libpldm/firmware_fd.h>
+#include <libpldm/firmware_update.h>
+#include <libpldm/sizes.h>
+
+#include <cstdarg>
+#include <cstdint>
+#include <cstring>
+#include <memory>
+#include <vector>
+
+#include "array.h"
+#include "msgbuf.h"
+
+/* Avoid out-of-memory, and
+ * avoid wasting time on inputs larger than MCTP message limits */
+static const uint32_t MAX_PART = 200;
+
+/* Maximum "send" buffer. Should be larger than any expected sent message */
+static const uint32_t MAX_SEND = 1024;
+
+/* Arbitrary EID */
+static const uint8_t FIXED_ADDR = 20;
+
+static const uint8_t PROGRESS_PERCENT = 5;
+
+static const ssize_t FUZZCTRL_SIZE = 0x400;
+
+static bool printf_enabled;
+// NOLINTNEXTLINE(cert-dcl50-cpp)
+static void debug_printf(const char* fmt, ...)
+{
+    if (printf_enabled)
+    {
+        va_list ap;
+        va_start(ap, fmt);
+        vprintf(fmt, ap);
+        va_end(ap);
+    }
+}
+
+struct fuzz_ops_ctx
+{
+    struct pldm_msgbuf* fuzz_ctrl;
+
+    /* Details of in-progress update, for consistency checking */
+    bool current_update;
+    struct pldm_firmware_update_component update_comp;
+    uint32_t offset;
+    bool transferred;
+    bool verified;
+    bool applied;
+
+    uint64_t now;
+};
+
+/* Returns true with roughly `percent` chance */
+static bool fuzz_chance(struct fuzz_ops_ctx* ctx, uint8_t percent)
+{
+    uint8_t v;
+    assert(percent <= 100);
+    int rc = pldm_msgbuf_extract_uint8(ctx->fuzz_ctrl, v);
+    if (rc != 0)
+    {
+        return false;
+    }
+    uint8_t cutoff = (uint32_t)percent * UINT8_MAX / 100;
+    return v <= cutoff;
+}
+
+// openbmc 49871
+static const uint8_t openbmc_iana[] = {
+    0xcf,
+    0xc2,
+    0x00,
+    0x00,
+};
+
+/* An arbitrary but valid set of descriptors.
+ * Short to be readily discoverable by fuzzing */
+static const pldm_descriptor FIXED_DESCRIPTORS[] = {
+    {
+        .descriptor_type = PLDM_FWUP_IANA_ENTERPRISE_ID,
+        .descriptor_length = 4,
+        .descriptor_data = openbmc_iana,
+    },
+};
+
+static int cb_device_identifiers(void* ctx LIBPLDM_CC_UNUSED,
+                                 uint8_t* descriptors_count,
+                                 const struct pldm_descriptor** descriptors)
+{
+    debug_printf("cb_device_identifiers\n");
+    *descriptors_count = 1;
+    *descriptors = FIXED_DESCRIPTORS;
+    return 0;
+}
+
+static const struct pldm_firmware_component_standalone comp = {
+    .comp_classification = PLDM_COMP_UNKNOWN,
+    .comp_identifier = 0,
+    .comp_classification_index = 0,
+    .active_ver =
+        {
+            .comparison_stamp = 1,
+            .str =
+                {
+                    .str_type = PLDM_STR_TYPE_UTF_8,
+                    .str_len = 3,
+                    .str_data = "zzz",
+                },
+            .date = {0},
+        },
+    .pending_ver =
+        {
+            .comparison_stamp = 1,
+            .str =
+                {
+                    .str_type = PLDM_STR_TYPE_UNKNOWN,
+                    .str_len = 4,
+                    .str_data = "fnnn",
+                },
+            .date = {0},
+        },
+    .comp_activation_methods = {0},
+    .capabilities_during_update = {0},
+};
+
+static const struct pldm_firmware_component_standalone* comp_list[1] = {
+    &comp,
+};
+
+static int cb_components(
+    void* ctx, uint16_t* ret_entry_count,
+    const struct pldm_firmware_component_standalone*** ret_entries)
+{
+    debug_printf("cb_components\n");
+    struct fuzz_ops_ctx* fuzz_ctx = static_cast<struct fuzz_ops_ctx*>(ctx);
+
+    *ret_entry_count = ARRAY_SIZE(comp_list);
+    *ret_entries = comp_list;
+    if (fuzz_chance(fuzz_ctx, 4))
+    {
+        return -EINVAL;
+    }
+    return 0;
+}
+
+static int cb_imageset_versions(void* ctx, struct pldm_firmware_string* active,
+                                struct pldm_firmware_string* pending)
+{
+    debug_printf("cb_imageset_versions\n");
+    struct fuzz_ops_ctx* fuzz_ctx = static_cast<struct fuzz_ops_ctx*>(ctx);
+
+    active->str_type = PLDM_STR_TYPE_ASCII;
+    active->str_len = 4;
+    memcpy(active->str_data, "1234", 4);
+    pending->str_type = PLDM_STR_TYPE_ASCII;
+    pending->str_len = 4;
+    memcpy(pending->str_data, "1235", 4);
+    if (fuzz_chance(fuzz_ctx, 4))
+    {
+        return -EINVAL;
+    }
+    return 0;
+}
+
+static enum pldm_component_response_codes
+    cb_update_component(void* ctx, bool update,
+                        const struct pldm_firmware_update_component* comp)
+{
+    debug_printf("cb_update_component update=%d\n", update);
+    struct fuzz_ops_ctx* fuzz_ctx = static_cast<struct fuzz_ops_ctx*>(ctx);
+
+    if (fuzz_chance(fuzz_ctx, 4))
+    {
+        return PLDM_CRC_COMP_PREREQUISITES_NOT_MET;
+    }
+    if (update)
+    {
+        /* Set up a new update */
+        assert(!fuzz_ctx->current_update);
+        debug_printf("cb_update_component set current_update=true\n");
+        fuzz_ctx->current_update = true;
+        fuzz_ctx->transferred = false;
+        fuzz_ctx->verified = false;
+        fuzz_ctx->applied = false;
+        fuzz_ctx->offset = 0;
+        memcpy(&fuzz_ctx->update_comp, comp, sizeof(*comp));
+    }
+    return PLDM_CRC_COMP_CAN_BE_UPDATED;
+}
+
+static uint32_t cb_transfer_size(void* ctx, uint32_t ua_max_transfer_size)
+{
+    debug_printf("cb_transfer_size ua_size=%zu\n",
+                 (ssize_t)ua_max_transfer_size);
+    struct fuzz_ops_ctx* fuzz_ctx = static_cast<struct fuzz_ops_ctx*>(ctx);
+
+    if (fuzz_chance(fuzz_ctx, 50))
+    {
+        // Sometimes adjust it
+        return MAX_PART - 20;
+    }
+    return ua_max_transfer_size;
+}
+
+static uint8_t
+    cb_firmware_data(void* ctx, uint32_t offset,
+                     const uint8_t* data LIBPLDM_CC_UNUSED, uint32_t len,
+                     const struct pldm_firmware_update_component* comp)
+{
+    debug_printf("cb_firmware_data offset=%zu len %zu\n", (size_t)offset,
+                 (size_t)len);
+    struct fuzz_ops_ctx* fuzz_ctx = static_cast<struct fuzz_ops_ctx*>(ctx);
+
+    assert(fuzz_ctx->current_update);
+    assert(!fuzz_ctx->transferred);
+    assert(!fuzz_ctx->verified);
+    assert(!fuzz_ctx->applied);
+    assert(offset == fuzz_ctx->offset);
+    fuzz_ctx->offset += len;
+    assert(fuzz_ctx->offset <= fuzz_ctx->update_comp.comp_image_size);
+    assert(memcmp(comp, &fuzz_ctx->update_comp, sizeof(*comp)) == 0);
+
+    if (fuzz_ctx->offset == fuzz_ctx->update_comp.comp_image_size)
+    {
+        fuzz_ctx->transferred = true;
+    }
+
+    if (fuzz_chance(fuzz_ctx, 2))
+    {
+        return PLDM_FWUP_TRANSFER_ERROR_IMAGE_CORRUPT;
+    }
+    return PLDM_FWUP_TRANSFER_SUCCESS;
+}
+
+static uint8_t cb_verify(void* ctx,
+                         const struct pldm_firmware_update_component* comp,
+                         bool* ret_pending,
+                         uint8_t* ret_percent_complete LIBPLDM_CC_UNUSED)
+{
+    debug_printf("cb_verify\n");
+    struct fuzz_ops_ctx* fuzz_ctx = static_cast<struct fuzz_ops_ctx*>(ctx);
+
+    assert(fuzz_ctx->current_update);
+    assert(fuzz_ctx->transferred);
+    assert(!fuzz_ctx->verified);
+    assert(!fuzz_ctx->applied);
+    assert(memcmp(comp, &fuzz_ctx->update_comp, sizeof(*comp)) == 0);
+
+    if (fuzz_chance(fuzz_ctx, 5))
+    {
+        debug_printf("cb_verify set failure\n");
+        return PLDM_FWUP_VERIFY_ERROR_VERSION_MISMATCH;
+    }
+
+    if (fuzz_chance(fuzz_ctx, 50))
+    {
+        debug_printf("cb_verify set ret_pending=true\n");
+        *ret_pending = true;
+    }
+    else
+    {
+        fuzz_ctx->verified = true;
+    }
+
+    return PLDM_SUCCESS;
+}
+
+static uint8_t cb_apply(void* ctx,
+                        const struct pldm_firmware_update_component* comp,
+                        bool* ret_pending,
+                        uint8_t* ret_percent_complete LIBPLDM_CC_UNUSED)
+{
+    debug_printf("cb_apply\n");
+    struct fuzz_ops_ctx* fuzz_ctx = static_cast<struct fuzz_ops_ctx*>(ctx);
+
+    assert(fuzz_ctx->current_update);
+    assert(fuzz_ctx->transferred);
+    assert(fuzz_ctx->verified);
+    assert(!fuzz_ctx->applied);
+    assert(memcmp(comp, &fuzz_ctx->update_comp, sizeof(*comp)) == 0);
+
+    if (fuzz_chance(fuzz_ctx, 5))
+    {
+        debug_printf("cb_apply set failure\n");
+        return PLDM_FWUP_APPLY_FAILURE_MEMORY_ISSUE;
+    }
+
+    if (fuzz_chance(fuzz_ctx, 50))
+    {
+        debug_printf("cb_apply set ret_pending=true\n");
+        *ret_pending = true;
+    }
+    else
+    {
+        debug_printf("cb_apply set current_update=false\n");
+        fuzz_ctx->current_update = false;
+        fuzz_ctx->applied = true;
+    }
+
+    return PLDM_SUCCESS;
+}
+
+static uint8_t cb_activate(void* ctx, bool self_contained LIBPLDM_CC_UNUSED,
+                           uint16_t* ret_estimated_time LIBPLDM_CC_UNUSED)
+{
+    debug_printf("cb_activate\n");
+    struct fuzz_ops_ctx* fuzz_ctx = static_cast<struct fuzz_ops_ctx*>(ctx);
+
+    assert(!fuzz_ctx->current_update);
+    if (fuzz_chance(fuzz_ctx, 5))
+    {
+        return PLDM_ERROR;
+    }
+    return PLDM_SUCCESS;
+}
+
+static void cb_cancel_update_component(
+    void* ctx, const struct pldm_firmware_update_component* comp)
+{
+    debug_printf("cb_cancel_update_component\n");
+    struct fuzz_ops_ctx* fuzz_ctx = static_cast<struct fuzz_ops_ctx*>(ctx);
+
+    assert(fuzz_ctx->current_update);
+    assert(fuzz_ctx->offset <= fuzz_ctx->update_comp.comp_image_size);
+    assert(memcmp(comp, &fuzz_ctx->update_comp, sizeof(*comp)) == 0);
+    fuzz_ctx->current_update = false;
+}
+
+static uint64_t cb_now(void* ctx)
+{
+    struct fuzz_ops_ctx* fuzz_ctx = static_cast<struct fuzz_ops_ctx*>(ctx);
+
+    // Arbitrary 3s increment. FD code has a 1s retry timeout.
+    fuzz_ctx->now += 3000;
+    return fuzz_ctx->now;
+}
+
+static const struct pldm_fd_ops fuzz_ops = {
+    .device_identifiers = cb_device_identifiers,
+    .components = cb_components,
+    .imageset_versions = cb_imageset_versions,
+    .update_component = cb_update_component,
+    .transfer_size = cb_transfer_size,
+    .firmware_data = cb_firmware_data,
+    .verify = cb_verify,
+    .apply = cb_apply,
+    .activate = cb_activate,
+    .cancel_update_component = cb_cancel_update_component,
+    .now = cb_now,
+};
+
+extern "C" int LLVMFuzzerInitialize(int* argc LIBPLDM_CC_UNUSED,
+                                    char*** argv LIBPLDM_CC_UNUSED)
+{
+    printf_enabled = getenv("TRACEFWFD");
+    return 0;
+}
+
+extern "C" int LLVMFuzzerTestOneInput(uint8_t* input, size_t len)
+{
+    int rc;
+    struct pldm_msgbuf _fuzzctrl;
+    struct pldm_msgbuf* fuzzctrl = &_fuzzctrl;
+    struct pldm_msgbuf _fuzzproto;
+    struct pldm_msgbuf* fuzzproto = &_fuzzproto;
+
+    /* Split input into two parts. First FUZZCTRL_SIZE (0x400 bytes currently)
+     * is used for fuzzing control (random choices etc).
+     * The remainder is a PLDM packet stream, of length:data */
+    if (len < FUZZCTRL_SIZE)
+    {
+        return 0;
+    }
+    size_t proto_size = len - FUZZCTRL_SIZE;
+    rc = pldm_msgbuf_init_errno(fuzzctrl, 0, input, FUZZCTRL_SIZE);
+    assert(rc == 0);
+    rc =
+        pldm_msgbuf_init_errno(fuzzproto, 0, &input[FUZZCTRL_SIZE], proto_size);
+    assert(rc == 0);
+
+    auto ops_ctx = std::make_unique<fuzz_ops_ctx>();
+    memset(ops_ctx.get(), 0x0, sizeof(fuzz_ops_ctx));
+    /* callbacks may consume bytes from the fuzz control */
+    ops_ctx->fuzz_ctrl = fuzzctrl;
+
+    struct pldm_fd* fd = pldm_fd_new(&fuzz_ops, ops_ctx.get(), NULL);
+    assert(fd);
+
+    while (true)
+    {
+        /* Arbitrary length send buffer */
+        uint32_t send_len;
+        rc = pldm_msgbuf_extract_uint32(fuzzctrl, send_len);
+        if (rc)
+        {
+            break;
+        }
+        send_len %= (MAX_SEND + 1);
+        std::vector<uint8_t> send_buf(send_len);
+
+        size_t len = send_buf.size();
+        /* Either perform pldm_fd_handle_msg() or pldm_fd_progress() */
+        if (fuzz_chance(ops_ctx.get(), PROGRESS_PERCENT))
+        {
+            uint8_t address = FIXED_ADDR;
+            pldm_fd_progress(fd, send_buf.data(), &len, &address);
+        }
+        else
+        {
+            uint32_t part_len;
+            rc = pldm_msgbuf_extract_uint32(fuzzproto, part_len);
+            if (rc)
+            {
+                break;
+            }
+            part_len = std::min(part_len, MAX_PART);
+            /* Fresh allocation so that ASAN will notice overflow reads */
+            std::vector<uint8_t> part_buf(part_len);
+            rc = pldm_msgbuf_extract_array_uint8(
+                fuzzproto, part_len, part_buf.data(), part_buf.size());
+            if (rc != 0)
+            {
+                break;
+            }
+            pldm_fd_handle_msg(fd, FIXED_ADDR, part_buf.data(), part_buf.size(),
+                               send_buf.data(), &len);
+        }
+        assert(len <= send_buf.size());
+    }
+
+    free(fd);
+    return 0;
+}
+
+#ifdef HFND_FUZZING_ENTRY_FUNCTION
+#define USING_HONGGFUZZ 1
+#else
+#define USING_HONGGFUZZ 0
+#endif
+
+#ifdef __AFL_FUZZ_TESTCASE_LEN
+#define USING_AFL 1
+#else
+#define USING_AFL 0
+#endif
+
+#if USING_AFL
+__AFL_FUZZ_INIT();
+#endif
+
+#if !USING_AFL && !USING_HONGGFUZZ
+/* Let it build without AFL taking stdin instead */
+static void run_standalone()
+{
+    while (true)
+    {
+        unsigned char buf[1024000];
+        ssize_t len = read(STDIN_FILENO, buf, sizeof(buf));
+        if (len <= 0)
+        {
+            break;
+        }
+        LLVMFuzzerTestOneInput(buf, len);
+    }
+}
+#endif
+
+#if !USING_HONGGFUZZ
+int main(int argc, char** argv)
+{
+    LLVMFuzzerInitialize(&argc, &argv);
+
+#if USING_AFL
+    __AFL_INIT();
+    uint8_t* buf = __AFL_FUZZ_TESTCASE_BUF;
+
+    while (__AFL_LOOP(100000))
+    {
+        size_t len = __AFL_FUZZ_TESTCASE_LEN;
+        LLVMFuzzerTestOneInput(buf, len);
+    }
+#else
+    run_standalone();
+#endif
+
+    return 0;
+}
+#endif // !USING_HONGGFUZZ
diff --git a/tests/fuzz/fd.dict b/tests/fuzz/fd.dict
new file mode 100644
index 0000000..5749e4d
--- /dev/null
+++ b/tests/fuzz/fd.dict
@@ -0,0 +1,22 @@
+FIXED_DESCRIPTORS="\x01\x00\x04\x00\xcf\xc2\x00\x00"
+
+# request update cmd 0x10. identifier "1"
+request_update="\x0f\x00\x00\x00\x80\x05\x10\x00\x04\x00\x00\x01\x00\x01\x00\x00\x01\x01\x31"
+
+# pass component table
+# Length 16 prefix.
+# startandend=0x05, classification=7, identifier=123 (0x7b), index = 9
+pass_comp="\x10\x00\x00\x00\x80\x05\x13\x05\x07\x00\x7b\x00\x09\xff\xff\xff\xff\x01\x01Z"
+# mostly 0x00 for details
+pass_comp_00="\x10\x00\x00\x00\x80\x05\x13\x05\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x01Z"
+
+# Update Component. Similar to pass_comp at the start
+# set size of 0x810 bytes (2064 bytes)
+update_comp="\x17\x00\x00\x00\x80\x05\x14\x07\x00\x7b\x00\x09\xff\xff\xff\xff\x10\x08\x00\x00\x00\x00\x00\x00\x01\x01Z"
+update_comp_00="\x17\x00\x00\x00\x80\x05\x14\x00\x00\x00\x00\x00\xff\xff\xff\xff\x10\x08\x00\x00\x00\x00\x00\x00\x01\x01Z"
+
+
+"zzz"
+"fnnn"
+"1234"
+"1235"
diff --git a/tests/fuzz/fuzz-build.py b/tests/fuzz/fuzz-build.py
new file mode 100755
index 0000000..cc34fb7
--- /dev/null
+++ b/tests/fuzz/fuzz-build.py
@@ -0,0 +1,90 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
+
+# Builds fuzzing variants. Run this from the toplevel directory.
+# Beware this will wipe build directories.
+
+# Requires honggfuzz and afl++ installed
+
+# Builds are:
+# * AFL (normal, asan, cmplog)
+# * honggfuzz
+# * -O0, with coverage
+
+import os
+import subprocess
+
+# reduce warning level since tests since gtest is noisy
+BASE_MESONFLAGS = "-Dwarning_level=2 -Ddefault_library=static --wipe".split()
+FUZZ_PROGRAMS = ["tests/fuzz/fd-fuzz"]
+
+
+def build(
+    build_dir: str,
+    cc: str = None,
+    cxx: str = None,
+    cflags="",
+    cxxflags="",
+    opt="3",
+    env={},
+    mesonflags=[],
+):
+    env = os.environ | env
+    env["CFLAGS"] = cflags
+    env["CXXFLAGS"] = cxxflags
+
+    # Meson sets CC="ccache cc" by default, but ccache removes -fprofile-arcs
+    # so coverage breaks (ccache #1531). Prevent that by setting CC/CXX.
+    env["CC"] = cc if cc else "cc"
+    env["CXX"] = cxx if cxx else "c++"
+
+    meson_cmd = ["meson"] + BASE_MESONFLAGS + mesonflags
+    meson_cmd += [f"-Doptimization={opt}"]
+    meson_cmd += [build_dir]
+    subprocess.run(meson_cmd, env=env, check=True)
+
+    ninja_cmd = ["ninja", "-C", build_dir] + FUZZ_PROGRAMS
+    subprocess.run(ninja_cmd, env=env, check=True)
+
+
+def build_afl():
+    env = {
+        # seems to be required for afl-clang-lto?
+        "AFL_REAL_LD": "ld.lld",
+    }
+    cc = "afl-clang-lto"
+    cxx = "afl-clang-lto++"
+
+    # normal
+    build("bfuzz", cc=cc, cxx=cxx, env=env)
+    # ASAN
+    build(
+        "bfuzzasan",
+        cc=cc,
+        cxx=cxx,
+        mesonflags=["-Db_sanitize=address"],
+        env=env,
+    )
+    # cmplog
+    build("bcmplog", cc=cc, cxx=cxx, env={"AFL_LLVM_CMPLOG": "1"} | env)
+
+
+def main():
+    # No profiling, has coverage
+    build(
+        "bnoopt",
+        cflags="-fprofile-abs-path",
+        cxxflags="-fprofile-abs-path",
+        opt="0",
+        mesonflags=["-Db_coverage=true"],
+    )
+
+    # AFL
+    build_afl()
+
+    # Honggfuzz
+    build("bhf", cc="hfuzz-clang", cxx="hfuzz-clang++")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tests/fuzz/fuzz-coverage.py b/tests/fuzz/fuzz-coverage.py
new file mode 100755
index 0000000..07756b0
--- /dev/null
+++ b/tests/fuzz/fuzz-coverage.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
+
+# usage: fuzz-coverage.py [-h] corpus program srcdir builddir outdir
+
+# Runs corpus (directory of testcases) against a program
+# built with coverage, and produces a html report.
+
+# The program should be built with --coverage -fprofile-abs-path
+# -O0 may make the html report more legible?
+
+# Requires lcov and https://github.com/mozilla/grcov
+
+import argparse
+import subprocess
+import sys
+from pathlib import Path
+
+
+def run(args):
+    corpus = Path(args.corpus)
+    outdir = Path(args.outdir)
+
+    for c in Path(args.builddir).glob("**/*.gcda"):
+        print(f"Removed old coverage {c}", file=sys.stderr)
+        c.unlink()
+
+    print("Running corpus", file=sys.stderr)
+    for c in corpus.glob("*"):
+        c = c.open("rb").read()
+        subprocess.run([args.program], input=c)
+
+    print("Running grcov", file=sys.stderr)
+    outdir.mkdir(parents=True, exist_ok=True)
+    coverage_paths = [args.builddir]
+    lcov_file = outdir / "lcov.info"
+
+    subprocess.run(
+        [
+            "grcov",
+            "-b",
+            args.program,
+            "-o",
+            lcov_file,
+            "-t",
+            "lcov",
+            "-s",
+            args.srcdir,
+        ]
+        + coverage_paths,
+        check=True,
+    )
+
+    print("Running genhtml", file=sys.stderr)
+    subprocess.run(
+        [
+            "genhtml",
+            "-o",
+            outdir,
+            "--show-details",
+            "--highlight",
+            "--ignore-errors",
+            "source",
+            "--legend",
+            lcov_file,
+        ],
+        check=True,
+    )
+
+    html = outdir / "index.html"
+    print(f"\n\nOutput is file://{html.absolute()}", file=sys.stderr)
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("corpus", type=str, help="Corpus directory")
+    parser.add_argument("program", type=str, help="Target Program")
+    parser.add_argument("srcdir", type=str, help="Source directory")
+    parser.add_argument("builddir", type=str)
+    parser.add_argument("outdir", type=str)
+    args = parser.parse_args()
+
+    run(args)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tests/fuzz/meson.build b/tests/fuzz/meson.build
new file mode 100644
index 0000000..5412c7f
--- /dev/null
+++ b/tests/fuzz/meson.build
@@ -0,0 +1,8 @@
+executable(
+    'fd-fuzz',
+    'fd-fuzz.cpp',
+    # for __AFL_LOOP
+    cpp_args: ['-Wno-gnu-statement-expression-from-macro-expansion'],
+    include_directories: test_include_dirs,
+    dependencies: [libpldm_dep],
+)
diff --git a/tests/meson.build b/tests/meson.build
index e85e1ba..0c3bc2b 100644
--- a/tests/meson.build
+++ b/tests/meson.build
@@ -18,6 +18,10 @@
 
 subdir('dsp')
 
+if get_option('abi').contains('testing')
+    subdir('fuzz')
+endif
+
 # The 'test' transport APIs will never be marked stable as they are just for
 # testing.
 if get_option('abi').contains('testing')