test/fuzz: build with CMake, keep the OSS-Fuzz make entry points (#4219)

Closes #4209.
diff --git a/.github/workflows/cifuzz.yml b/.github/workflows/cifuzz.yml
index ff75a8b..126c2b1 100644
--- a/.github/workflows/cifuzz.yml
+++ b/.github/workflows/cifuzz.yml
@@ -10,6 +10,9 @@
       - '**.cpp'
       - '**.cxx'
       - '**.h'
+      - 'test/fuzz/**'
+      - '!test/fuzz/**.md'
+      - '.github/workflows/cifuzz.yml'
 jobs:
  Fuzzing:
    runs-on: ubuntu-latest
diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml
index 09f912b..1717005 100644
--- a/.github/workflows/pre-commit.yml
+++ b/.github/workflows/pre-commit.yml
@@ -34,15 +34,19 @@
     - name: Run pre-commit
       uses: pre-commit/action@v3.0.1
 
-    - name: Build Fuzzer
+    - name: Fuzz
       run: |
-        sudo apt install libc++-dev libc++abi-dev
-        clang --version
-        export CC=clang
-        export CXX=clang++
-        fuzz_harness=$(ls -d test/fuzz/device/*/)
-        for h in $fuzz_harness
-        do
-          make -C $h get-deps
-          make -C $h all
+        python3 tools/get_deps.py
+        cmake -S test/fuzz -B test/fuzz/_build -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++
+        cmake --build test/fuzz/_build --parallel
+        ctest --test-dir test/fuzz/_build --output-on-failure
+        pids=""
+        for d in test/fuzz/device/*/; do
+          h=$(basename $d)
+          test/fuzz/_build/$h -max_total_time=10 test/fuzz/_build/corpus/$h > fuzz-$h.log 2>&1 &
+          pids="$pids $!"
         done
+        rc=0
+        for p in $pids; do wait $p || rc=1; done
+        if [ $rc -ne 0 ]; then cat fuzz-*.log; else tail -n 3 fuzz-*.log; fi
+        exit $rc
diff --git a/.oss-scanner/Dockerfile b/.oss-scanner/Dockerfile
index 13ccff8..1761241 100644
--- a/.oss-scanner/Dockerfile
+++ b/.oss-scanner/Dockerfile
@@ -5,7 +5,7 @@
     CC=clang \
     CXX=clang++
 RUN apt-get update && apt-get install -y --no-install-recommends \
-      ca-certificates build-essential clang libclang-rt-14-dev lld libc++-dev libc++abi-dev git python3 ruby unzip \
+      ca-certificates build-essential clang cmake libclang-rt-14-dev lld git python3 ruby unzip \
     && rm -rf /var/lib/apt/lists/*
 # pinned like .github/workflows/pre-commit.yml
 RUN gem install --no-document ceedling -v 1.0.1
@@ -13,13 +13,13 @@
 COPY . /src
 WORKDIR /src
 
-# COVERAGE_FLAGS is cleared as OSS-Fuzz does: libFuzzer refuses to run a binary built with make.mk's default
-# -fsanitize-coverage=trace-pc-guard
+# same make entry points OSS-Fuzz uses; each harness then replays its seeds once
 RUN python3 tools/get_deps.py && \
     for h in test/fuzz/device/*/; do \
       name=$(basename "$h"); \
-      make -C "$h" all COVERAGE_FLAGS= && \
-      if [ -f "$h/${name}_seed_corpus.zip" ]; then unzip -qo "$h/${name}_seed_corpus.zip" -d "$h/corpus"; fi \
+      make -C "$h" all && mkdir -p "$h/corpus" && \
+      if [ -f "$h/${name}_seed_corpus.zip" ]; then unzip -qo "$h/${name}_seed_corpus.zip" -d "$h/corpus"; fi && \
+      "$h/_build/$name" -runs=0 "$h/corpus" \
       || exit 1; \
     done
 
diff --git a/.oss-scanner/threat_model.md b/.oss-scanner/threat_model.md
index f945128..789cf50 100644
--- a/.oss-scanner/threat_model.md
+++ b/.oss-scanner/threat_model.md
@@ -28,9 +28,14 @@
   those libraries stays in scope.
 
 ## How to exercise it
-- libFuzzer + ASan harnesses: `test/fuzz/device/<cdc|msc|net|net_ncm>/_build/<name>`; where a seed archive exists
-  it is unpacked to `test/fuzz/device/<name>/corpus/`. The `cdc`, `msc` and `net` harnesses drive the device stack through a synthetic
-  controller; `net_ncm` tests the NCM receive validator alone. None covers host mode.
+- libFuzzer + ASan harnesses: `test/fuzz/device/<cdc|msc|net|net_ncm>/_build/<name>`, seeds unpacked to
+  `test/fuzz/device/<name>/corpus/` (empty for `net`). Replay with `<binary> -runs=0 <corpus or file>`; rebuild offline
+  with `make -C test/fuzz/device/<name> all` (CMake, `test/fuzz/CMakeLists.txt`).
+- Harness reach is narrow; absence of a harness crash is not evidence of safety. `cdc`, `msc` and `net` drive the device
+  stack through a synthetic controller that delivers bus events and SETUP packets but never completes a transfer, so
+  control data stages and class bulk data (MSC SCSI, RNDIS/ECM payloads) are not reached. The MSC and ECM/RNDIS
+  drivers are never opened either: `msc` uses endpoint 5 while its build allows five endpoints (0-4), and `net`'s
+  descriptor advertises only CDC. `net_ncm` tests the NCM receive validator alone. None covers host mode.
 - Unit tests: `cd test/unit-test && ceedling test:all` (device and host classes, hub, FIFO).
 
 ## How you rate severity
diff --git a/test/fuzz/CMakeLists.txt b/test/fuzz/CMakeLists.txt
new file mode 100644
index 0000000..fabaaea
--- /dev/null
+++ b/test/fuzz/CMakeLists.txt
@@ -0,0 +1,134 @@
+cmake_minimum_required(VERSION 3.20)
+
+project(tinyusb_fuzz C CXX)
+
+cmake_path(SET TOP NORMALIZE ${CMAKE_CURRENT_LIST_DIR}/../..)
+set(TINYUSB_FUZZ_TARGET "" CACHE STRING "Harness to configure: cdc, msc, net or net_ncm; empty for all")
+
+if (NOT CMAKE_C_COMPILER_ID MATCHES "Clang" OR NOT CMAKE_CXX_COMPILER_ID MATCHES "Clang")
+  message(FATAL_ERROR "fuzz harnesses need clang: configure with CC=clang CXX=clang++")
+endif ()
+
+# OSS-Fuzz (LIB_FUZZING_ENGINE defined) owns the engine, sanitizer, coverage and optimisation flags through
+# CFLAGS/CXXFLAGS; its trunk clang also gains warnings over time, so -Werror is local only.
+if (DEFINED ENV{LIB_FUZZING_ENGINE})
+  separate_arguments(FUZZ_ENGINE UNIX_COMMAND "$ENV{LIB_FUZZING_ENGINE}")
+  set(FUZZ_OPTIONS "")
+else ()
+  set(FUZZ_ENGINE -fsanitize=fuzzer,address)
+  set(FUZZ_OPTIONS -fsanitize=fuzzer-no-link,address -O1 -g -Werror)
+endif ()
+
+# unconditional so a reused build dir never keeps seed tests from an earlier local-mode configure
+enable_testing()
+
+set(FUZZ_WARNINGS
+  -Wall
+  -Wextra
+  -Wfatal-errors
+  -Wdouble-promotion
+  -Wstrict-overflow
+  -Wfloat-equal
+  -Wundef
+  -Wshadow
+  -Wwrite-strings
+  -Wsign-compare
+  -Wmissing-format-attribute
+  -Wunreachable-code
+  -Wcast-align
+  -Wcast-qual
+  -Wnull-dereference
+  -Wuninitialized
+  -Wunused
+  -Wredundant-decls
+  -Wno-error=unreachable-code
+  $<$<COMPILE_LANGUAGE:C>:-Wstrict-prototypes -Werror=implicit-function-declaration>
+  $<$<COMPILE_LANGUAGE:CXX>:-Wno-c++11-narrowing>
+  )
+
+# target_link_libraries rather than link options: OSS-Fuzz's AFL, honggfuzz and centipede engines are archive paths
+function(fuzz_target_common TARGET)
+  target_link_libraries(${TARGET} PRIVATE ${FUZZ_ENGINE})
+  # sections + gc drop stack paths whose controller hooks the harness lacks (e.g. dcd_edpt_iso_alloc); GNU ld keeps
+  # them alive through the sanitizer-coverage tables, lld does not
+  target_compile_options(${TARGET} PRIVATE -ffunction-sections -fdata-sections -fno-strict-aliasing ${FUZZ_WARNINGS} ${FUZZ_OPTIONS})
+  target_link_options(${TARGET} PRIVATE -fuse-ld=lld -Wl,--gc-sections)
+  set_target_properties(${TARGET} PROPERTIES C_STANDARD 99 CXX_STANDARD 17 LINKER_LANGUAGE CXX)
+endfunction()
+
+# Replay the seed archive once (local libFuzzer builds only; OSS-Fuzz exercises its own engines)
+function(fuzz_add_seed_test NAME)
+  cmake_parse_arguments(ARG "" "SEED" "" ${ARGN})
+  if (DEFINED ENV{LIB_FUZZING_ENGINE})
+    return()
+  endif ()
+  set(CORPUS ${CMAKE_CURRENT_BINARY_DIR}/corpus/${NAME})
+  file(REMOVE_RECURSE ${CORPUS})
+  file(MAKE_DIRECTORY ${CORPUS})
+  if (ARG_SEED)
+    file(ARCHIVE_EXTRACT INPUT ${ARG_SEED} DESTINATION ${CORPUS})
+    set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${ARG_SEED})
+  endif ()
+  add_test(NAME ${NAME}_seed_replay COMMAND ${NAME} -runs=0 ${CORPUS})
+  set_tests_properties(${NAME}_seed_replay PROPERTIES TIMEOUT 300)
+endfunction()
+
+# Device stack harness driven through the synthetic controller in dcd_fuzz.cc
+function(fuzz_add_device_harness NAME)
+  cmake_parse_arguments(ARG "" "" "SOURCES;INCLUDES" ${ARGN})
+  set(DIR ${CMAKE_CURRENT_LIST_DIR}/device/${NAME})
+  add_executable(${NAME}
+    ${TOP}/src/tusb.c
+    ${TOP}/src/common/tusb_fifo.c
+    ${TOP}/src/device/usbd.c
+    ${CMAKE_CURRENT_LIST_DIR}/dcd_fuzz.cc
+    ${CMAKE_CURRENT_LIST_DIR}/fuzz.cc
+    ${CMAKE_CURRENT_LIST_DIR}/usbd_fuzz.cc
+    ${DIR}/src/fuzz.cc
+    ${DIR}/src/usb_descriptors.cc
+    ${ARG_SOURCES}
+    )
+  target_include_directories(${NAME} PRIVATE ${DIR}/src ${TOP}/src ${TOP}/test ${ARG_INCLUDES})
+  target_compile_definitions(${NAME} PRIVATE OPT_MCU_FUZZ=1 CFG_TUSB_MCU=OPT_MCU_FUZZ _FUZZ)
+  fuzz_target_common(${NAME})
+endfunction()
+
+if (TINYUSB_FUZZ_TARGET STREQUAL "" OR TINYUSB_FUZZ_TARGET STREQUAL "cdc")
+  fuzz_add_device_harness(cdc SOURCES ${TOP}/src/class/cdc/cdc_device.c)
+  fuzz_add_seed_test(cdc SEED ${CMAKE_CURRENT_LIST_DIR}/device/cdc/cdc_seed_corpus.zip)
+endif ()
+
+if (TINYUSB_FUZZ_TARGET STREQUAL "" OR TINYUSB_FUZZ_TARGET STREQUAL "msc")
+  fuzz_add_device_harness(msc SOURCES
+    ${TOP}/src/class/msc/msc_device.c
+    ${CMAKE_CURRENT_LIST_DIR}/msc_fuzz.cc
+    )
+  fuzz_add_seed_test(msc SEED ${CMAKE_CURRENT_LIST_DIR}/device/msc/msc_seed_corpus.zip)
+endif ()
+
+if (TINYUSB_FUZZ_TARGET STREQUAL "" OR TINYUSB_FUZZ_TARGET STREQUAL "net")
+  # lwIP headers only (rndis_reports.c needs netif/ethernet.h); fetched by tools/get_deps.py
+  if (NOT EXISTS ${TOP}/lib/lwip/src/include/lwip/init.h)
+    message(FATAL_ERROR "lib/lwip is missing: run python3 tools/get_deps.py")
+  endif ()
+  fuzz_add_device_harness(net
+    SOURCES
+      ${TOP}/src/class/cdc/cdc_device.c
+      ${TOP}/src/class/net/ecm_rndis_device.c
+      ${TOP}/lib/networking/rndis_reports.c
+      ${CMAKE_CURRENT_LIST_DIR}/net_fuzz.cc
+    INCLUDES
+      ${TOP}/lib/lwip/src/include
+      ${TOP}/lib/networking
+    )
+  fuzz_add_seed_test(net)
+endif ()
+
+# NCM receive validator alone: fuzz.c #includes ncm_device.c
+if (TINYUSB_FUZZ_TARGET STREQUAL "" OR TINYUSB_FUZZ_TARGET STREQUAL "net_ncm")
+  set(DIR ${CMAKE_CURRENT_LIST_DIR}/device/net_ncm)
+  add_executable(net_ncm ${DIR}/fuzz.c)
+  target_include_directories(net_ncm PRIVATE ${DIR} ${TOP}/src)
+  fuzz_target_common(net_ncm)
+  fuzz_add_seed_test(net_ncm SEED ${DIR}/net_ncm_seed_corpus.zip)
+endif ()
diff --git a/test/fuzz/README.md b/test/fuzz/README.md
new file mode 100644
index 0000000..7e30463
--- /dev/null
+++ b/test/fuzz/README.md
@@ -0,0 +1,40 @@
+# Fuzz harnesses
+
+libFuzzer harnesses for the device stack, built with CMake on the host:
+
+| Harness   | Exercises                                                          |
+|-----------|--------------------------------------------------------------------|
+| `cdc`     | device stack + CDC, through the synthetic controller `dcd_fuzz.cc` |
+| `msc`     | device stack + MSC                                                 |
+| `net`     | device stack + CDC and ECM/RNDIS                                   |
+| `net_ncm` | the NCM receive validator alone                                    |
+
+The synthetic controller never completes a transfer, so only bus events and SETUP packets reach the stack; see
+`.oss-scanner/threat_model.md` for the current limits.
+
+## Build and run
+
+Needs clang with the libFuzzer runtime, lld and CMake >= 3.20 (>= 3.24 for the Make entry points). `net` needs
+`lib/lwip` headers: `python3 tools/get_deps.py`.
+
+```sh
+CC=clang CXX=clang++ cmake -S test/fuzz -B test/fuzz/_build
+cmake --build test/fuzz/_build --parallel
+ctest --test-dir test/fuzz/_build              # replays each harness's seed corpus once
+mkdir -p cdc_corpus
+test/fuzz/_build/cdc -max_total_time=60 cdc_corpus test/fuzz/_build/corpus/cdc
+```
+
+New inputs go to the first corpus directory; `_build/corpus/<harness>` is re-extracted from the seed zip on every
+configure.
+
+CMake caches the compiler and `CFLAGS`/`CXXFLAGS` on first configure: use a fresh build directory to change them.
+Without `LIB_FUZZING_ENGINE` the build adds libFuzzer + ASan, `-O1 -g` and `-Werror`. With it set (OSS-Fuzz),
+the engine, sanitizers and optimisation come from `LIB_FUZZING_ENGINE` and `CFLAGS`/`CXXFLAGS` only.
+
+## OSS-Fuzz
+
+OSS-Fuzz's `projects/tinyusb/build.sh` runs `make -C test/fuzz/device/<harness> get-deps all` and copies
+`_build/<harness>` and `<harness>_seed_corpus.zip`. Those Makefiles are thin wrappers around the CMake build
+(`fuzz.mk`); keep that interface. `make -C test/fuzz/device/net_ncm regression` replays the NCM seed
+archive (a crafted out-of-bounds NTB) once.
diff --git a/test/fuzz/device/cdc/CMakeLists.txt b/test/fuzz/device/cdc/CMakeLists.txt
deleted file mode 100644
index 85094cf..0000000
--- a/test/fuzz/device/cdc/CMakeLists.txt
+++ /dev/null
@@ -1,26 +0,0 @@
-cmake_minimum_required(VERSION 3.5)
-
-include(${CMAKE_CURRENT_SOURCE_DIR}/../../../hw/bsp/family_support.cmake)
-
-project(cdc)
-
-# Checks this example is valid for the family and initializes the project
-family_initialize_project(${PROJECT_NAME} ${CMAKE_CURRENT_LIST_DIR})
-
-add_executable(${PROJECT_NAME})
-
-# Example source
-target_sources(${PROJECT_NAME} PUBLIC
-  ${CMAKE_CURRENT_SOURCE_DIR}/src/main.c
-  ${CMAKE_CURRENT_SOURCE_DIR}/src/msc_disk.c
-  ${CMAKE_CURRENT_SOURCE_DIR}/src/usb_descriptors.c
-  )
-
-# Example include
-target_include_directories(${PROJECT_NAME} PUBLIC
-  ${CMAKE_CURRENT_SOURCE_DIR}/src
-  )
-
-# Configure compilation flags and libraries for the example without RTOS.
-# See the corresponding function in hw/bsp/FAMILY/family.cmake for details.
-family_configure_device_example(${PROJECT_NAME} noos)
diff --git a/test/fuzz/device/cdc/Makefile b/test/fuzz/device/cdc/Makefile
index d448907..b80d48b 100644
--- a/test/fuzz/device/cdc/Makefile
+++ b/test/fuzz/device/cdc/Makefile
@@ -1,11 +1 @@
-include ../../make.mk
-
-INC += \
-	src \
-
-
-# Example source
-SRC_C += $(addprefix $(EXAMPLE_PATH)/, $(wildcard src/*.c))
-SRC_CXX += $(addprefix $(EXAMPLE_PATH)/, $(wildcard src/*.cc))
-
-include ../../rules.mk
+include ../../fuzz.mk
diff --git a/test/fuzz/device/msc/CMakeLists.txt b/test/fuzz/device/msc/CMakeLists.txt
deleted file mode 100644
index 6eb6c8c..0000000
--- a/test/fuzz/device/msc/CMakeLists.txt
+++ /dev/null
@@ -1,26 +0,0 @@
-cmake_minimum_required(VERSION 3.5)
-
-include(${CMAKE_CURRENT_SOURCE_DIR}/../../../hw/bsp/family_support.cmake)
-
-project(msc)
-
-# Checks this example is valid for the family and initializes the project
-family_initialize_project(${PROJECT_NAME} ${CMAKE_CURRENT_LIST_DIR})
-
-add_executable(${PROJECT_NAME})
-
-# Example source
-target_sources(${PROJECT_NAME} PUBLIC
-        ${CMAKE_CURRENT_SOURCE_DIR}/src/main.c
-        ${CMAKE_CURRENT_SOURCE_DIR}/src/msc_disk.c
-        ${CMAKE_CURRENT_SOURCE_DIR}/src/usb_descriptors.c
-        )
-
-# Example include
-target_include_directories(${PROJECT_NAME} PUBLIC
-        ${CMAKE_CURRENT_SOURCE_DIR}/src
-        )
-
-# Configure compilation flags and libraries for the example without RTOS.
-# See the corresponding function in hw/bsp/FAMILY/family.cmake for details.
-family_configure_device_example(${PROJECT_NAME} noos)
diff --git a/test/fuzz/device/msc/Makefile b/test/fuzz/device/msc/Makefile
index d448907..b80d48b 100644
--- a/test/fuzz/device/msc/Makefile
+++ b/test/fuzz/device/msc/Makefile
@@ -1,11 +1 @@
-include ../../make.mk
-
-INC += \
-	src \
-
-
-# Example source
-SRC_C += $(addprefix $(EXAMPLE_PATH)/, $(wildcard src/*.c))
-SRC_CXX += $(addprefix $(EXAMPLE_PATH)/, $(wildcard src/*.cc))
-
-include ../../rules.mk
+include ../../fuzz.mk
diff --git a/test/fuzz/device/net/CMakeLists.txt b/test/fuzz/device/net/CMakeLists.txt
deleted file mode 100644
index 84e92ad..0000000
--- a/test/fuzz/device/net/CMakeLists.txt
+++ /dev/null
@@ -1,26 +0,0 @@
-cmake_minimum_required(VERSION 3.5)
-
-include(${CMAKE_CURRENT_SOURCE_DIR}/../../../hw/bsp/family_support.cmake)
-
-project(net)
-
-# Checks this example is valid for the family and initializes the project
-family_initialize_project(${PROJECT_NAME} ${CMAKE_CURRENT_LIST_DIR})
-
-add_executable(${PROJECT_NAME})
-
-# Example source
-target_sources(${PROJECT_NAME} PUBLIC
-        ${CMAKE_CURRENT_SOURCE_DIR}/src/main.c
-        ${CMAKE_CURRENT_SOURCE_DIR}/src/msc_disk.c
-        ${CMAKE_CURRENT_SOURCE_DIR}/src/usb_descriptors.c
-        )
-
-# Example include
-target_include_directories(${PROJECT_NAME} PUBLIC
-        ${CMAKE_CURRENT_SOURCE_DIR}/src
-        )
-
-# Configure compilation flags and libraries for the example without RTOS.
-# See the corresponding function in hw/bsp/FAMILY/family.cmake for details.
-family_configure_device_example(${PROJECT_NAME} noos)
diff --git a/test/fuzz/device/net/Makefile b/test/fuzz/device/net/Makefile
index 45c684e..8caeb86 100644
--- a/test/fuzz/device/net/Makefile
+++ b/test/fuzz/device/net/Makefile
@@ -1,67 +1,4 @@
-include ../../make.mk
+include ../../fuzz.mk
 
-# suppress warning caused by lwip
-CFLAGS += \
-  -Wno-error=null-dereference \
-  -Wno-error=unused-parameter \
-  -Wno-error=unused-variable
-
-INC += \
-  src \
-  $(TOP)/lib/lwip/src/include \
-  $(TOP)/lib/lwip/src/include/ipv4 \
-  $(TOP)/lib/lwip/src/include/lwip/apps \
-  $(TOP)/lib/networking
-
-# Example source
-SRC_C += $(addprefix $(EXAMPLE_PATH)/, $(wildcard src/*.c))
-SRC_CXX += $(addprefix $(EXAMPLE_PATH)/, $(wildcard src/*.cc))
-
-# lwip sources
-SRC_C += \
-  lib/lwip/src/core/altcp.c \
-  lib/lwip/src/core/altcp_alloc.c \
-  lib/lwip/src/core/altcp_tcp.c \
-  lib/lwip/src/core/def.c \
-  lib/lwip/src/core/dns.c \
-  lib/lwip/src/core/inet_chksum.c \
-  lib/lwip/src/core/init.c \
-  lib/lwip/src/core/ip.c \
-  lib/lwip/src/core/mem.c \
-  lib/lwip/src/core/memp.c \
-  lib/lwip/src/core/netif.c \
-  lib/lwip/src/core/pbuf.c \
-  lib/lwip/src/core/raw.c \
-  lib/lwip/src/core/stats.c \
-  lib/lwip/src/core/sys.c \
-  lib/lwip/src/core/tcp.c \
-  lib/lwip/src/core/tcp_in.c \
-  lib/lwip/src/core/tcp_out.c \
-  lib/lwip/src/core/timeouts.c \
-  lib/lwip/src/core/udp.c \
-  lib/lwip/src/core/ipv4/autoip.c \
-  lib/lwip/src/core/ipv4/dhcp.c \
-  lib/lwip/src/core/ipv4/etharp.c \
-  lib/lwip/src/core/ipv4/icmp.c \
-  lib/lwip/src/core/ipv4/igmp.c \
-  lib/lwip/src/core/ipv4/ip4.c \
-  lib/lwip/src/core/ipv4/ip4_addr.c \
-  lib/lwip/src/core/ipv4/ip4_frag.c \
-  lib/lwip/src/core/ipv6/dhcp6.c \
-  lib/lwip/src/core/ipv6/ethip6.c \
-  lib/lwip/src/core/ipv6/icmp6.c \
-  lib/lwip/src/core/ipv6/inet6.c \
-  lib/lwip/src/core/ipv6/ip6.c \
-  lib/lwip/src/core/ipv6/ip6_addr.c \
-  lib/lwip/src/core/ipv6/ip6_frag.c \
-  lib/lwip/src/core/ipv6/mld6.c \
-  lib/lwip/src/core/ipv6/nd6.c \
-  lib/lwip/src/netif/ethernet.c \
-  lib/lwip/src/netif/slipif.c \
-  lib/lwip/src/apps/http/httpd.c \
-  lib/lwip/src/apps/http/fs.c \
-  lib/networking/dhserver.c \
-  lib/networking/dnserver.c \
-  lib/networking/rndis_reports.c
-
-include ../../rules.mk
+get-deps:
+	python3 $(TOP)/tools/get_deps.py
diff --git a/test/fuzz/device/net_ncm/Makefile b/test/fuzz/device/net_ncm/Makefile
index df5a613..1482aaa 100644
--- a/test/fuzz/device/net_ncm/Makefile
+++ b/test/fuzz/device/net_ncm/Makefile
@@ -1,43 +1,7 @@
-# Focused fuzz harness for the NCM receive path (recv_validate_datagram).
-#
-# It is self-contained: it #includes src/class/net/ncm_device.c and stubs the
-# few usbd/glue symbols, so it does not link the whole device stack.
-#
-#   make            # build the libFuzzer target: _build/net_ncm
-#   make regression # build + replay the crafted seed under ASan (no engine)
-#   make clean
+include ../../fuzz.mk
 
-TOP := ../../../..
-BUILD := _build
+.PHONY: regression
 
-CC ?= clang
-
-INC += \
-  $(TOP)/src \
-  .
-
-CFLAGS += $(addprefix -I,$(INC)) -g -O1 -fsanitize=address
-
-FUZZ_FLAGS := -fsanitize=fuzzer
-
-.PHONY: all regression clean get-deps
-
-all: $(BUILD)/net_ncm
-
-# No external dependencies (the harness only needs the in-tree NCM driver).
-get-deps:
-
-# libFuzzer supplies its own main(), so drop the standalone driver here.
-$(BUILD)/net_ncm: fuzz.c
-	mkdir -p $(@D)
-	$(CC) $(CFLAGS) $(FUZZ_FLAGS) -DNO_MAIN -o $@ $<
-
-# Standalone build with a plain main(): replays the crafted seed and any extra
-# corpus files given as arguments. Used as a deterministic regression.
-regression: fuzz.c
-	mkdir -p $(BUILD)
-	$(CC) $(CFLAGS) -o $(BUILD)/net_ncm_regression $<
-	./$(BUILD)/net_ncm_regression
-
-clean:
-	$(RM) -rf $(BUILD) *.dSYM
+# replays the seed archive (the crafted out-of-bounds NTB) once under ASan
+regression: all
+	ctest --test-dir $(BUILD) --output-on-failure --no-tests=error -R '^net_ncm_seed_replay$$'
diff --git a/test/fuzz/device/net_ncm/fuzz.c b/test/fuzz/device/net_ncm/fuzz.c
index 3052636..7f6247e 100644
--- a/test/fuzz/device/net_ncm/fuzz.c
+++ b/test/fuzz/device/net_ncm/fuzz.c
@@ -21,7 +21,6 @@
 // this reproduces that out-of-bounds read under -fsanitize=address.
 
 #include <stdint.h>
-#include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
 
@@ -88,56 +87,3 @@
   free(ntb);
   return 0;
 }
-
-//--------------------------------------------------------------------+
-// Standalone driver (built when there is no libFuzzer engine): replays the
-// crafted NTB, or any corpus files passed on the command line.
-//--------------------------------------------------------------------+
-#ifndef NO_MAIN
-static void run_crafted_seed(void) {
-  uint8_t buf[64];
-  memset(buf, 0, sizeof(buf));
-
-  // NTH16: signature, wHeaderLength = sizeof(nth16_t), wSequence, wBlockLength, wNdpIndex
-  uint32_t nth_sig = NTH16_SIGNATURE;
-  memcpy(buf + 0, &nth_sig, 4);
-  uint16_t v;
-  v = sizeof(nth16_t); memcpy(buf + 4, &v, 2);
-  v = 0;               memcpy(buf + 6, &v, 2);
-  v = sizeof(buf);     memcpy(buf + 8, &v, 2);
-  v = sizeof(nth16_t); memcpy(buf + 10, &v, 2);
-
-  // NDP16 at wNdpIndex: signature, wLength (unbounded pre-fix), wNextNdpIndex
-  uint32_t ndp_sig = NDP16_SIGNATURE_NCM0;
-  memcpy(buf + sizeof(nth16_t) + 0, &ndp_sig, 4);
-  v = 0xFFF0; memcpy(buf + sizeof(nth16_t) + 4, &v, 2);
-  v = 0;      memcpy(buf + sizeof(nth16_t) + 6, &v, 2);
-
-  LLVMFuzzerTestOneInput(buf, sizeof(buf));
-}
-
-int main(int argc, char **argv) {
-  if (argc < 2) {
-    run_crafted_seed();
-    return 0;
-  }
-  for (int i = 1; i < argc; i++) {
-    FILE *f = fopen(argv[i], "rb");
-    if (f == NULL) {
-      continue;
-    }
-    fseek(f, 0, SEEK_END);
-    long len = ftell(f);
-    fseek(f, 0, SEEK_SET);
-    if (len > 0) {
-      uint8_t *buf = (uint8_t *) malloc((size_t) len);
-      if (buf != NULL && fread(buf, 1, (size_t) len, f) == (size_t) len) {
-        LLVMFuzzerTestOneInput(buf, (size_t) len);
-      }
-      free(buf);
-    }
-    fclose(f);
-  }
-  return 0;
-}
-#endif
diff --git a/test/fuzz/device/net_ncm/tusb_config.h b/test/fuzz/device/net_ncm/tusb_config.h
index b5e3a1f..b26cd76 100644
--- a/test/fuzz/device/net_ncm/tusb_config.h
+++ b/test/fuzz/device/net_ncm/tusb_config.h
@@ -13,6 +13,7 @@
 // because the harness drives recv_validate_datagram() directly.
 #define CFG_TUSB_MCU            OPT_MCU_NONE
 #define CFG_TUSB_OS             OPT_OS_NONE
+#define TUP_DCD_ENDPOINT_MAX    8 // no controller; tusb_mcu.h would warn and default to 8
 
 #define CFG_TUD_ENABLED        1
 #define CFG_TUD_ENDPOINT0_SIZE  64
diff --git a/test/fuzz/fuzz.h b/test/fuzz/fuzz.h
index 6aa4949..cacca8d 100644
--- a/test/fuzz/fuzz.h
+++ b/test/fuzz/fuzz.h
@@ -24,6 +24,7 @@
  */
 
 #pragma once
+#include <stddef.h>
 #include <stdint.h>
 
 #ifdef __cplusplus
diff --git a/test/fuzz/fuzz.mk b/test/fuzz/fuzz.mk
new file mode 100644
index 0000000..5bb31e8
--- /dev/null
+++ b/test/fuzz/fuzz.mk
@@ -0,0 +1,25 @@
+# Make entry points kept for OSS-Fuzz's projects/tinyusb/build.sh, which runs `make -C <harness> get-deps all` and
+# copies _build/<harness>; the build itself is test/fuzz/CMakeLists.txt.
+TOP := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))/../..)
+HARNESS := $(notdir $(CURDIR))
+BUILD := _build
+
+# make's built-in cc must not reach CMake; an environment or command-line CC still wins
+ifeq ($(origin CC),default)
+export CC := clang
+endif
+ifeq ($(origin CXX),default)
+export CXX := clang++
+endif
+
+.PHONY: all get-deps clean
+
+# --fresh so CC/CXX/CFLAGS/CXXFLAGS/LDFLAGS changes are never masked by CMake's cache
+all:
+	cmake --fresh -S $(TOP)/test/fuzz -B $(BUILD) -DTINYUSB_FUZZ_TARGET=$(HARNESS)
+	cmake --build $(BUILD) --target $(HARNESS) --parallel
+
+get-deps:
+
+clean:
+	cmake -E rm -rf $(BUILD)
diff --git a/test/fuzz/make.mk b/test/fuzz/make.mk
deleted file mode 100644
index 733a571..0000000
--- a/test/fuzz/make.mk
+++ /dev/null
@@ -1,128 +0,0 @@
-# ---------------------------------------
-# Common make definition for all examples
-# ---------------------------------------
-
-#-------------- TOP and EXAMPLE_PATH ------------
-
-# Set TOP to be the path to get from the current directory (where make was
-# invoked) to the top of the tree. $(lastword $(MAKEFILE_LIST)) returns
-# the name of this makefile relative to where make was invoked.
-THIS_MAKEFILE := $(lastword $(MAKEFILE_LIST))
-
-# strip off /tools/top.mk to get for example ../../..
-# and Set TOP to an absolute path
-TOP = $(abspath $(subst make.mk,../..,$(THIS_MAKEFILE)))
-
-# Set EXAMPLE_PATH to the relative path from TOP to the current directory, ie examples/device/cdc_msc_freertos
-EXAMPLE_PATH = $(subst $(TOP)/,,$(abspath .))
-
-# Detect whether shell style is windows or not
-# https://stackoverflow.com/questions/714100/os-detecting-makefile/52062069#52062069
-ifeq '$(findstring ;,$(PATH))' ';'
-# PATH contains semicolon - so we're definitely on Windows.
-CMDEXE := 1
-
-# makefile shell commands should use syntax for DOS CMD, not unix sh
-# Unfortunately, SHELL may point to sh or bash, which can't accept DOS syntax.
-# We can't just use sh, because while sh and/or bash shell may be available,
-# many Windows environments won't have utilities like realpath used below, so...
-# Force DOS command shell on Windows.
-SHELL := cmd.exe
-endif
-
-# Build directory
-BUILD := _build
-PROJECT := $(notdir $(CURDIR))
-
-# Handy check parameter function
-check_defined = \
-    $(strip $(foreach 1,$1, \
-    $(call __check_defined,$1,$(strip $(value 2)))))
-__check_defined = \
-    $(if $(value $1),, \
-    $(error Undefined make flag: $1$(if $2, ($2))))
-
-#-------------- Fuzz harness compiler  ------------
-
-CC ?= clang
-CXX ?= clang++
-GDB ?= gdb
-OBJCOPY = objcopy
-SIZE = size
-MKDIR = mkdir
-
-ifeq ($(CMDEXE),1)
-  CP = copy
-  RM = del
-  PYTHON = python
-else
-  SED = sed
-  CP = cp
-  RM = rm
-  PYTHON = python3
-endif
-
-#-------------- Fuzz harness flags ------------
-COVERAGE_FLAGS ?= -fsanitize-coverage=trace-pc-guard
-SANITIZER_FLAGS ?= -fsanitize=fuzzer \
-                   -fsanitize=address
-
-CFLAGS += $(COVERAGE_FLAGS) $(SANITIZER_FLAGS)
-
-#-------------- Source files and compiler flags --------------
-INC += $(TOP)/test
-
-# Compiler Flags
-CFLAGS += \
-  -ggdb \
-  -fdata-sections \
-  -ffunction-sections \
-  -fno-strict-aliasing \
-  -Wall \
-  -Wextra \
-  -Werror \
-  -Wfatal-errors \
-  -Wdouble-promotion \
-  -Wstrict-prototypes \
-  -Wstrict-overflow \
-  -Werror-implicit-function-declaration \
-  -Wfloat-equal \
-  -Wundef \
-  -Wshadow \
-  -Wwrite-strings \
-  -Wsign-compare \
-  -Wmissing-format-attribute \
-  -Wunreachable-code \
-  -Wcast-align \
-  -Wcast-qual \
-  -Wnull-dereference \
-  -Wuninitialized \
-  -Wunused \
-  -Wredundant-decls \
-  -O1
-
-CFLAGS += \
-	-Wno-error=unreachable-code \
-  -DOPT_MCU_FUZZ=1 \
-  -DCFG_TUSB_MCU=OPT_MCU_FUZZ \
-  -D_FUZZ
-
-CXXFLAGS += \
-  -xc++ \
-  -Wno-c++11-narrowing \
-  -fno-implicit-templates
-
-# conversion is too strict for most mcu driver, may be disable sign/int/arith-conversion
-#  -Wconversion
-
-# Debugging/Optimization
-ifeq ($(DEBUG), 1)
-  CFLAGS += -Og
-else
-  CFLAGS += $(CFLAGS_OPTIMIZED)
-endif
-
-# Log level is mapped to TUSB DEBUG option
-ifneq ($(LOG),)
-  CFLAGS += -DCFG_TUSB_DEBUG=$(LOG)
-endif
diff --git a/test/fuzz/rules.mk b/test/fuzz/rules.mk
deleted file mode 100644
index c143303..0000000
--- a/test/fuzz/rules.mk
+++ /dev/null
@@ -1,160 +0,0 @@
-# ---------------------------------------
-# Common make rules for all examples
-# ---------------------------------------
-
-# Set all as default goal
-.DEFAULT_GOAL := all
-
-# ---------------------------------------
-# Compiler Flags
-# ---------------------------------------
-
-LIBS_GCC ?=  -lm
-
-# libc
-LIBS += $(LIBS_GCC)
-
-ifneq ($(BOARD), spresense)
-LIBS += -lc -Wl,-Bstatic -lc++ -Wl,-Bdynamic
-endif
-
-# TinyUSB Stack source
-SRC_C += \
-	src/tusb.c \
-	src/common/tusb_fifo.c \
-	src/device/usbd.c \
-	src/class/audio/audio_device.c \
-	src/class/cdc/cdc_device.c \
-	src/class/dfu/dfu_device.c \
-	src/class/dfu/dfu_rt_device.c \
-	src/class/hid/hid_device.c \
-	src/class/midi/midi_device.c \
-	src/class/msc/msc_device.c \
-	src/class/mtp/mtp_device.c \
-	src/class/printer/printer_device.c \
-	src/class/net/ecm_rndis_device.c \
-	src/class/net/ncm_device.c \
-	src/class/usbtmc/usbtmc_device.c \
-	src/class/video/video_device.c \
-	src/class/vendor/vendor_device.c
-
-
-# Fuzzers are c++
-SRC_CXX += \
-	test/fuzz/dcd_fuzz.cc \
-	test/fuzz/fuzz.cc \
-	test/fuzz/msc_fuzz.cc \
-	test/fuzz/net_fuzz.cc \
-	test/fuzz/usbd_fuzz.cc
-
-# TinyUSB stack include
-INC += $(TOP)/src
-
-CFLAGS += $(addprefix -I,$(INC))
-CXXFLAGS += -std=c++17
-
-# LTO makes it difficult to analyze map file for optimizing size purpose
-# We will run this option in ci
-ifeq ($(NO_LTO),1)
-CFLAGS := $(filter-out -flto,$(CFLAGS))
-endif
-
-ifneq ($(LD_FILE),)
-LDFLAGS_LD_FILE ?= -Wl,-T,$(TOP)/$(LD_FILE)
-endif
-
-LDFLAGS += $(CFLAGS) $(LDFLAGS_LD_FILE) -fuse-ld=lld -Wl,-Map=$@.map -Wl,--cref -Wl,-gc-sections
-ifneq ($(SKIP_NANOLIB), 1)
-endif
-
-ASFLAGS += $(CFLAGS)
-
-# Assembly files can be name with upper case .S, convert it to .s
-SRC_S := $(SRC_S:.S=.s)
-
-# Due to GCC LTO bug https://bugs.launchpad.net/gcc-arm-embedded/+bug/1747966
-# assembly file should be placed first in linking order
-# '_asm' suffix is added to object of assembly file
-OBJ += $(addprefix $(BUILD)/obj/, $(SRC_S:.s=_asm.o))
-OBJ += $(addprefix $(BUILD)/obj/, $(SRC_C:.c=.o))
-OBJ += $(addprefix $(BUILD)/obj/, $(SRC_CXX:.cc=_cxx.o))
-
-# Verbose mode
-ifeq ("$(V)","1")
-$(info CFLAGS  $(CFLAGS) ) $(info )
-$(info LDFLAGS $(LDFLAGS)) $(info )
-$(info ASFLAGS $(ASFLAGS)) $(info )
-endif
-
-# ---------------------------------------
-# Rules
-# ---------------------------------------
-
-all: $(BUILD)/$(PROJECT)
-
-OBJ_DIRS = $(sort $(dir $(OBJ)))
-$(OBJ): | $(OBJ_DIRS)
-$(OBJ_DIRS):
-ifeq ($(CMDEXE),1)
-	@$(MKDIR) $(subst /,\,$@)
-else
-	@$(MKDIR) -p $@
-endif
-
-$(BUILD)/$(PROJECT): $(OBJ)
-	@echo LINK $@
-	@ $(CXX) -o $@  $(LIB_FUZZING_ENGINE) $^ $(LIBS) $(LDFLAGS)
-
-# We set vpath to point to the top of the tree so that the source files
-# can be located. By following this scheme, it allows a single build rule
-# to be used to compile all .c files.
-vpath %.c . $(TOP)
-$(BUILD)/obj/%.o: %.c
-	@echo CC $(notdir $@)
-	@$(CC) $(CFLAGS) -c -MD -o $@ $<
-
-# All cpp srcs
-vpath %.cc . $(TOP)
-$(BUILD)/obj/%_cxx.o: %.cc
-	@echo CXX $(notdir $@)
-	@$(CXX) $(CFLAGS) $(CXXFLAGS) -c -MD -o $@ $<
-
-# ASM sources lower case .s
-vpath %.s . $(TOP)
-$(BUILD)/obj/%_asm.o: %.s
-	@echo AS $(notdir $@)
-	@$(CC) -x assembler-with-cpp $(ASFLAGS) -c -o $@ $<
-
-# ASM sources upper case .S
-vpath %.S . $(TOP)
-$(BUILD)/obj/%_asm.o: %.S
-	@echo AS $(notdir $@)
-	@$(CC) -x assembler-with-cpp $(ASFLAGS) -c -o $@ $<
-
-.PHONY: clean
-clean:
-ifeq ($(CMDEXE),1)
-	rd /S /Q $(subst /,\,$(BUILD))
-else
-	$(RM) -rf $(BUILD)
-endif
-# ---------------- GNU Make End -----------------------
-
-# get depenecies
-.PHONY: get-deps
-get-deps:
-	$(PYTHON) $(TOP)/tools/get_deps.py $(FAMILY)
-
-size: $(BUILD)/$(PROJECT)
-	-@echo ''
-	@$(SIZE) $<
-	-@echo ''
-
-# linkermap must be install previously at https://github.com/hathach/linkermap
-linkermap: $(BUILD)/$(PROJECT)
-	@linkermap -v $<.map
-
-# Print out the value of a make variable.
-# https://stackoverflow.com/questions/16467718/how-to-print-out-a-variable-in-makefile
-print-%:
-	@echo $* = $($*)