Sync jazzer in AOSP with upstream repo (new SHA: 30decf81a147c66fa5a098072c38ab6924ba0aa6) am: 9350e0ab03 am: 99d9a79746 am: 34a8e5c8aa am: e73be1680d am: 54819157ea am: f1ff6ce482

Original change: https://android-review.googlesource.com/c/platform/external/jazzer-api/+/2627336

Change-Id: Iaaed944c1e9e457640f7055fc57e8678f90f4603
Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
diff --git a/.bazelignore b/.bazelignore
new file mode 100644
index 0000000..92d9be0
--- /dev/null
+++ b/.bazelignore
@@ -0,0 +1 @@
+examples/junit/target
diff --git a/.bazelrc b/.bazelrc
index 78b0d87..ed20aac 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -1,49 +1,94 @@
+# Allow directories as sources.
+startup --host_jvm_args=-DBAZEL_TRACK_SOURCE_DIRECTORIES=1
 build --incompatible_strict_action_env
 build --sandbox_tmpfs_path=/tmp
 build --enable_platform_specific_config
 build -c opt
+# Ensure that paths of files printed by our examples are valid.
+build --nozip_undeclared_test_outputs
 
 # C/C++
+# GCC is supported on a best-effort basis.
 common --repo_env=CC=clang
 build --incompatible_enable_cc_toolchain_resolution
+# Required by abseil-cpp.
+build --cxxopt=-std=c++14
 # Requires a relatively modern clang.
 build:ci --features=layering_check
+build:macos --apple_crosstool_top=@local_config_apple_cc//:toolchain
+build:macos --crosstool_top=@local_config_apple_cc//:toolchain
+build:macos --host_crosstool_top=@local_config_apple_cc//:toolchain
 
 # Java
+# Always build for Java 8, even with a newer JDK. This ensures that all
+# artifacts we release are compatible with Java 8 runtimes.
+# Note: We would like to use --release, but can't due to
+#  https://bugs.openjdk.org/browse/JDK-8214165.
+build --javacopt=-target --javacopt=8
 build --java_language_version=8
-build --tool_java_language_version=9
+build --tool_java_language_version=8
+# Use a hermetic JDK to compile Java code, which also means that contributors
+# don't need to install a JDK to compile Jazzer.
+build --java_runtime_version=remotejdk_17
+build --tool_java_runtime_version=remotejdk_17
+# Speed up Java compilation by removing indirect deps from the compile classpath.
+build --experimental_java_classpath=bazel
+
+# Android
+build:android_arm --incompatible_enable_android_toolchain_resolution
+build:android_arm --android_platforms=//:android_arm64
+build:android_arm --copt=-D_ANDROID
+build:android_arm --java_runtime_version=local_jdk
 
 # Windows
 # Only compiles with clang on Windows.
 build:windows --extra_toolchains=@local_config_cc//:cc-toolchain-x64_windows-clang-cl
-build:windows --extra_execution_platforms=//:x64_windows-clang-cl
+build:windows --extra_execution_platforms=//bazel/platforms:x64_windows-clang-cl
 build:windows --features=static_link_msvcrt
 # Required as PATH doubles as the shared library search path on Windows and the
 # Java agent functionality depends on system-provided shared libraries.
 test:windows --noincompatible_strict_action_env
 run:windows --noincompatible_strict_action_env
 
+# macOS
+# Workaround for https://github.com/bazelbuild/bazel/issues/13944, which breaks external Java
+# dependencies on M1 Macs without Rosetta.
+build:macos --extra_toolchains=//bazel/toolchains:java_non_prebuilt_definition
+
 # Toolchain
 # Since the toolchain is conditional on OS and architecture, set it on the particular GitHub Action.
-build:toolchain --repo_env=BAZEL_DO_NOT_DETECT_CPP_TOOLCHAIN=1
-build:toolchain --//third_party:toolchain
+common:toolchain --repo_env=BAZEL_DO_NOT_DETECT_CPP_TOOLCHAIN=1
 
 # Forward debug variables to tests
-test --test_env=JAZZER_AUTOFUZZ_DEBUG
-test --test_env=JAZZER_REFLECTION_DEBUG
+build --test_env=JAZZER_AUTOFUZZ_DEBUG
+build --test_env=JAZZER_REFLECTION_DEBUG
+
+# Interactively debug Jazzer integration tests by passing --config=debug and attaching to port 5005.
+# This is different from --java_debug: It affects the actual inner Jazzer process rather than the
+# outer FuzzTargetTestWrapper.
+test:debug --test_env=JAZZER_DEBUG=1
+test:debug --test_output=streamed
+test:debug --test_strategy=exclusive
+test:debug --test_timeout=9999
+test:debug --nocache_test_results
 
 # CI tests (not using the toolchain to test OSS-Fuzz & local compatibility)
-test:ci --test_env=JAZZER_CI=1
 build:ci --bes_results_url=https://app.buildbuddy.io/invocation/
 build:ci --bes_backend=grpcs://remote.buildbuddy.io
 build:ci --remote_cache=grpcs://remote.buildbuddy.io
 build:ci --remote_timeout=3600
+# Fail if Bazel can't find Xcode. This improves error messages as the fallback toolchain will only
+# fail when requested to cross-compile.
+build:ci --repo_env=BAZEL_USE_XCODE_TOOLCHAIN=1
+# Suggested by BuildBuddy
+build:ci --noslim_profile
+build:ci --experimental_profile_include_target_label
+build:ci --experimental_profile_include_primary_output
+build:ci --nolegacy_important_outputs
 
-# Maven publishing (local only, requires GPG signature)
-build:maven --config=toolchain
-build:maven --stamp
-build:maven --define "maven_repo=https://oss.sonatype.org/service/local/staging/deploy/maven2"
-build:maven --java_runtime_version=local_jdk_8
+# Docker images
+common:docker --config=toolchain
+common:docker --extra_toolchains=@llvm_toolchain//:cc-toolchain-x86_64-linux
 
 # Generic coverage configuration taken from https://github.com/fmeum/rules_jni
 coverage --combined_report=lcov
@@ -54,5 +99,10 @@
 coverage --repo_env=GCOV=llvm-profdata
 
 # Instrument all source files of non-test targets matching at least one of these regexes.
-coverage --instrumentation_filter=^//agent/src/main[:/],^//driver:,^//sanitizers/src/main[:/]
+coverage --instrumentation_filter=^//src/main[:/],^//sanitizers/src/main[:/]
 coverage --test_tag_filters=-no-coverage
+
+# Hide all non-structured output in scripts.
+# https://github.com/bazelbuild/bazel/issues/4867#issuecomment-830402410
+common:quiet --ui_event_filters=-info,-stderr
+common:quiet --noshow_progress
diff --git a/.bazelversion b/.bazelversion
index bddfde6..19997e0 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-5.3.0rc1
+0573eee38d7d3b695267dfd125ab8e08d83a2640
diff --git a/.clang-format b/.clang-format
index bdbc38d..fde9e5a 100644
--- a/.clang-format
+++ b/.clang-format
@@ -4,4 +4,5 @@
 ---
 Language: Java
 BasedOnStyle: Google
+AllowShortFunctionsOnASingleLine: Empty
 ...
diff --git a/.github/BUILD.bazel b/.github/BUILD.bazel
index ee17ccc..66897c4 100644
--- a/.github/BUILD.bazel
+++ b/.github/BUILD.bazel
@@ -1,26 +1,26 @@
-# Extracted on 2022-01-05 as described in
+# Extracted on 2022-12-22 as described in
 # https://www.smileykeith.com/2021/03/08/locking-xcode-in-bazel/
 
 package(default_visibility = ["//visibility:public"])
 
 xcode_version(
-    name = "version13_1_0_13A1030d",
+    name = "version14_2_0_14C18",
     aliases = [
-        "13.1.0",
-        "13.1",
-        "13.1.0.13A1030d",
+        "14.2.0.14C18",
+        "14.2.0",
+        "14C18",
+        "14.2",
+        "14",
     ],
-    default_ios_sdk_version = "15.0",
-    default_macos_sdk_version = "12.0",
-    default_tvos_sdk_version = "15.0",
-    default_watchos_sdk_version = "8.0",
-    version = "13.1.0.13A1030d",
+    default_ios_sdk_version = "16.2",
+    default_macos_sdk_version = "13.1",
+    default_tvos_sdk_version = "16.1",
+    default_watchos_sdk_version = "9.1",
+    version = "14.2.0.14C18",
 )
 
 xcode_config(
     name = "host_xcodes",
-    default = ":version13_1_0_13A1030d",
-    versions = [
-        ":version13_1_0_13A1030d",
-    ],
+    default = ":version14_2_0_14C18",
+    versions = [":version14_2_0_14C18"],
 )
diff --git a/.github/workflows/check-formatting.yml b/.github/workflows/check-formatting.yml
index f410ef4..a1257c6 100644
--- a/.github/workflows/check-formatting.yml
+++ b/.github/workflows/check-formatting.yml
@@ -6,6 +6,7 @@
     branches: [ main ]
   pull_request:
     branches: [ main ]
+  merge_group:
 
   workflow_dispatch:
 
@@ -14,27 +15,13 @@
     runs-on: ubuntu-20.04
 
     steps:
-      - uses: actions/checkout@v2
-
-      - name: Setup Go environment
-        uses: actions/setup-go@v2
-        with:
-          go-version: '^1.15.5'
-
-      - name: Install formatters
-        run: |
-          wget -O - https://apt.llvm.org/llvm-snapshot.gpg.key | sudo apt-key add -
-          sudo apt-get install software-properties-common
-          sudo add-apt-repository 'deb http://apt.llvm.org/focal/ llvm-toolchain-focal-13 main'
-          sudo apt-get install clang-format-13
-          curl -sSLO https://github.com/pinterest/ktlint/releases/download/0.42.1/ktlint && chmod a+x ktlint && sudo mv ktlint /usr/bin/ktlint
-          go install github.com/google/addlicense@latest
-          go install github.com/bazelbuild/buildtools/buildifier@latest
+      - uses: actions/checkout@v3
 
       - name: Run format.sh and print changes
+        env:
+          CI: 1
         run: |
           ./format.sh
-          clang-format --version
           git diff
 
       - name: Check for changes
diff --git a/.github/workflows/oss-fuzz.yml b/.github/workflows/oss-fuzz.yml
deleted file mode 100644
index 2c6bbf5..0000000
--- a/.github/workflows/oss-fuzz.yml
+++ /dev/null
@@ -1,30 +0,0 @@
-name: OSS-Fuzz build
-
-on:
-  push:
-    branches: [ main ]
-  pull_request:
-    branches: [ main ]
-
-  workflow_dispatch:
-
-jobs:
-
-  oss_fuzz:
-    runs-on: ubuntu-20.04
-    container: gcr.io/oss-fuzz-base/base-builder-jvm
-
-    steps:
-      - name: Adding github workspace as safe directory
-        # See issue https://github.com/actions/checkout/issues/760
-        run: git config --global --add safe.directory $GITHUB_WORKSPACE
-
-      - uses: actions/checkout@v2
-
-      - name: Build Jazzer
-        # Keep in sync with https://github.com/google/oss-fuzz/blob/221b39181a372ff16c0c813c5963a08aa58f19e2/infra/base-images/base-builder/install_java.sh#L33.
-        run: bazel build --java_runtime_version=local_jdk_15 -c opt --cxxopt="-stdlib=libc++" --linkopt=-lc++ //agent:jazzer_agent_deploy.jar //driver:jazzer_driver //driver:jazzer_driver_asan //driver:jazzer_driver_ubsan //agent:jazzer_api_deploy.jar
-
-      - name: Test Jazzer build
-        # Keep in sync with https://github.com/google/oss-fuzz/blob/221b39181a372ff16c0c813c5963a08aa58f19e2/infra/base-images/base-builder/install_java.sh#L35-L36.
-        run: "test -f bazel-bin/agent/jazzer_agent_deploy.jar && test -f bazel-bin/driver/jazzer_driver && test -f bazel-bin/driver/jazzer_driver_asan && test -f bazel-bin/driver/jazzer_driver_ubsan && test -f bazel-bin/agent/jazzer_api_deploy.jar"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2cbfbaa..fb25268 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -8,82 +8,79 @@
     runs-on: ${{ matrix.os }}
     strategy:
       matrix:
-        # Keep arch names in sync with replayer download and merge
-        os: [ubuntu-latest, macos-10.15, windows-2019]
         include:
-          - os: ubuntu-latest
-            arch: "linux"
-            bazel_args: "--config=toolchain --extra_toolchains=@llvm_toolchain//:cc-toolchain-x86_64-linux"
-          - os: macos-10.15
-            arch: "macos-x86_64"
-            bazel_args: "--config=toolchain --extra_toolchains=@llvm_toolchain//:cc-toolchain-x86_64-darwin --xcode_version_config=//.github:host_xcodes"
+          - os: ubuntu-20.04
+            name: linux
+          - os: macos-11
+            name: macos
           - os: windows-2019
-            arch: "windows"
-            bazel_args: ""
+            name: windows
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
 
       - name: Set up JDK
-        uses: actions/setup-java@v1
+        uses: actions/setup-java@v3
         with:
+          distribution: zulu
           java-version: 8
 
       - name: Set Build Buddy config
-        run: .github/scripts/echoBuildBuddyConfig.sh ${{ secrets.BUILDBUDDY_API_KEY }} >> $GITHUB_ENV
         shell: bash
+        run: .github/scripts/echoBuildBuddyConfig.sh ${{ secrets.BUILDBUDDY_API_KEY }} >> $GITHUB_ENV
+
+      - name: Append build settings to .bazelrc
+        shell: bash
+        run: |
+          echo "build --announce_rc" >> .bazelrc
+          echo "build:linux --config=toolchain" >> .bazelrc
+          echo "build:linux --extra_toolchains=@llvm_toolchain//:cc-toolchain-x86_64-linux" >> .bazelrc
 
       - name: Build
+        shell: bash
+        # Double forward slashes are converted to single ones by Git Bash on Windows, so we use working directory
+        # relative labels instead.
         run: |
-          bazelisk build ${{env.BUILD_BUDDY_CONFIG}} --java_runtime_version=local_jdk_8 ${{ matrix.bazel_args }} //agent/src/main/java/com/code_intelligence/jazzer/replay:Replayer_deploy.jar //:jazzer_release
-          cp -L bazel-bin/agent/src/main/java/com/code_intelligence/jazzer/replay/Replayer_deploy.jar replayer.jar
-          cp -L bazel-bin/jazzer_release.tar.gz release-${{ matrix.arch }}.tar.gz
+          bazelisk build ${{env.BUILD_BUDDY_CONFIG}} deploy:jazzer :jazzer_release
+          cp -L $(bazel cquery --output=files deploy:jazzer) jazzer-${{ matrix.name }}.jar
+          cp -L $(bazel cquery --output=files :jazzer_release) jazzer-${{ matrix.name }}.tar.gz
 
-      - name: Upload replayer
-        uses: actions/upload-artifact@v2
+      - name: Upload jazzer.jar
+        uses: actions/upload-artifact@v3
         with:
-          name: replayer_${{ matrix.arch }}
-          path: replayer.jar
+          name: jazzer_tmp
+          path: jazzer-${{ matrix.name }}.jar
+          if-no-files-found: error
 
-      - name: Upload release tar
-        uses: actions/upload-artifact@v2
+      - name: Upload release archive
+        uses: actions/upload-artifact@v3
         with:
           name: jazzer_releases
-          path: release-${{ matrix.arch}}.tar.gz
+          path: jazzer-${{ matrix.name }}.tar.gz
+          if-no-files-found: error
 
-  merge_replayer_jars:
+  merge_jars:
     runs-on: ubuntu-latest
     needs: build_release
 
     steps:
-      - name: Download macOS jar
-        uses: actions/download-artifact@v2
-        with:
-          name: replayer_macos-x86_64
-          path: replayer_macos-x86_64
+      - uses: actions/checkout@v3
 
-      - name: Download Linux jar
-        uses: actions/download-artifact@v2
+      - name: Download individual jars
+        uses: actions/download-artifact@v3
         with:
-          name: replayer_linux
-          path: replayer_linux
-
-      - name: Download Windows jar
-        uses: actions/download-artifact@v2
-        with:
-          name: replayer_windows
-          path: replayer_windows
+          name: jazzer_tmp
+          path: _tmp/
 
       - name: Merge jars
         run: |
-          mkdir merged
-          unzip -o replayer_macos-x86_64/replayer.jar -d merged
-          unzip -o replayer_linux/replayer.jar -d merged
-          unzip -o replayer_windows/replayer.jar -d merged
-          jar cvmf merged/META-INF/MANIFEST.MF replayer.jar -C merged .
+          bazel run @rules_jvm_external//private/tools/java/com/github/bazelbuild/rules_jvm_external/jar:MergeJars -- \
+            --output "$(pwd)"/_tmp/jazzer.jar \
+            $(find "$(pwd)/_tmp/" -name '*.jar' -printf "--sources %h/%f ")
 
       - name: Upload merged jar
-        uses: actions/upload-artifact@v2
+        uses: actions/upload-artifact@v3
         with:
-          name: replayer
-          path: replayer.jar
+          name: jazzer
+          path: _tmp/jazzer.jar
+          if-no-files-found: error
diff --git a/.github/workflows/run-all-tests.yml b/.github/workflows/run-all-tests.yml
index 35334b8..9728378 100644
--- a/.github/workflows/run-all-tests.yml
+++ b/.github/workflows/run-all-tests.yml
@@ -5,40 +5,72 @@
     branches: [ main ]
   pull_request:
     branches: [ main ]
+  merge_group:
 
   workflow_dispatch:
 
 jobs:
 
+  test_junit_springboot:
+    runs-on: ubuntu-20.04
+    steps:
+      - uses: actions/checkout@v3
+      - name: Set up JDK
+        uses: actions/setup-java@v3
+        with:
+          distribution: zulu
+          java-version: 17
+      - name: Build and run tests
+        # The Spring Boot example project is built with Maven. The shell script builds the project
+        # against the local version of Jazzer and runs its unit and fuzz tests.
+        # Spring version 6 requires JDK 17.
+        run: |
+          cd examples/junit-spring-web
+          ./build-and-run-tests.sh
+        shell: bash
+
   build_and_test:
     runs-on: ${{ matrix.os }}
     strategy:
       matrix:
-        os: [ubuntu-latest, macos-11, windows-latest]
+        os: [ubuntu-20.04, macos-12, windows-2019]
         jdk: [8, 17]
         include:
-          - os: ubuntu-latest
+          - os: ubuntu-20.04
             arch: "linux"
             cache: "/home/runner/.cache/bazel-disk"
-          - os: macos-11
+            bazel_args: "//launcher/android:jazzer_android"
+          - os: ubuntu-20.04
+            jdk: 20
+            # Workaround for https://github.com/bazelbuild/bazel/issues/14502
+            bazel_args: "--jvmopt=-Djava.security.manager=allow"
+            arch: "linux"
+            cache: "/home/runner/.cache/bazel-disk"
+          - os: macos-12
+            bazel_args: "--xcode_version_config=//.github:host_xcodes"
             arch: "macos-x86_64"
-            # Always use the toolchain as UBSan produces linker errors with Apple LLVM 13.
-            bazel_args: "--config=toolchain --extra_toolchains=@llvm_toolchain//:cc-toolchain-x86_64-darwin --xcode_version_config=//.github:host_xcodes"
             cache: "/private/var/tmp/bazel-disk"
-          - os: windows-latest
+          - os: windows-2019
             arch: "windows"
             cache: "%HOME%/bazel-disk"
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
 
       - name: Set up JDK
-        uses: actions/setup-java@v1
+        uses: actions/setup-java@v3
         with:
+          distribution: zulu
           java-version: ${{ matrix.jdk }}
 
+      # The java binary has the necessary entitlements to allow tests to pass, but that requires
+      # user interaction (clicking through Gatekeeper warnings) that we can't simulate in CI.
+      - name: Remove codesign signature on java binary
+        if: contains(matrix.os, 'mac')
+        run: codesign --remove-signature "$JAVA_HOME"/bin/java
+
       - name: Mount Bazel disk cache
-        uses: actions/cache@v2
+        uses: actions/cache@v3
         with:
           path: ${{ matrix.cache }}
           key: bazel-disk-cache-${{ matrix.arch }}-${{ matrix.jdk }}
@@ -47,16 +79,20 @@
         run: .github/scripts/echoBuildBuddyConfig.sh ${{ secrets.BUILDBUDDY_API_KEY }} >> $GITHUB_ENV
         shell: bash
 
-      - name: Build
-        run: bazelisk build ${{env.BUILD_BUDDY_CONFIG}} --java_runtime_version=local_jdk_${{ matrix.jdk }} --disk_cache=${{ matrix.cache }} ${{ matrix.bazel_args }} //...
+      - name: Build & Test
+        run: bazelisk test ${{env.BUILD_BUDDY_CONFIG}} --java_runtime_version=local_jdk_${{ matrix.jdk }} --disk_cache=${{ matrix.cache }} ${{ matrix.bazel_args }} --build_tag_filters="-no-${{ matrix.arch }}-jdk${{ matrix.jdk }},-no-jdk${{ matrix.jdk }}" --test_tag_filters="-no-${{ matrix.arch }}-jdk${{ matrix.jdk }},-no-jdk${{ matrix.jdk }}" //...
 
-      - name: Test
-        run: bazelisk test ${{env.BUILD_BUDDY_CONFIG}} --java_runtime_version=local_jdk_${{ matrix.jdk }} --disk_cache=${{ matrix.cache }} ${{ matrix.bazel_args }} //...
+      - name: Copy Bazel log
+        if: always()
+        shell: bash
+        run: cp "$(readlink bazel-out)"/../../../java.log* .
 
       - name: Upload test logs
         if: always()
-        uses: actions/upload-artifact@v2
+        uses: actions/upload-artifact@v3
         with:
           name: testlogs-${{ matrix.arch }}-${{ matrix.jdk }}
           # https://github.com/actions/upload-artifact/issues/92#issuecomment-711107236
-          path: bazel-testlogs*/**/test.log
+          path: |
+            bazel-testlogs*/**/test.log
+            java.log*
diff --git a/Android.bp b/Android.bp
index 1f5b45b..d8b0b7d 100644
--- a/Android.bp
+++ b/Android.bp
@@ -49,7 +49,7 @@
     name: "jazzer",
     host_supported: true,
     srcs: [
-        "agent/src/main/java/com/code_intelligence/jazzer/api/*.java",
+        "src/main/java/com/code_intelligence/jazzer/api/*.java",
     ],
     visibility: ["//visibility:public"],
 }
diff --git a/BUILD.bazel b/BUILD.bazel
index 5afcd03..9084574 100644
--- a/BUILD.bazel
+++ b/BUILD.bazel
@@ -1,82 +1,81 @@
-load("@bazel_tools//tools/build_defs/pkg:pkg.bzl", "pkg_tar")
-load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "define_kt_toolchain")
-load("@io_bazel_rules_kotlin//kotlin/internal:opts.bzl", "kt_javac_options", "kt_kotlinc_options")
+load("@buildifier_prebuilt//:rules.bzl", "buildifier", "buildifier_test")
+load("@rules_pkg//:pkg.bzl", "pkg_tar")
+load("//bazel:compat.bzl", "SKIP_ON_WINDOWS")
 
 exports_files(["LICENSE"])
 
-kt_kotlinc_options(
-    name = "kotlinc_options",
-)
-
-kt_javac_options(
-    name = "default_javac_options",
-)
-
-define_kt_toolchain(
-    name = "kotlin_toolchain",
-    api_version = "1.5",
-    javac_options = ":default_javac_options",
-    jvm_target = "1.8",
-    kotlinc_options = ":kotlinc_options",
-    language_version = "1.5",
-)
-
 pkg_tar(
     name = "jazzer_release",
     srcs = [
-        "//agent:jazzer_agent_deploy",
-        "//agent:jazzer_api_deploy.jar",
-        "//driver:jazzer_driver",
+        "//launcher:jazzer",
+        "//src/main/java/com/code_intelligence/jazzer:jazzer_standalone_deploy.jar",
     ],
     extension = "tar.gz",
     mode = "0777",
     remap_paths = {
-        "agent/jazzer_agent_deploy.jar": "jazzer_agent_deploy.jar",
-        "agent/jazzer_api_deploy.jar": "jazzer_api_deploy.jar",
-        "driver/jazzer_driver": "jazzer",
-    },
-    strip_prefix = "./",
+        "src/main/java/com/code_intelligence/jazzer/jazzer_standalone_deploy.jar": "jazzer_standalone.jar",
+    } | select({
+        "@platforms//os:windows": {"launcher/jazzer": "jazzer.exe"},
+        "//conditions:default": {"launcher/jazzer": "jazzer"},
+    }),
+    strip_prefix = select({
+        "@platforms//os:windows": ".\\",
+        "//conditions:default": "./",
+    }),
+    visibility = ["//tests:__pkg__"],
 )
 
 alias(
     name = "jazzer",
-    actual = "//driver:jazzer_driver",
+    actual = "//launcher:jazzer",
 )
 
 alias(
-    name = "jazzer_asan",
-    actual = "//driver:jazzer_driver_asan",
-)
-
-alias(
-    name = "jazzer_ubsan",
-    actual = "//driver:jazzer_driver_ubsan",
-)
-
-exports_files([
-    "jazzer-api.pom",
-])
-
-config_setting(
-    name = "clang",
-    flag_values = {"@bazel_tools//tools/cpp:compiler": "clang"},
-    visibility = ["//visibility:public"],
-)
-
-alias(
-    name = "clang_on_linux",
+    name = "addlicense",
     actual = select({
-        ":clang": "@platforms//os:linux",
-        "//conditions:default": ":clang",
+        "@platforms//os:macos": "@addlicense-darwin-universal//file:addlicense",
+        "@platforms//os:linux": "@addlicense-linux-amd64//file:addlicense",
     }),
-    visibility = ["//visibility:public"],
+    tags = ["manual"],
+)
+
+BUILDIFIER_EXCLUDE_PATTERNS = [
+    "./.git/*",
+    "./.ijwb/*",
+    "./.clwb/*",
+]
+
+buildifier(
+    name = "buildifier",
+    diff_command = "diff -u",
+    exclude_patterns = BUILDIFIER_EXCLUDE_PATTERNS,
+    mode = "fix",
+    tags = ["manual"],
+)
+
+buildifier_test(
+    name = "buildifier_test",
+    diff_command = "diff -u",
+    exclude_patterns = BUILDIFIER_EXCLUDE_PATTERNS,
+    no_sandbox = True,
+    target_compatible_with = SKIP_ON_WINDOWS,
+    workspace = "//:WORKSPACE.bazel",
+)
+
+alias(
+    name = "clang-format",
+    actual = select({
+        "@platforms//os:macos": "@clang-format-15-darwin-x64//file:clang-format",
+        "@platforms//os:linux": "@clang-format-15-linux-x64//file:clang-format",
+    }),
+    tags = ["manual"],
 )
 
 platform(
-    name = "x64_windows-clang-cl",
+    name = "android_arm64",
     constraint_values = [
-        "@platforms//cpu:x86_64",
-        "@platforms//os:windows",
-        "@bazel_tools//tools/cpp:clang-cl",
+        "@platforms//cpu:arm64",
+        "@platforms//os:android",
     ],
+    visibility = ["//:__subpackages__"],
 )
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0898a24..8e1c2f6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,13 +2,29 @@
 
 **Note:** Before version 1.0.0, every release may contain breaking changes.
 
+## Version 0.12.0
+
+* **Breaking change**: Autofuzz API methods (`consume` and `autofuzz`) have moved from the
+  `Jazzer` class to the dedicated `Autofuzz` class
+* **Major feature**: Added JUnit 5 integration for fuzzing and regression tests using the
+  `@FuzzTest` annotation (available as `com.code-intelligence:jazzer-junit`)
+* Feature: Added sanitizer for SQL injections
+* Feature: Hooks can be selectively disabled by specifying their full class name using the new
+  `--disabled_hooks` flag
+* Fix: Remove memory leaks in native code
+* Fix: Don't instrument internal Azul JDK classes
+* Fix: Classes with local variable annotations are now instrumented without errors
+
+This release also includes smaller improvements and bugfixes, as well as a major refactoring and
+Java rewrite of native components.
+
 ## Version 0.11.0
 
 * Feature: Add sanitizer for context lookups
 * Feature: Add sanitizer for OS command injection
 * Feature: Add sanitizer for regex injection
 * Feature: Add sanitizer for LDAP injections
-* Feature: Add sanitizer for arbitrary class loading 
+* Feature: Add sanitizer for arbitrary class loading
 * Feature: Guide fuzzer to generate proper map lookups keys
 * Feature: Generate standalone Java reproducers for autofuzz
 * Feature: Hooks targeting interfaces and abstract classes hook all implementations
@@ -28,7 +44,7 @@
 ## Version 0.10.0
 
 * **Breaking change**: Use OS-specific classpath separator to split jvm_args
-* Feature: Add support to "autofuzz" targets without the need to manually write fuzz targets 
+* Feature: Add support to "autofuzz" targets without the need to manually write fuzz targets
 * Feature: Add macOS and Windows support
 * Feature: Add option to generate coverage report
 * Feature: Support multiple hook annotations per hook method
@@ -46,7 +62,7 @@
 * Fixed: Make initialized `this` object available to `<init>` AFTER hooks
 * Fixed: Allow instrumented classes loaded by custom class loaders to find Jazzer internals
 
-This release also includes smaller improvements and bugfixes. 
+This release also includes smaller improvements and bugfixes.
 
 ## Version 0.9.1
 
@@ -56,7 +72,7 @@
 * Feature: `assert` can be used in fuzz targets
 * Feature: Coverage is now collision-free and more fine-grained (based on [JaCoCo](https://www.eclemma.org/jacoco/))
 * API: Added `pickValue(Collection c)` and `consumeChar(char min, char max)` to `FuzzedDataProvider`
-* API: Added `FuzzerSecurityIssue*` exceptions to allow specifiying the severity of findings
+* API: Added `FuzzerSecurityIssue*` exceptions to allow specifying the severity of findings
 
 ## Version 0.9.0
 
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..bc2d174
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,94 @@
+## Building Jazzer from source
+
+### Dependencies
+
+Jazzer has the following dependencies when being built from source:
+
+* [Bazelisk](https://github.com/bazelbuild/bazelisk) or the version of Bazel specified in [`.bazelversion`](.bazelversion)
+* One of the following C++ compilers:
+  * [Clang](https://clang.llvm.org/) 9.0+ (clang-cl on Windows)
+  * Xcode (Xcode.app is required, not just the developer tools)
+  * GCC (should work with `--repo_env=CC=gcc`, but is not tested)
+
+It is recommended to use [Bazelisk](https://github.com/bazelbuild/bazelisk) to automatically download and install Bazel.
+Simply download the release binary for your OS and architecture and ensure that it is available in the `PATH`.
+The instructions below will assume that this binary is called `bazel` - Bazelisk is a thin wrapper around the actual Bazel binary and can be used interchangeably.
+
+### Compiling
+
+Assuming the dependencies are installed, build Jazzer from source and run it as follows:
+
+```bash
+$ git clone https://github.com/CodeIntelligenceTesting/jazzer
+$ cd jazzer
+# Note the double dash used to pass <arguments> to Jazzer rather than Bazel.
+$ bazel run //:jazzer -- <arguments>
+```
+
+You can also build your own version of the release binaries:
+
+```bash
+$ bazel build //:jazzer_release
+...
+INFO: Found 1 target...
+Target //:jazzer_release up-to-date:
+  bazel-bin/jazzer_release.tar.gz
+...
+```
+
+### Running the tests
+
+To run the tests, execute the following command:
+
+```bash
+$ bazel test //...
+```
+
+#### Debugging
+
+If you need to debug an issue that can only be reproduced by an integration test (`java_fuzz_target_test`), you can start Jazzer in debug mode via `--config=debug`.
+The JVM running Jazzer will suspend until a debugger connects on port 5005 (or the port specified via `DEFAULT_JVM_DEBUG_PORT`).
+
+### Formatting
+
+Run `./format.sh` to format all source files in the way enforced by the "Check formatting" CI job.
+
+## Releasing (CI employees only)
+
+Requires an account on [Sonatype](https://issues.sonatype.org) with access to the `com.code-intelligence` group as well as a YubiKey with the signing key.
+
+### One-time setup
+
+1. Install GPG prerequisites via `sudo apt-get install gnupg2 gnupg-agent scdaemon pcscd`.
+2. Execute `mkdir -p ~/.gnupg && echo use-agent >> ~/.gnupg/gpg.conf` to enable GPG's smart card support.
+3. Execute `cat deploy/maven.pub | gpg --import` to import the public key used for Maven signatures
+4. Plug in the YubiKey and execute `gpg --card-status` to generate a key stub.
+   If you see a `No such device` error, retry after executing `killall gpg-agent; killall pcscd` to remove existing locks on the YubiKey.
+
+### Per release
+
+1. Update `JAZZER_VERSION` in [`maven.bzl`](maven.bzl).
+2. Create a release, using the auto-generated changelog as a base for the release notes.
+3. Trigger the "Release" GitHub Actions workflow for the tag.
+   This builds release archives for GitHub as well as the multi-architecture jar for the `com.code-intelligence:jazzer` Maven artifact.
+4. Create a GitHub release and upload the contents of the `jazzer_releases` artifact from the workflow run.
+5. Check out the tag locally and, with the YubiKey plugged in, run `bazel run //deploy` with the following environment variables to upload the Maven artifacts:
+    * `JAZZER_JAR_PATH`: local path of the multi-architecture `jazzer.jar` contained in the `jazzer` artifact of the "Release" workflow
+    * `MAVEN_USER`: username on https://oss.sonatype.org
+    * `MAVEN_PASSWORD`: password on https://oss.sonatype.org
+
+   The YubiKey blinks repeatedly and needs a touch to confirm each individual signature.
+6. Log into https://oss.sonatype.org, select both staging repositories and "Close" them.
+   Wait and refresh, then select them again and "Release" them.
+7. Locally, with Docker credentials available, run `docker/push_all.sh` to build and push the `cifuzz/jazzer` and `cifuzz/jazzer-autofuzz` Docker images.
+
+### Updating the hosted javadocs
+
+Javadocs are hosted at https://codeintelligencetesting.github.io/jazzer-docs, which is populated from https://github.com/CodeIntelligenceTesting/jazzer-docs.
+
+To update the docs after a release with API changes, follow these steps to get properly linked cross-references:
+
+1. Delete the contents of the `jazzer-api` subdirectory of `jazzer-docs`.
+2. Run `bazel build --//deploy:linked_javadoc //deploy:jazzer-api-docs` and unpack the jar into the `jazzer-api` subdirectory of `jazzer-docs`.
+3. Commit and push the changes, then wait for them to be published (can take a minute).
+4. Repeat the same steps with `jazzer-api` replaced by `jazzer` and then by `jazzer-junit`.
diff --git a/METADATA b/METADATA
index ae15e36..d99af95 100644
--- a/METADATA
+++ b/METADATA
@@ -6,13 +6,13 @@
 third_party {
   url {
     type: HOMEPAGE
-    value: "https://github.com/CodeIntelligenceTesting/jazzer"
+    value: "https://www.code-intelligence.com/"
   }
   url {
     type: GIT
     value: "https://github.com/CodeIntelligenceTesting/jazzer"
   }
-  version: "327677ad48bdd3e87794a1fe3c2becc13288e4b7"
-  last_upgrade_date { year: 2022 month: 9 day: 15 }
+  version: "30decf81a147c66fa5a098072c38ab6924ba0aa6"
+  last_upgrade_date { year: 2023 month: 6 day: 14 }
   license_type: NOTICE
 }
diff --git a/OWNERS b/OWNERS
index 2da7a0b..e90c1f4 100644
--- a/OWNERS
+++ b/OWNERS
@@ -1,4 +1,3 @@
-mhahmad@google.com
 hamzeh@google.com
 kalder@google.com
 cobark@google.com
diff --git a/README.md b/README.md
index 12137ae..e89b8b6 100644
--- a/README.md
+++ b/README.md
@@ -1,535 +1,147 @@
-<img src="https://www.code-intelligence.com/hubfs/Logos/CI%20Logos/Jazzer_einfach.png" height=150px alt="Jazzer logo">
+<div align="center">
+  <a href="https://code-intelligence.com"><img src="https://www.code-intelligence.com/hubfs/Logos/CI%20Logos/Jazzer_einfach.png" height=150px alt="Jazzer by Code Intelligence">
+</a>
+  <h1>Jazzer</h1>
+  <p>Fuzz Testing for the JVM</p>
+  <a href="https://github.com/CodeIntelligenceTesting/jazzer/releases">
+    <img src="https://img.shields.io/github/v/release/CodeIntelligenceTesting/jazzer" alt="Releases">
+  </a>
+  <a href="https://search.maven.org/search?q=g:com.code-intelligence%20a:jazzer">
+    <img src="https://img.shields.io/maven-central/v/com.code-intelligence/jazzer" alt="Maven Central">
+  </a>
+  <a href="https://github.com/CodeIntelligenceTesting/jazzer/actions/workflows/run-all-tests.yml?query=branch%3Amain">
+    <img src="https://img.shields.io/github/actions/workflow/status/CodeIntelligenceTesting/jazzer/run-all-tests.yml?branch=main&logo=github" alt="CI status">
+  </a>
+  <a href="https://github.com/CodeIntelligenceTesting/jazzer/blob/main/LICENSE">
+    <img src="https://img.shields.io/github/license/CodeIntelligenceTesting/jazzer" alt="License">
+  </a>
+  <a href="https://github.com/CodeIntelligenceTesting/jazzer/blob/main/CONTRIBUTING.md">
+    <img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs welcome" />
+  </a>
 
+  <br />
 
-# Jazzer
-[![Maven Central](https://img.shields.io/maven-central/v/com.code-intelligence/jazzer-api)](https://search.maven.org/search?q=g:com.code-intelligence%20a:jazzer-api)
-![GitHub Actions](https://github.com/CodeIntelligenceTesting/jazzer/workflows/Build%20all%20targets%20and%20run%20all%20tests/badge.svg)
-[![Fuzzing Status](https://oss-fuzz-build-logs.storage.googleapis.com/badges/java-example.svg)](https://bugs.chromium.org/p/oss-fuzz/issues/list?sort=-opened&can=1&q=proj:java-example)
+<a href="https://www.code-intelligence.com/" target="_blank">Website</a>
+|
+<a href="https://www.code-intelligence.com/blog" target="_blank">Blog</a>
+|
+<a href="https://twitter.com/CI_Fuzz" target="_blank">Twitter</a>
+</div>
 
 Jazzer is a coverage-guided, in-process fuzzer for the JVM platform developed by [Code Intelligence](https://code-intelligence.com).
 It is based on [libFuzzer](https://llvm.org/docs/LibFuzzer.html) and brings many of its instrumentation-powered mutation features to the JVM.
 
-The JVM bytecode is executed inside the fuzzer process, which ensures fast execution speeds and allows seamless fuzzing of
-native libraries.
-
 Jazzer currently supports the following platforms:
 * Linux x86_64
-* macOS 10.15+ x86_64 (experimental support for arm64)
+* macOS 12+ x86_64 & arm64
 * Windows x86_64
 
-## News: Jazzer available in OSS-Fuzz
+## Quick start
 
-[Code Intelligence](https://code-intelligence.com) and Google have teamed up to bring support for Java, Kotlin, and other JVM-based languages to [OSS-Fuzz](https://github.com/google/oss-fuzz), Google's project for large-scale fuzzing of open-souce software. Read [the blogpost](https://security.googleblog.com/2021/03/fuzzing-java-in-oss-fuzz.html) over at the Google Security Blog.
+You can use Docker to try out Jazzer's Autofuzz mode, in which it automatically generates arguments to a given Java function and reports unexpected exceptions and detected security issues:
 
-If you want to learn more about Jazzer and OSS-Fuzz, [watch the FuzzCon 2020 talk](https://www.youtube.com/watch?v=SmH3Ys_k8vA&list=PLI0R_0_8-TV55gJU-UXrOzZoPbVOj1CW6&index=3) by [Abhishek Arya](https://twitter.com/infernosec) and [Fabian Meumertzheim](https://twitter.com/fhenneke).
+```
+docker run -it cifuzz/jazzer-autofuzz \
+   com.mikesamuel:json-sanitizer:1.2.0 \
+   com.google.json.JsonSanitizer::sanitize \
+   --autofuzz_ignore=java.lang.ArrayIndexOutOfBoundsException
+```
 
-## Getting Jazzer
+Here, the first two arguments are the Maven coordinates of the Java library and the fully qualified name of the Java function to be fuzzed in "method reference" form.
+The optional `--autofuzz_ignore` flag takes a list of uncaught exception classes to ignore.
 
-### Using Docker
+After a few seconds, Jazzer should trigger an `AssertionError`, reproducing a bug it found in this library that has since been fixed.
 
-The "distroless" Docker image [cifuzz/jazzer](https://hub.docker.com/r/cifuzz/jazzer) includes Jazzer together with OpenJDK 11. Just mount a directory containing your compiled fuzz target into the container under `/fuzzing` by running:
+## Using Jazzer via...
+
+### JUnit 5
+
+The following steps assume that JUnit 5 is set up for your project, for example based on the official [junit5-samples](https://github.com/junit-team/junit5-samples).
+
+1. Add a dependency on `com.code-intelligence:jazzer-junit:<latest version>`.
+   All Jazzer Maven artifacts are signed with [this key](deploy/maven.pub).
+2. Add a new *fuzz test* to a new or existing test class: a method annotated with [`@FuzzTest`](https://codeintelligencetesting.github.io/jazzer-docs/jazzer-junit/com/code_intelligence/jazzer/junit/FuzzTest.html) and at least one parameter.
+   Using a single parameter of type [`FuzzedDataProvider`](https://codeintelligencetesting.github.io/jazzer-docs/jazzer-api/com/code_intelligence/jazzer/api/FuzzedDataProvider.html), which provides utility functions to produce commonly used Java values, or `byte[]` is recommended for optimal performance and reproducibility of findings.
+3. Assuming your test class is called `com.example.MyFuzzTests`, create the *inputs directory* `src/test/resources/com/example/MyFuzzTestsInputs`.
+4. Run a fuzz test with the environment variable `JAZZER_FUZZ` set to `1` to let the fuzzer rapidly try new sets of arguments.
+   If the fuzzer finds arguments that make your fuzz test fail or even trigger a security issue, it will store them in the inputs directory.
+5. Run the fuzz test without `JAZZER_FUZZ` set to execute it only on the inputs in the inputs directory.
+   This mode, which behaves just like a traditional unit test, ensures that issues previously found by the fuzzer remain fixed and can also be used to debug the fuzz test on individual inputs.
+
+A simple property-based fuzz test could look like this (excluding imports):
+
+```java
+class ParserTests {
+   @Test
+   void unitTest() {
+      assertEquals("foobar", SomeScheme.decode(SomeScheme.encode("foobar")));
+   }
+
+   @FuzzTest
+   void fuzzTest(FuzzedDataProvider data) {
+      String input = data.consumeRemainingAsString();
+      assertEquals(input, SomeScheme.decode(SomeScheme.encode(input)));
+   }
+}
+```
+
+A complete Maven example project can be found in [`examples/junit`](examples/junit).
+
+### CI Fuzz
+
+The open-source CLI tool [cifuzz](https://github.com/CodeIntelligenceTesting/cifuzz) makes
+it easy to set up Maven and Gradle projects for fuzzing with Jazzer.
+It provides a command-line UI for fuzzing runs, deduplicates and manages findings, and
+provides coverage reports for fuzz tests. Moreover, you can use CI Fuzz to run your fuzz
+tests at scale in the [CI App](https://app.code-intelligence.com).
+
+### GitHub releases
+
+You can also use GitHub release archives to run a standalone Jazzer binary that starts its own JVM configured for fuzzing:
+
+1. Download and extract the latest release from the [GitHub releases page](https://github.com/CodeIntelligenceTesting/jazzer/releases).
+2. Add a new class to your project with a <code>public static void fuzzerTestOneInput(<a href="https://codeintelligencetesting.github.io/jazzer-docs/jazzer-api/com/code_intelligence/jazzer/api/FuzzedDataProvider.html">FuzzedDataProvider</a> data)</code> method.
+3. Compile your fuzz test with `jazzer_standalone.jar` on the classpath.
+4. Run the `jazzer` binary (`jazzer.exe` on Windows), specifying the classpath and fuzz test class:
+
+```shell
+./jazzer --cp=<classpath> --target_class=<fuzz test class>
+```
+
+If you see an error saying that `libjvm.so` has not been found, make sure that `JAVA_HOME` points to a JDK.
+
+The [`examples`](examples/src/main/java/com/example) directory includes both toy and real-world examples of fuzz tests.
+
+### Docker
+
+The "distroless" Docker image [cifuzz/jazzer](https://hub.docker.com/r/cifuzz/jazzer) includes a recent Jazzer release together with OpenJDK 17.
+Mount a directory containing your compiled fuzz target into the container under `/fuzzing` and use it like a GitHub release binary by running:
 
 ```sh
-docker run -v path/containing/the/application:/fuzzing cifuzz/jazzer <arguments>
+docker run -v path/containing/the/application:/fuzzing cifuzz/jazzer --cp=<classpath> --target_class=<fuzz test class>
 ```
 
 If Jazzer produces a finding, the input that triggered it will be available in the same directory.
 
-### Compiling with Bazel
+### Bazel
 
-#### Dependencies
+Support for Jazzer is available in [rules_fuzzing](https://github.com/bazelbuild/rules_fuzzing), the official Bazel rules for fuzzing.
+See [the README](https://github.com/bazelbuild/rules_fuzzing#java-fuzzing) for instructions on how to use Jazzer in a Java Bazel project.
 
-Jazzer has the following dependencies when being built from source:
+### OSS-Fuzz
 
-* Bazel 4 or later
-* JDK 8 or later (e.g. [OpenJDK](https://openjdk.java.net/))
-* [Clang](https://clang.llvm.org/) and [LLD](https://lld.llvm.org/) 9.0 or later (using a recent version is strongly recommended)
+[Code Intelligence](https://code-intelligence.com) and Google have teamed up to bring support for Java, Kotlin, and other JVM-based languages to [OSS-Fuzz](https://github.com/google/oss-fuzz), Google's project for large-scale fuzzing of open-souce software.
+Read [the OSS-Fuzz guide](https://google.github.io/oss-fuzz/getting-started/new-project-guide/jvm-lang/) to learn how to set up a Java project.
 
-It is recommended to use [Bazelisk](https://github.com/bazelbuild/bazelisk) to automatically download and install Bazel.
-Simply download the release binary for your OS and architecture and ensure that it is available in the `PATH`.
-The instructions below will assume that this binary is called `bazel` - Bazelisk is a thin wrapper around the actual Bazel binary and can be used interchangeably.
+## Further documentation
 
-#### Compilation
-
-Assuming the dependencies are installed, build Jazzer from source as follows:
-
-```bash
-$ git clone https://github.com/CodeIntelligenceTesting/jazzer
-$ cd jazzer
-# Note the double dash used to pass <arguments> to Jazzer rather than Bazel.
-$ bazel run //:jazzer -- <arguments>
-```
-
-If you prefer to build binaries that can be run without Bazel, use the following command to build your own archive with release binaries:
-
-```bash
-$ bazel build //:jazzer_release
-...
-INFO: Found 1 target...
-Target //:jazzer_release up-to-date:
-  bazel-bin/jazzer_release.tar.gz
-...
-```
-
-This will print the path of a `jazzer_release.tar.gz` archive that contains the same binaries that would be part of a release.
-
-##### macOS
-
-The build may fail with the clang shipped with Xcode. If you encounter issues during the build, add `--config=toolchain`
-right after `run` or `build` in the `bazelisk` commands above to use a checked-in toolchain that is known to work.
-Alternatively, manually install LLVM and set `CC` to the path of LLVM clang.
-
-#### rules_fuzzing
-
-Support for Jazzer has recently been added to [rules_fuzzing](https://github.com/bazelbuild/rules_fuzzing), the official Bazel rules for fuzzing.
-See their README for instructions on how to use Jazzer in a Java Bazel project.
-
-### Using the provided binaries
-
-Binary releases are available under [Releases](https://github.com/CodeIntelligenceTesting/jazzer/releases),
-but do not always include the latest changes.
-
-The binary distributions of Jazzer consist of the following components:
-
-- `jazzer` - main binary
-- `jazzer_agent_deploy.jar` - Java agent that performs bytecode instrumentation and tracks coverage (automatically loaded by `jazzer`)
-- `jazzer_api_deploy.jar` - contains convenience methods for creating fuzz targets and defining custom hooks
-
-The additional release artifact `examples_deploy.jar` contains most of the examples and can be used to run them without having to build them (see Examples below).
-
-After unpacking the archive, run Jazzer via
-
-```bash
-./jazzer <arguments>
-```
-
-If this leads to an error message saying that `libjvm.so` has not been found, the path to the local JRE needs to be
-specified in the `JAVA_HOME` environment variable.
-
-## Examples
-
-Multiple examples for instructive and real-world Jazzer fuzz targets can be found in the `examples/` directory.
-A toy example can be run as follows:
-
-```bash
-# Using Bazel:
-bazel run //examples:ExampleFuzzer
-# Using the binary release and examples_deploy.jar:
-./jazzer --cp=examples_deploy.jar
-```
-
-This should produce output similar to the following:
-
-```
-INFO: Loaded 1 hooks from com.example.ExampleFuzzerHooks
-INFO: Instrumented com.example.ExampleFuzzer (took 81 ms, size +83%)
-INFO: libFuzzer ignores flags that start with '--'
-INFO: Seed: 2735196724
-INFO: Loaded 1 modules   (65536 inline 8-bit counters): 65536 [0xe387b0, 0xe487b0),
-INFO: Loaded 1 PC tables (65536 PCs): 65536 [0x7f9353eff010,0x7f9353fff010),
-INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytes
-INFO: A corpus is not provided, starting from an empty corpus
-#2      INITED cov: 2 ft: 2 corp: 1/1b exec/s: 0 rss: 94Mb
-#1562   NEW    cov: 4 ft: 4 corp: 2/14b lim: 17 exec/s: 0 rss: 98Mb L: 13/13 MS: 5 ShuffleBytes-CrossOver-InsertRepeatedBytes-ShuffleBytes-CMP- DE: "magicstring4"-
-#1759   REDUCE cov: 4 ft: 4 corp: 2/13b lim: 17 exec/s: 0 rss: 99Mb L: 12/12 MS: 2 ChangeBit-EraseBytes-
-#4048   NEW    cov: 6 ft: 6 corp: 3/51b lim: 38 exec/s: 0 rss: 113Mb L: 38/38 MS: 4 ChangeBit-ChangeByte-CopyPart-CrossOver-
-#4055   REDUCE cov: 6 ft: 6 corp: 3/49b lim: 38 exec/s: 0 rss: 113Mb L: 36/36 MS: 2 ShuffleBytes-EraseBytes-
-#4266   REDUCE cov: 6 ft: 6 corp: 3/48b lim: 38 exec/s: 0 rss: 113Mb L: 35/35 MS: 1 EraseBytes-
-#4498   REDUCE cov: 6 ft: 6 corp: 3/47b lim: 38 exec/s: 0 rss: 114Mb L: 34/34 MS: 2 EraseBytes-CopyPart-
-#4764   REDUCE cov: 6 ft: 6 corp: 3/46b lim: 38 exec/s: 0 rss: 115Mb L: 33/33 MS: 1 EraseBytes-
-#5481   REDUCE cov: 6 ft: 6 corp: 3/44b lim: 43 exec/s: 0 rss: 116Mb L: 31/31 MS: 2 InsertByte-EraseBytes-
-#131072 pulse  cov: 6 ft: 6 corp: 3/44b lim: 1290 exec/s: 65536 rss: 358Mb
-
-== Java Exception: java.lang.IllegalStateException: mustNeverBeCalled has been called
-        at com.example.ExampleFuzzer.mustNeverBeCalled(ExampleFuzzer.java:38)
-        at com.example.ExampleFuzzer.fuzzerTestOneInput(ExampleFuzzer.java:32)
-DEDUP_TOKEN: eb6ee7d9b256590d
-== libFuzzer crashing input ==
-MS: 1 CMP- DE: "\x00C"-; base unit: 04e0ccacb50424e06e45f6184ad45895b6b8df8f
-0x6d,0x61,0x67,0x69,0x63,0x73,0x74,0x72,0x69,0x6e,0x67,0x34,0x74,0x72,0x69,0x6e,0x67,0x34,0x74,0x69,0x67,0x34,0x7b,0x0,0x0,0x43,0x34,0xa,0x0,0x0,0x0,
-magicstring4tring4tig4{\x00\x00C4\x0a\x00\x00\x00
-artifact_prefix='./'; Test unit written to crash-efea1e8fc83a15217d512e20d964040a68a968c3
-Base64: bWFnaWNzdHJpbmc0dHJpbmc0dGlnNHsAAEM0CgAAAA==
-reproducer_path='.'; Java reproducer written to Crash_efea1e8fc83a15217d512e20d964040a68a968c3.java
-```
-
-Here you can see the usual libFuzzer output in case of a crash, augmented with JVM-specific information.
-Instead of a native stack trace, the details of the uncaught Java exception that caused the crash are printed, followed by the fuzzer input that caused the exception to be thrown (if it is not too long).
-More information on what hooks and Java reproducers are can be found below.
-
-See `examples/BUILD.bazel` for the list of all possible example targets.
-
-## Usage
-
-### Creating a fuzz target
-
-Jazzer requires a JVM class containing the entry point for the fuzzer. This is commonly referred to as a "fuzz target" and
-may be as simple as the following Java example:
-
-```java
-package com.example.MyFirstFuzzTarget;
-
-public class MyFirstFuzzTarget {
-    public static void fuzzerTestOneInput(byte[] input) {
-        ...
-        // Call the function under test with arguments derived from input and
-        // throw an exception if something unwanted happens.
-        ...
-    }
-}
-```
-
-A Java fuzz target class needs to define exactly one of the following functions:
-
-* `public static void fuzzerTestOneInput(byte[] input)`: Ideal for fuzz targets that naturally work on raw byte input (e.g.
-  image parsers).
-* `public static void fuzzerTestOneInput(com.code_intelligence.api.FuzzedDataProvider data)`: A variety of types of "fuzzed
-  data" is made available via the `FuzzedDataProvider` interface (see below for more information on this interface).
-
-The fuzzer will repeatedly call this function with generated inputs. All unhandled exceptions are caught and
-reported as errors.
-
-The optional functions `public static void fuzzerInitialize()` or `public static void fuzzerInitialize(String[] args)`
-can be defined if initial setup is required. These functions will be called once before
-the first call to `fuzzerTestOneInput`.
-
-The optional function `public static void fuzzerTearDown()` will be run just before the JVM is shut down.
-
-#### Kotlin
-
-An example of a Kotlin fuzz target can be found in
-[KlaxonFuzzer.kt](https://github.com/CodeIntelligenceTesting/jazzer/tree/main/examples/src/main/java/com/example/KlaxonFuzzer.kt).
-
-### Running the fuzzer
-
-The fuzz target needs to be compiled and packaged into a `.jar` archive. Assuming that this archive is called
-`fuzz_target.jar` and depends on libraries available as `lib1.jar` and `lib2.jar`, fuzzing is started by
-invoking Jazzer with the following arguments:
-
-```bash
---cp=fuzz_target.jar:lib1.jar:lib2.jar --target_class=com.example.MyFirstFuzzTarget <optional_corpus_dir>
-```
-
-The fuzz target class can optionally be specified by adding it as the value of the `Jazzer-Fuzz-Target-Class` attribute
-in the JAR's manifest. If there is only a single such attribute among all manifests of JARs on the classpath, Jazzer will
-use its value as the fuzz target class.
-
-Bazel produces the correct type of `.jar` from a `java_binary` target with `create_executable = False` and
-`deploy_manifest_lines = ["Jazzer-Fuzz-Target-Class: com.example.MyFirstFuzzTarget"]` by adding the suffix `_deploy.jar`
-to the target name.
-
-### Fuzzed Data Provider
-
-For most non-trivial fuzz targets it is necessary to further process the byte array passed from the fuzzer, for example
-to extract multiple values or convert the input into a valid `java.lang.String`. We provide functionality similar to
-[atheris'](https://github.com/google/atheris) `FuzzedDataProvider` and libFuzzer's `FuzzedDataProvider.h` to simplify
-the task of writing JVM fuzz targets.
-
-If the function `public static void fuzzerTestOneInput(FuzzedDataProvider data)` is defined in the fuzz target, it will
-be passed an object implementing `com.code_intelligence.jazzer.api.FuzzedDataProvider` that allows _consuming_ the raw fuzzer
-input as values of common types. This can look as follows:
-
-```java
-package com.example.MySecondFuzzTarget;
-
-import com.code_intelligence.jazzer.api.FuzzedDataProvider;
-
-public class MySecondFuzzTarget {
-    public static void callApi(int val, String text) {
-        ...
-    }
-
-    public static void fuzzerTestOneInput(FuzzedDataProvider data) {
-        callApi1(data.consumeInt(), data.consumeRemainingAsString());
-    }
-}
-```
-
-The `FuzzedDataProvider` interface definition is contained in `jazzer_api_deploy.jar` in the binary release and can be
-built by the Bazel target `//agent:jazzer_api_deploy.jar`. It is also available from
-[Maven Central](https://search.maven.org/search?q=g:com.code-intelligence%20a:jazzer-api).
-For additional information, see the
-[javadocs](https://codeintelligencetesting.github.io/jazzer-api/com/code_intelligence/jazzer/api/FuzzedDataProvider.html).
-
-It is highly recommended to use `FuzzedDataProvider` for generating `java.lang.String` objects inside the fuzz target
-instead of converting the raw byte array to directly via a `String` constructor as the `FuzzedDataProvider` implementation is
-engineered to minimize copying and generate both valid and invalid ASCII-only and Unicode strings.
-
-### Autofuzz mode
-
-The Autofuzz mode enables fuzzing arbitrary methods without having to manually create fuzz targets.
-Instead, Jazzer will attempt to generate suitable and varied inputs to a specified methods using only public API functions available on the classpath.
-
-To use Autofuzz, specify the `--autofuzz` flag and provide a fully [qualified method reference](https://docs.oracle.com/javase/specs/jls/se8/html/jls-15.html#jls-15.13), e.g.:
-```
---autofuzz=org.apache.commons.imaging.Imaging::getBufferedImage
-```
-To autofuzz a constructor the `ClassType::new` format can be used.  
-If there are multiple overloads, and you want Jazzer to only fuzz one, you can optionally specify the signature of the method to fuzz:
-```
---autofuzz=org.apache.commons.imaging.Imaging::getBufferedImage(java.io.InputStream,java.util.Map)
-```
-The format of the signature agrees with that obtained from the part after the `#` of the link to the Javadocs for the particular method.
-
-Under the hood, Jazzer tries various ways of creating objects from the fuzzer input. For example, if a parameter is an
-interface or an abstract class, it will look for all concrete implementing classes on the classpath.
-Jazzer can also create objects from classes that follow the [builder design pattern](https://www.baeldung.com/creational-design-patterns#builder)
-or have a default constructor and use setters to set the fields.
-
-Creating objects from fuzzer input can lead to many reported exceptions.
-Jazzer addresses this issue by ignoring exceptions that the target method declares to throw.
-In addition to that, you can provide a list of exceptions to be ignored during fuzzing via the `--autofuzz_ignore` flag in the form of a comma-separated list.
-You can specify concrete exceptions (e.g., `java.lang.NullPointerException`), in which case also subclasses of these exception classes will be ignored, or glob patterns to ignore all exceptions in a specific package (e.g. `java.lang.*` or `com.company.**`).
-
-When fuzzing with `--autofuzz`, Jazzer automatically enables the `--keep_going` mode to keep fuzzing indefinitely after the first finding.
-Set `--keep_going=N` explicitly to stop after the `N`-th finding.
-
-#### Docker
-To facilitate using the Autofuzz mode, there is a docker image that you can use to fuzz libraries just by providing their Maven coordinates.
-The dependencies will then be downloaded and autofuzzed:
-
-```sh
-docker run cifuzz/jazzer-autofuzz <Maven coordinates> --autofuzz=<method reference> <further arguments>
-```
-
-As an example, you can autofuzz the `json-sanitizer` library as follows:
-```sh
-docker run -it cifuzz/jazzer-autofuzz \
-   com.mikesamuel:json-sanitizer:1.2.0 \
-   com.google.json.JsonSanitizer::sanitize \
-   --autofuzz_ignore=java.lang.ArrayIndexOutOfBoundsException \
-   --keep_going=1
-```
-
-####
-
-### Reproducing a bug
-
-When Jazzer manages to find an input that causes an uncaught exception or a failed assertion, it prints a Java
-stack trace and creates two files that aid in reproducing the crash without Jazzer:
-
-* `crash-<sha1_of_input>` contains the raw bytes passed to the fuzz target (just as with libFuzzer C/C++ fuzz targets).
-  The crash can be reproduced with Jazzer by passing the path to the crash file as the only positional argument.
-* `Crash-<sha1_of_input>.java` contains a class with a `main` function that invokes the fuzz target with the
-  crashing input. This is especially useful if using `FuzzedDataProvider` as the raw bytes of the input do not
-  directly correspond to the values consumed by the fuzz target. The `.java` file can be compiled with just
-  the fuzz target and its dependencies in the classpath (plus `jazzer_api_deploy.jar` if using `FuzzedDataProvider).
-
-### Minimizing a crashing input
-
-Every crash stack trace is accompanied by a `DEDUP_TOKEN` that uniquely identifies the relevant parts of the stack
-trace. This value is used by libFuzzer while minimizing a crashing input to ensure that the smaller inputs reproduce
-the "same" bug. To minimize a crashing input, execute Jazzer with the following arguments in addition to `--cp` and
-`--target_class`:
-
-```bash
--minimize_crash=1 <path/to/crashing_input>
-```
-
-### Parallel execution
-
-libFuzzer offers the `-fork=N` and `-jobs=N` flags for parallel fuzzing, both of which are also supported by Jazzer.
-
-### Limitations
-
-Jazzer currently maintains coverage information in a global variable that is shared among threads. This means that while
-fuzzing multi-threaded fuzz targets is theoretically possible, the reported coverage information may be misleading.
+* [Common options and workflows](docs/common.md)
+* [Advanced techniques](docs/advanced.md)
 
 ## Findings
 
-Jazzer has so far uncovered the following vulnerabilities and bugs:
-
-| Project | Bug      | Status | CVE | found by |
-| ------- | -------- | ------ | --- | -------- |
-| [OpenJDK](https://github.com/openjdk/jdk) | `OutOfMemoryError` via a small BMP image | [fixed](https://openjdk.java.net/groups/vulnerability/advisories/2022-01-18) | [CVE-2022-21360](https://nvd.nist.gov/vuln/detail/CVE-2022-21360) | [Code Intelligence](https://code-intelligence.com) |
-| [OpenJDK](https://github.com/openjdk/jdk) | `OutOfMemoryError` via a small TIFF image | [fixed](https://openjdk.java.net/groups/vulnerability/advisories/2022-01-18) | [CVE-2022-21366](https://nvd.nist.gov/vuln/detail/CVE-2022-21366) | [Code Intelligence](https://code-intelligence.com) |
-| [protocolbuffers/protobuf](https://github.com/protocolbuffers/protobuf) | Small protobuf messages can consume minutes of CPU time | [fixed](https://github.com/protocolbuffers/protobuf/security/advisories/GHSA-wrvw-hg22-4m67) | [CVE-2021-22569](https://nvd.nist.gov/vuln/detail/CVE-2021-22569) | [OSS-Fuzz](https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=39330) |
-| [jhy/jsoup](https://github.com/jhy/jsoup) | More than 19 Bugs found in HTML and XML parser | [fixed](https://github.com/jhy/jsoup/security/advisories/GHSA-m72m-mhq2-9p6c) | [CVE-2021-37714](https://nvd.nist.gov/vuln/detail/CVE-2021-37714) | [Code Intelligence](https://code-intelligence.com) |
-| [Apache/commons-compress](https://commons.apache.org/proper/commons-compress/) | Infinite loop when loading a crafted 7z | fixed | [CVE-2021-35515](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-35515) | [Code Intelligence](https://code-intelligence.com) |
-| [Apache/commons-compress](https://commons.apache.org/proper/commons-compress/) | `OutOfMemoryError` when loading a crafted 7z | fixed | [CVE-2021-35516](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-35516) | [Code Intelligence](https://code-intelligence.com) |
-| [Apache/commons-compress](https://commons.apache.org/proper/commons-compress/) | Infinite loop when loading a crafted TAR | fixed | [CVE-2021-35517](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-35517) | [Code Intelligence](https://code-intelligence.com) |
-| [Apache/commons-compress](https://commons.apache.org/proper/commons-compress/) | `OutOfMemoryError` when loading a crafted ZIP | fixed | [CVE-2021-36090](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-36090) | [Code Intelligence](https://code-intelligence.com) |
-| [Apache/PDFBox](https://pdfbox.apache.org/) | Infinite loop when loading a crafted PDF | fixed | [CVE-2021-27807](https://cve.mitre.org/cgi-bin/cvename.cgi?name=2021-27807) | [Code Intelligence](https://code-intelligence.com) |
-| [Apache/PDFBox](https://pdfbox.apache.org/) | OutOfMemoryError when loading a crafted PDF | fixed | [CVE-2021-27906](https://cve.mitre.org/cgi-bin/cvename.cgi?name=2021-27906) | [Code Intelligence](https://code-intelligence.com) |
-| [netplex/json-smart-v1](https://github.com/netplex/json-smart-v1) <br/> [netplex/json-smart-v2](https://github.com/netplex/json-smart-v2) | `JSONParser#parse` throws an undeclared exception | [fixed](https://github.com/netplex/json-smart-v2/issues/60) | [CVE-2021-27568](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-27568) | [@GanbaruTobi](https://github.com/GanbaruTobi) |
-| [OWASP/json-sanitizer](https://github.com/OWASP/json-sanitizer) | Output can contain`</script>` and `]]>`, which allows XSS | [fixed](https://groups.google.com/g/json-sanitizer-support/c/dAW1AeNMoA0) | [CVE-2021-23899](https://cve.mitre.org/cgi-bin/cvename.cgi?name=2021-23899) | [Code Intelligence](https://code-intelligence.com) |
-| [OWASP/json-sanitizer](https://github.com/OWASP/json-sanitizer) | Output can be invalid JSON and undeclared exceptions can be thrown | [fixed](https://groups.google.com/g/json-sanitizer-support/c/dAW1AeNMoA0) | [CVE-2021-23900](https://cve.mitre.org/cgi-bin/cvename.cgi?name=2021-23900) | [Code Intelligence](https://code-intelligence.com) |
-| [alibaba/fastjon](https://github.com/alibaba/fastjson) | `JSON#parse` throws undeclared exceptions | [fixed](https://github.com/alibaba/fastjson/issues/3631) | | [Code Intelligence](https://code-intelligence.com) |
-| [Apache/commons-compress](https://commons.apache.org/proper/commons-compress/) | Infinite loop and `OutOfMemoryError` in `TarFile` | [fixed](https://issues.apache.org/jira/browse/COMPRESS-569) | | [Code Intelligence](https://code-intelligence.com) |
-| [Apache/commons-compress](https://commons.apache.org/proper/commons-compress/) | `NullPointerException` in `ZipFile`| [fixed](https://issues.apache.org/jira/browse/COMPRESS-568) | | [Code Intelligence](https://code-intelligence.com) |
-| [Apache/commons-imaging](https://commons.apache.org/proper/commons-imaging/) | Parsers for multiple image formats throw undeclared exceptions | [reported](https://issues.apache.org/jira/browse/IMAGING-279?jql=project%20%3D%20%22Commons%20Imaging%22%20AND%20reporter%20%3D%20Meumertzheim%20) | | [Code Intelligence](https://code-intelligence.com) |
-| [Apache/PDFBox](https://pdfbox.apache.org/) | Various undeclared exceptions | [fixed](https://issues.apache.org/jira/browse/PDFBOX-5108?jql=project%20%3D%20PDFBOX%20AND%20reporter%20in%20(Meumertzheim)) | | [Code Intelligence](https://code-intelligence.com) |
-| [cbeust/klaxon](https://github.com/cbeust/klaxon) | Default parser throws runtime exceptions | [fixed](https://github.com/cbeust/klaxon/pull/330) | | [Code Intelligence](https://code-intelligence.com) |
-| [FasterXML/jackson-dataformats-binary](https://github.com/FasterXML/jackson-dataformats-binary) | `CBORParser` throws an undeclared exception due to missing bounds checks when parsing Unicode | [fixed](https://github.com/FasterXML/jackson-dataformats-binary/issues/236) | | [Code Intelligence](https://code-intelligence.com) |
-| [FasterXML/jackson-dataformats-binary](https://github.com/FasterXML/jackson-dataformats-binary) | `CBORParser` throws an undeclared exception on dangling arrays | [fixed](https://github.com/FasterXML/jackson-dataformats-binary/issues/240) | | [Code Intelligence](https://code-intelligence.com) |
-| [ngageoint/tiff-java](https://github.com/ngageoint/tiff-java) | `readTiff ` Index Out Of Bounds | [fixed](https://github.com/ngageoint/tiff-java/issues/38) | | [@raminfp](https://github.com/raminfp) |
-| [google/re2j](https://github.com/google/re2j) | `NullPointerException` in `Pattern.compile` | [reported](https://github.com/google/re2j/issues/148) | | [@schirrmacher](https://github.com/schirrmacher) |
-| [google/gson](https://github.com/google/gson) | `ArrayIndexOutOfBounds` in `ParseString` | [fixed](https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=40838) | | [@DavidKorczynski](https://twitter.com/Davkorcz) |
-
-As Jazzer is used to fuzz JVM projects in OSS-Fuzz, an additional list of bugs can be found [on the OSS-Fuzz issue tracker](https://bugs.chromium.org/p/oss-fuzz/issues/list?q=proj%3A%22json-sanitizer%22%20OR%20proj%3A%22fastjson2%22%20OR%20proj%3A%22jackson-core%22%20OR%20proj%3A%22jackson-dataformats-binary%22%20OR%20proj%3A%22jackson-dataformats-xml%22%20OR%20proj%3A%22apache-commons%22%20OR%20proj%3A%22jsoup%22%20OR%20proj%3A%22apache-commons-codec%22%20OR%20proj%3A%22apache-commons-io%22%20OR%20proj%3A%22apache-commons-jxpath%22%20OR%20proj%3A%22apache-commons-lang%22%20OR%20proj%3A%22httpcomponents-client%22%20OR%20proj%3A%22httpcomponents-core%22%20OR%20proj%3A%22tomcat%22%20OR%20proj%3A%22archaius-core%22%20OR%20proj%3A%22bc-java%22%20OR%20proj%3A%22gson%22%20OR%20proj%3A%22guava%22%20OR%20proj%3A%22guice%22%20OR%20proj%3A%22hdrhistogram%22%20OR%20proj%3A%22jackson-databind%22%20OR%20proj%3A%22javassist%22%20OR%20proj%3A%22jersey%22%20OR%20proj%3A%22jettison%22%20OR%20proj%3A%22joda-time%22%20OR%20proj%3A%22jul-to-slf4j%22%20OR%20proj%3A%22logback%22%20OR%20proj%3A%22servo-core%22%20OR%20proj%3A%22slf4j-api%22%20OR%20proj%3A%22snakeyaml%22%20OR%20proj%3A%22spring-boot-actuator%22%20OR%20proj%3A%22spring-boot%22%20OR%20proj%3A%22spring-framework%22%20OR%20proj%3A%22spring-security%22%20OR%20proj%3A%22stringtemplate4%22%20OR%20proj%3A%22woodstox%22%20OR%20proj%3A%22xmlpulll%22%20OR%20proj%3A%22xstream%22&can=1).
-
-If you find bugs with Jazzer, we would like to hear from you!
-Feel free to [open an issue](https://github.com/CodeIntelligenceTesting/jazzer/issues/new) or submit a pull request.
-
-## Advanced Options
-
-Various command line options are available to control the instrumentation and fuzzer execution. Since Jazzer is a
-libFuzzer-compiled binary, all positional and single dash command-line options are parsed by libFuzzer. Therefore, all
-Jazzer options are passed via double dash command-line flags, i.e., as `--option=value` (note the `=` instead of a space).
-
-A full list of command-line flags can be printed with the `--help` flag. For the available libFuzzer options please refer
-to [its documentation](https://llvm.org/docs/LibFuzzer.html) for a detailed description.
-
-### Passing JVM arguments
-
-When Jazzer is launched, it starts a JVM in which it executes the fuzz target.
-Arguments for this JVM can be provided via the `JAVA_OPTS` environment variable.
-
-Alternatively, arguments can also be supplied via the `--jvm_args` argument.
-Multiple arguments are delimited by the classpath separator, which is `;` on Windows and `:` else.
-For example, to enable preview features as well as set a maximum heap size, add the following to the Jazzer invocation:
-
-```bash
-# Windows
---jvm_args=--enable-preview;-Xmx1000m
-# Linux & macOS
---jvm_args=--enable-preview:-Xmx1000m
-```
-
-Arguments specified with `--jvm_args` take precendence over those in `JAVA_OPTS`.
-
-### Coverage Instrumentation
-
-The Jazzer agent inserts coverage markers into the JVM bytecode during class loading. libFuzzer uses this information
-to guide its input mutations towards increased coverage.
-
-It is possible to restrict instrumentation to only a subset of classes with the `--instrumentation_includes` flag. This
-is especially useful if coverage inside specific packages is of higher interest, e.g., the user library under test rather than an
-external parsing library in which the fuzzer is likely to get lost. Similarly, there is `--instrumentation_excludes` to
-exclude specific classes from instrumentation. Both flags take a list of glob patterns for the java class name separated
-by colon:
-
-```bash
---instrumentation_includes=com.my_com.**:com.other_com.** --instrumentation_excludes=com.my_com.crypto.**
-```
-
-By default, JVM-internal classes and Java as well as Kotlin standard library classes are not instrumented, so these do not
-need to be excluded manually.
-
-### Trace Instrumentation
-
-The agent adds additional hooks for tracing compares, integer divisions, switch statements and array indices.
-These hooks correspond to [clang's data flow hooks](https://clang.llvm.org/docs/SanitizerCoverage.html#tracing-data-flow).
-The particular instrumentation types to apply can be specified using the `--trace` flag, which accepts the following values:
-
-* `cov`: AFL-style edge coverage
-* `cmp`: compares (int, long, String) and switch cases
-* `div`: divisors in integer divisions
-* `gep`: constant array indexes
-* `indir`: call through `Method#invoke`
-* `all`: shorthand to apply all available instrumentations (except `gep`)
-
-Multiple instrumentation types can be combined with a colon (Linux, macOS) or a semicolon (Windows).
-
-### Value Profile
-
-The run-time flag `-use_value_profile=1` enables [libFuzzer's value profiling mode](https://llvm.org/docs/LibFuzzer.html#value-profile).
-When running with this flag, the feedback about compares and constants received from Jazzer's trace instrumentation is
-associated with the particular bytecode location and used to provide additional coverage instrumentation.
-See [ExampleValueProfileFuzzer.java](https://github.com/CodeIntelligenceTesting/jazzer/tree/main/examples/src/main/java/com/example/ExampleValueProfileFuzzer.java)
-for a fuzz target that would be very hard to fuzz without value profile.
-
-### Custom Hooks
-
-In order to obtain information about data passed into functions such as `String.equals` or `String.startsWith`, Jazzer
-hooks invocations to these methods. This functionality is also available to fuzz targets, where it can be used to implement
-custom sanitizers or stub out methods that block the fuzzer from progressing (e.g. checksum verifications or random number generation).
-See [ExampleFuzzerHooks.java](https://github.com/CodeIntelligenceTesting/jazzer/tree/main/examples/src/main/java/com/example/ExampleFuzzerHooks.java)
-for an example of such a hook. An example for a sanitizer can be found in
-[ExamplePathTraversalFuzzerHooks.java](https://github.com/CodeIntelligenceTesting/jazzer/tree/main/examples/src/main/java/com/example/ExamplePathTraversalFuzzerHooks.java).
-
-Method hooks can be declared using the `@MethodHook` annotation defined in the `com.code_intelligence.jazzer.api` package,
-which is contained in `jazzer_api_deploy.jar` (binary release) or built by the target `//agent:jazzer_api_deploy.jar` (Bazel).
-It is also available from
-[Maven Central](https://search.maven.org/search?q=g:com.code-intelligence%20a:jazzer-api).
-See the [javadocs of the `@MethodHook` API](https://codeintelligencetesting.github.io/jazzer-api/com/code_intelligence/jazzer/api/MethodHook.html)
-for more details.
-
-To use the compiled method hooks they have to be available on the classpath provided by `--cp` and can then be loaded by providing the
-flag `--custom_hooks`, which takes a colon-separated list of names of classes to load hooks from.
-If a hook is meant to be applied to a class in the Java standard library, it has to be loaded from a JAR file so that Jazzer can [add it to the bootstrap class loader search](https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/Instrumentation.html#appendToBootstrapClassLoaderSearch-java.util.jar.JarFile-).
-This list of custom hooks can alternatively be specified via the `Jazzer-Hook-Classes` attribute in the fuzz target
-JAR's manifest.
-
-### Suppressing stack traces
-
-With the flag `--keep_going=N` Jazzer continues fuzzing until `N` unique stack traces have been encountered.
-
-Particular stack traces can also be ignored based on their `DEDUP_TOKEN` by passing a comma-separated list of tokens
-via `--ignore=<token_1>,<token2>`.
-
-### Export coverage information
-
-The internally gathered JaCoCo coverage information can be exported in human-readable and JaCoCo execution data format
-(`.exec`). These can help identify code areas that have not been covered by the fuzzer and thus may require more
-comprehensive fuzz targets or a more extensive initial corpus to reach.
-
-The human-readable report contains coverage information, like branch and line coverage, on file level. It's useful to 
-get a quick overview about the overall coverage. The flag `--coverage_report=<file>` can be used to generate it.
-
-Similar to the JaCoCo `dump` command, the flag `--coverage_dump=<file>` specifies a coverage dump file, often called
-`jacoco.exec`, that is generated after the fuzzing run. It contains a binary representation of the gathered coverage 
-data in the JaCoCo format.
-
-The JaCoCo `report` command can be used to generate reports based on this coverage dump. The JaCoCo CLI tools are 
-available on their [GitHub release page](https://github.com/jacoco/jacoco/releases) as `zip` file. The report tool is 
-located in the `lib` folder and can be used as described in the JaCoCo 
-[CLI documentation](https://www.eclemma.org/jacoco/trunk/doc/cli.html). For example the following command generates an 
-HTML report in the folder `report` containing all classes available in `classes.jar` and their coverage as captured in 
-the export `coverage.exec`. Source code to include in the report is searched for in `some/path/to/sources`. 
-After execution the `index.html` file in the output folder can be opened in a browser.
-```shell
-java -jar path/to/jacococli.jar report coverage.exec \
-  --classfiles classes.jar \
-  --sourcefiles some/path/to/sources \
-  --html report \
-  --name FuzzCoverageReport
-```
-
-## Advanced fuzz targets
-
-### Fuzzing with Native Libraries
-
-Jazzer supports fuzzing of native libraries loaded by the JVM, for example via `System.load()`. For the fuzzer to get
-coverage feedback, these libraries have to be compiled with `-fsanitize=fuzzer-no-link`.
-
-Additional sanitizers such as AddressSanitizer or UndefinedBehaviorSanitizer are often desirable to uncover bugs inside
-the native libraries. The required compilation flags for native libraries are as follows:
- - *AddressSanitizer*: `-fsanitize=fuzzer-no-link,address`
- - *UndefinedBehaviorSanitizer*: `-fsanitize=fuzzer-no-link,undefined` (add `-fno-sanitize-recover=all` to crash on UBSan reports)
-
-Then, use the appropriate driver `//:jazzer_asan` or `//:jazzer_ubsan`.
-
-**Note:** Sanitizers other than AddressSanitizer and UndefinedBehaviorSanitizer are not yet supported.
-Furthermore, due to the nature of the JVM's GC, LeakSanitizer reports too many false positives to be useful and is thus disabled.
-
-The fuzz targets `ExampleFuzzerWithNativeASan` and `ExampleFuzzerWithNativeUBSan` in the `examples/` directory contain
-minimal working examples for fuzzing with native libraries. Also see `TurboJpegFuzzer` for a real-world example.
-
-### Fuzzing with Custom Mutators
-
-LibFuzzer API offers two functions to customize the mutation strategy which is especially useful when fuzzing functions
-that require structured input. Jazzer does not define `LLVMFuzzerCustomMutator` nor `LLVMFuzzerCustomCrossOver` and
-leaves the mutation strategy entirely to libFuzzer. However, custom mutators can easily be integrated by
-compiling a mutator library which defines `LLVMFuzzerCustomMutator` (and optionally `LLVMFuzzerCustomCrossOver`) and
-pre-loading the mutator library:
-
-```bash
-# Using Bazel:
-LD_PRELOAD=libcustom_mutator.so bazel run //:jazzer -- <arguments>
-# Using the binary release:
-LD_PRELOAD=libcustom_mutator.so ./jazzer <arguments>
-```
+A list of security issues and bugs found by Jazzer is maintained [here](docs/findings.md).
+If you found something interesting and the information is public, please send a PR to add it to the list.
 
 ## Credit
 
@@ -550,3 +162,5 @@
 <p align="center">
 <a href="https://www.code-intelligence.com"><img src="https://www.code-intelligence.com/hubfs/Logos/CI%20Logos/CI_Header_GitHub_quer.jpeg" height=50px alt="Code Intelligence logo"></a>
 </p>
+
+[`FuzzedDataProvider`]: https://codeintelligencetesting.github.io/jazzer-docs/jazzer-api/com/code_intelligence/jazzer/api/FuzzedDataProvider.html
diff --git a/WORKSPACE.bazel b/WORKSPACE.bazel
index 5589d57..ae28bfc 100644
--- a/WORKSPACE.bazel
+++ b/WORKSPACE.bazel
@@ -3,13 +3,22 @@
 load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_file", "http_jar")
 load("//:repositories.bzl", "jazzer_dependencies")
 
-jazzer_dependencies()
+jazzer_dependencies(android = True)
 
 load("//:init.bzl", "jazzer_init")
 
 jazzer_init()
 
 http_archive(
+    name = "com_google_protobuf",
+    patches = ["//third_party:protobuf-disable-layering_check.patch"],
+    sha256 = "ddf8c9c1ffccb7e80afd183b3bd32b3b62f7cc54b106be190bf49f2bc09daab5",
+    strip_prefix = "protobuf-23.2",
+    # Keep in sync with com_google_protobuf_protobuf_java in repositories.bzl.
+    urls = ["https://github.com/protocolbuffers/protobuf/releases/download/v23.2/protobuf-23.2.tar.gz"],
+)
+
+http_archive(
     name = "org_chromium_sysroot_linux_x64",
     build_file_content = """
 filegroup(
@@ -24,12 +33,6 @@
 
 http_archive(
     name = "com_grail_bazel_toolchain",
-    patches = [
-        # There is no static runtime library for ASan on macOS, so when using
-        # the toolchain in the CI, we have to explicitly depend on the dylib and
-        # add it to the runfiles for clang/ld.
-        "//third_party:bazel-toolchain-export-dynamic-macos-asan.patch",
-    ],
     sha256 = "da607faed78c4cb5a5637ef74a36fdd2286f85ca5192222c4664efec2d529bb8",
     strip_prefix = "bazel-toolchain-0.6.3",
     urls = ["https://github.com/grailbio/bazel-toolchain/archive/refs/tags/0.6.3.tar.gz"],
@@ -51,9 +54,10 @@
 
 http_archive(
     name = "rules_jvm_external",
-    sha256 = "cd1a77b7b02e8e008439ca76fd34f5b07aecb8c752961f9640dea15e9e5ba1ca",
-    strip_prefix = "rules_jvm_external-4.2",
-    url = "https://github.com/bazelbuild/rules_jvm_external/archive/refs/tags/4.2.zip",
+    sha256 = "6ebe13d95fc5549cc32b27d41c907426b16464c5aae893a163c7fe0c9051ec1d",
+    # TODO: Return to the next release.
+    strip_prefix = "rules_jvm_external-90280783fa4e74439b88191acafd99232ada61aa",
+    url = "https://github.com/bazelbuild/rules_jvm_external/archive/90280783fa4e74439b88191acafd99232ada61aa.tar.gz",
 )
 
 http_archive(
@@ -64,12 +68,67 @@
     url = "https://github.com/libjpeg-turbo/libjpeg-turbo/archive/refs/tags/2.0.90.tar.gz",
 )
 
-http_jar(
-    name = "org_kohsuke_args4j_args4j",
-    sha256 = "91ddeaba0b24adce72291c618c00bbdce1c884755f6c4dba9c5c46e871c69ed6",
-    url = "https://repo1.maven.org/maven2/args4j/args4j/2.33/args4j-2.33.jar",
+http_archive(
+    name = "rules_pkg",
+    sha256 = "8a298e832762eda1830597d64fe7db58178aa84cd5926d76d5b744d6558941c2",
+    urls = [
+        "https://mirror.bazel.build/github.com/bazelbuild/rules_pkg/releases/download/0.7.0/rules_pkg-0.7.0.tar.gz",
+        "https://github.com/bazelbuild/rules_pkg/releases/download/0.7.0/rules_pkg-0.7.0.tar.gz",
+    ],
 )
 
+http_archive(
+    name = "contrib_rules_jvm",
+    # TODO: Return to the next release.
+    sha256 = "57dfe16edee7b6df7fcc2b92551aab233e9c802a7aba420e5302b99f3eccbbc3",
+    strip_prefix = "rules_jvm-80780e1b1d9cc041b843acc3155a2828a57d5807",
+    url = "https://github.com/bazel-contrib/rules_jvm/archive/80780e1b1d9cc041b843acc3155a2828a57d5807.tar.gz",
+)
+
+http_archive(
+    name = "build_bazel_rules_android",
+    sha256 = "cd06d15dd8bb59926e4d65f9003bfc20f9da4b2519985c27e190cddc8b7a7806",
+    strip_prefix = "rules_android-0.1.1",
+    urls = ["https://github.com/bazelbuild/rules_android/archive/v0.1.1.zip"],
+)
+
+http_archive(
+    name = "rules_android_ndk",
+    sha256 = "0fcaeed391bfc0ee784ab0365312e6c59fe75db9df3a67e8708c606e2a9cfd90",
+    strip_prefix = "rules_android_ndk-79923720aef601fad89c94e8802f5d77c1b73c5d",
+    url = "https://github.com/bazelbuild/rules_android_ndk/archive/79923720aef601fad89c94e8802f5d77c1b73c5d.zip",
+)
+
+http_file(
+    name = "genhtml",
+    downloaded_file_path = "genhtml",
+    executable = True,
+    sha256 = "4120cc9186a0687db218520a2d0dc9bae75d15faf41d87448b6b6c5140c19156",
+    urls = ["https://raw.githubusercontent.com/linux-test-project/lcov/6da8399c7a7a3370de2c69b16b092e945442ffcd/bin/genhtml"],
+)
+
+http_file(
+    name = "jacocoagent",
+    downloaded_file_path = "jacocoagent.jar",
+    sha256 = "191734a0b7ef97606e6a09ae584c4acab47eb30fcb4c555d3d440d4e0d71d73d",
+    urls = ["https://repo1.maven.org/maven2/org/jacoco/org.jacoco.agent/0.8.9/org.jacoco.agent-0.8.9-runtime.jar"],
+)
+
+http_file(
+    name = "jacococli",
+    downloaded_file_path = "jacococli.jar",
+    sha256 = "29c7754338512599f742ebfedd095c9c93800fefbce407500eceb16f0ed5a20d",
+    urls = ["https://repo1.maven.org/maven2/org/jacoco/org.jacoco.cli/0.8.9/org.jacoco.cli-0.8.9-nodeps.jar"],
+)
+
+load("@contrib_rules_jvm//:repositories.bzl", "contrib_rules_jvm_deps")
+
+contrib_rules_jvm_deps()
+
+load("@contrib_rules_jvm//:setup.bzl", "contrib_rules_jvm_setup")
+
+contrib_rules_jvm_setup()
+
 load("@com_grail_bazel_toolchain//toolchain:deps.bzl", "bazel_toolchain_dependencies")
 
 bazel_toolchain_dependencies()
@@ -106,6 +165,7 @@
     override_targets = {
         "org.jetbrains.kotlin:kotlin-reflect": "@com_github_jetbrains_kotlin//:kotlin-reflect",
         "org.jetbrains.kotlin:kotlin-stdlib": "@com_github_jetbrains_kotlin//:kotlin-stdlib",
+        "com.google.errorprone:error_prone_annotations": "@com_google_errorprone_error_prone_annotations//jar",
     },
     repositories = [
         "https://repo1.maven.org/maven2",
@@ -117,10 +177,124 @@
 
 pinned_maven_install()
 
+load("@rules_pkg//:deps.bzl", "rules_pkg_dependencies")
+
+rules_pkg_dependencies()
+
+load("@com_google_protobuf//:protobuf_deps.bzl", "protobuf_deps")
+
+protobuf_deps()
+
 http_file(
-    name = "genhtml",
-    downloaded_file_path = "genhtml",
+    name = "jacocoagent",
+    downloaded_file_path = "jacocoagent.jar",
+    sha256 = "67de51e9ca1db044f3a3d10613518befb02e8eee1015f2ff6d56cfb9d4506546",
+    urls = ["https://repo1.maven.org/maven2/org/jacoco/org.jacoco.agent/0.8.8/org.jacoco.agent-0.8.8-runtime.jar"],
+)
+
+http_file(
+    name = "jacococli",
+    downloaded_file_path = "jacococli.jar",
+    sha256 = "c449591174982bbc003d1290003fcbc7b939215436922d2f0f25239d110d531a",
+    urls = ["https://repo1.maven.org/maven2/org/jacoco/org.jacoco.cli/0.8.8/org.jacoco.cli-0.8.8-nodeps.jar"],
+)
+
+load("//third_party/android:android_configure.bzl", "android_configure")
+
+android_configure(name = "configure_android_rules")
+
+load("@configure_android_rules//:android_configure.bzl", "android_workspace")
+
+android_workspace()
+
+http_archive(
+    name = "buildifier_prebuilt",
+    sha256 = "7015c623143084bbdb3d2bb955087deb7cbb8e4806df457f3340d64b6eda876e",
+    strip_prefix = "buildifier-prebuilt-5b6adef925e98f90305d69de6d7ad70dd512c4ee",
+    urls = [
+        "https://github.com/keith/buildifier-prebuilt/archive/5b6adef925e98f90305d69de6d7ad70dd512c4ee.tar.gz",
+    ],
+)
+
+load("@buildifier_prebuilt//:deps.bzl", "buildifier_prebuilt_deps")
+
+buildifier_prebuilt_deps()
+
+load("@bazel_skylib//:workspace.bzl", "bazel_skylib_workspace")
+
+bazel_skylib_workspace()
+
+load("@buildifier_prebuilt//:defs.bzl", "buildifier_prebuilt_register_toolchains")
+
+buildifier_prebuilt_register_toolchains()
+
+http_file(
+    name = "clang-format-15-darwin-x64",
+    downloaded_file_path = "clang-format",
     executable = True,
-    sha256 = "4120cc9186a0687db218520a2d0dc9bae75d15faf41d87448b6b6c5140c19156",
-    urls = ["https://raw.githubusercontent.com/linux-test-project/lcov/6da8399c7a7a3370de2c69b16b092e945442ffcd/bin/genhtml"],
+    sha256 = "97116f64d97fb2870b4aa29758bba8fb0fe7f3b1ed8a4bc12faa927ecfdec196",
+    urls = [
+        "https://github.com/angular/clang-format/raw/master/bin/darwin_x64/clang-format",
+    ],
+)
+
+http_file(
+    name = "clang-format-15-linux-x64",
+    downloaded_file_path = "clang-format",
+    executable = True,
+    sha256 = "050c600256e225eabe9608d28f492fe8673c6e7f5deac59c6da973223c764d6c",
+    urls = [
+        "https://github.com/angular/clang-format/raw/master/bin/linux_x64/clang-format",
+    ],
+)
+
+http_file(
+    name = "addlicense-darwin-universal",
+    downloaded_file_path = "addlicense",
+    executable = True,
+    sha256 = "9c08964e15d6ed0568c4e8a5f861bcc2122498419586fbe87e08add56d18762d",
+    urls = [
+        "https://github.com/CodeIntelligenceTesting/addlicense/releases/download/v1.1.1/addlicense-darwin-universal",
+    ],
+)
+
+http_file(
+    name = "addlicense-linux-amd64",
+    downloaded_file_path = "addlicense",
+    executable = True,
+    sha256 = "521e680ff085f511d760aa139a0e869238ab4c936e89d258ac3432147d9e8be9",
+    urls = [
+        "https://github.com/CodeIntelligenceTesting/addlicense/releases/download/v1.1.1/addlicense-linux-amd64",
+    ],
+)
+
+http_archive(
+    name = "libprotobuf-mutator",
+    build_file_content = """
+cc_library(
+    name = "libprotobuf-mutator",
+    srcs = glob([
+        "src/*.cc",
+        "src/*.h",
+        "src/libfuzzer/*.cc",
+        "src/libfuzzer/*.h",
+        "port/protobuf.h",
+    ], exclude = [
+        "**/*_test.cc",
+    ]),
+    hdrs = ["src/libfuzzer/libfuzzer_macro.h"],
+    deps = ["@com_google_protobuf//:protobuf"],
+    visibility = ["//visibility:public"],
+)
+""",
+    sha256 = "21bfdfef25554fa2e30aec2a9f9b58f4a17c1d8c8593763fa94a6dd74b226594",
+    strip_prefix = "libprotobuf-mutator-3b28530531b154a748fe9884bc9219b4966f0754",
+    urls = ["https://github.com/google/libprotobuf-mutator/archive/3b28530531b154a748fe9884bc9219b4966f0754.tar.gz"],
+)
+
+http_file(
+    name = "android_jvmti",
+    downloaded_file_path = "jvmti.encoded",
+    sha256 = "95bd6fb4f296ff1c49b893c1d3a665de3c2b1beaa3cc8fc570dea992202daa35",
+    url = "https://android.googlesource.com/platform/art/+/1cff8449bac0fdab6e84dc9255c3cccd504c1705/openjdkjvmti/include/jvmti.h?format=TEXT",
 )
diff --git a/agent/BUILD.bazel b/agent/BUILD.bazel
deleted file mode 100644
index aedbe42..0000000
--- a/agent/BUILD.bazel
+++ /dev/null
@@ -1,70 +0,0 @@
-load("@com_github_johnynek_bazel_jar_jar//:jar_jar.bzl", "jar_jar")
-load("//bazel:compat.bzl", "SKIP_ON_WINDOWS")
-load("//bazel:jar.bzl", "strip_jar")
-load("//sanitizers:sanitizers.bzl", "SANITIZER_CLASSES")
-
-java_binary(
-    name = "jazzer_agent_unshaded",
-    create_executable = False,
-    deploy_manifest_lines = [
-        "Premain-Class: com.code_intelligence.jazzer.agent.Agent",
-        "Can-Retransform-Classes: true",
-        "Jazzer-Hook-Classes: ",
-    ] + [" {}:".format(c) for c in SANITIZER_CLASSES],
-    runtime_deps = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/agent:agent_lib",
-        "//driver/src/main/java/com/code_intelligence/jazzer/driver",
-        "//sanitizers",
-    ],
-)
-
-strip_jar(
-    name = "jazzer_agent_deploy",
-    out = "jazzer_agent_deploy.jar",
-    jar = ":jazzer_agent_shaded_deploy",
-    paths_to_strip = [
-        "module-info.class",
-    ],
-    visibility = ["//visibility:public"],
-)
-
-jar_jar(
-    name = "jazzer_agent_shaded_deploy",
-    input_jar = "jazzer_agent_unshaded_deploy.jar",
-    rules = "agent_shade_rules",
-)
-
-sh_test(
-    name = "jazzer_agent_shading_test",
-    srcs = ["verify_shading.sh"],
-    args = [
-        "$(rootpath :jazzer_agent_deploy)",
-    ],
-    data = [
-        ":jazzer_agent_deploy",
-        "@local_jdk//:bin/jar",
-    ],
-    tags = [
-        # Coverage instrumentation necessarily adds files to the jar that we
-        # wouldn't want to release and thus causes this test to fail.
-        "no-coverage",
-    ],
-    target_compatible_with = SKIP_ON_WINDOWS,
-)
-
-java_binary(
-    name = "jazzer_api",
-    create_executable = False,
-    visibility = ["//visibility:public"],
-    runtime_deps = ["//agent/src/main/java/com/code_intelligence/jazzer/api"],
-)
-
-java_import(
-    name = "jazzer_api_compile_only",
-    jars = [
-        ":jazzer_api_deploy.jar",
-    ],
-    neverlink = True,
-    visibility = ["//visibility:public"],
-    deps = [],
-)
diff --git a/agent/src/jmh/native/com/code_intelligence/jazzer/runtime/BUILD.bazel b/agent/src/jmh/native/com/code_intelligence/jazzer/runtime/BUILD.bazel
deleted file mode 100644
index 33a0303..0000000
--- a/agent/src/jmh/native/com/code_intelligence/jazzer/runtime/BUILD.bazel
+++ /dev/null
@@ -1,12 +0,0 @@
-load("@fmeum_rules_jni//jni:defs.bzl", "cc_jni_library")
-
-cc_jni_library(
-    name = "fuzzer_callbacks",
-    srcs = ["fuzzer_callbacks.cpp"],
-    visibility = ["//agent/src/jmh/java/com/code_intelligence/jazzer/runtime:__pkg__"],
-    deps = [
-        "//agent/src/jmh/java/com/code_intelligence/jazzer/runtime:fuzzer_callbacks.hdrs",
-        "//driver/src/main/native/com/code_intelligence/jazzer/driver:sanitizer_hooks_with_pc",
-        "@jazzer_libfuzzer//:libfuzzer_no_main",
-    ],
-)
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/agent/Agent.kt b/agent/src/main/java/com/code_intelligence/jazzer/agent/Agent.kt
deleted file mode 100644
index f9b026f..0000000
--- a/agent/src/main/java/com/code_intelligence/jazzer/agent/Agent.kt
+++ /dev/null
@@ -1,171 +0,0 @@
-// Copyright 2021 Code Intelligence GmbH
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-@file:JvmName("Agent")
-
-package com.code_intelligence.jazzer.agent
-
-import com.code_intelligence.jazzer.driver.Opt
-import com.code_intelligence.jazzer.instrumentor.CoverageRecorder
-import com.code_intelligence.jazzer.instrumentor.Hooks
-import com.code_intelligence.jazzer.instrumentor.InstrumentationType
-import com.code_intelligence.jazzer.runtime.NativeLibHooks
-import com.code_intelligence.jazzer.runtime.TraceCmpHooks
-import com.code_intelligence.jazzer.runtime.TraceDivHooks
-import com.code_intelligence.jazzer.runtime.TraceIndirHooks
-import com.code_intelligence.jazzer.utils.ClassNameGlobber
-import com.code_intelligence.jazzer.utils.ManifestUtils
-import java.io.File
-import java.lang.instrument.Instrumentation
-import java.net.URI
-import java.nio.file.Paths
-import java.util.jar.JarFile
-import kotlin.io.path.ExperimentalPathApi
-import kotlin.io.path.exists
-import kotlin.io.path.isDirectory
-
-private object AgentJarFinder {
-    val agentJarFile = jarUriForClass(AgentJarFinder::class.java)?.let { JarFile(File(it)) }
-}
-
-fun jarUriForClass(clazz: Class<*>): URI? {
-    return clazz.protectionDomain?.codeSource?.location?.toURI()
-}
-
-@OptIn(ExperimentalPathApi::class)
-@Suppress("UNUSED_PARAMETER")
-fun premain(agentArgs: String?, instrumentation: Instrumentation) {
-    // Add the agent jar (i.e., the jar out of which we are currently executing) to the search path of the bootstrap
-    // class loader to ensure that instrumented classes can find the CoverageMap class regardless of which ClassLoader
-    // they are using.
-    if (AgentJarFinder.agentJarFile != null) {
-        instrumentation.appendToBootstrapClassLoaderSearch(AgentJarFinder.agentJarFile)
-    } else {
-        println("WARN: Failed to add agent JAR to bootstrap class loader search path")
-    }
-
-    val manifestCustomHookNames =
-        ManifestUtils.combineManifestValues(ManifestUtils.HOOK_CLASSES).flatMap {
-            it.split(':')
-        }.filter { it.isNotBlank() }
-    val allCustomHookNames = (manifestCustomHookNames + Opt.customHooks).toSet()
-    val disabledCustomHookNames = Opt.disabledHooks.toSet()
-    val customHookNames = allCustomHookNames - disabledCustomHookNames
-    val disabledCustomHooksToPrint = allCustomHookNames - customHookNames.toSet()
-    if (disabledCustomHooksToPrint.isNotEmpty()) {
-        println("INFO: Not using the following disabled hooks: ${disabledCustomHooksToPrint.joinToString(", ")}")
-    }
-
-    val classNameGlobber = ClassNameGlobber(Opt.instrumentationIncludes, Opt.instrumentationExcludes + customHookNames)
-    CoverageRecorder.classNameGlobber = classNameGlobber
-    val customHookClassNameGlobber = ClassNameGlobber(Opt.customHookIncludes, Opt.customHookExcludes + customHookNames)
-    // FIXME: Setting trace to the empty string explicitly results in all rather than no trace types
-    //  being applied - this is unintuitive.
-    val instrumentationTypes = (Opt.trace.takeIf { it.isNotEmpty() } ?: listOf("all")).flatMap {
-        when (it) {
-            "cmp" -> setOf(InstrumentationType.CMP)
-            "cov" -> setOf(InstrumentationType.COV)
-            "div" -> setOf(InstrumentationType.DIV)
-            "gep" -> setOf(InstrumentationType.GEP)
-            "indir" -> setOf(InstrumentationType.INDIR)
-            "native" -> setOf(InstrumentationType.NATIVE)
-            // Disable GEP instrumentation by default as it appears to negatively affect fuzzing
-            // performance. Our current GEP instrumentation only reports constant indices, but even
-            // when we instead reported non-constant indices, they tended to completely fill up the
-            // table of recent compares and value profile map.
-            "all" -> InstrumentationType.values().toSet() - InstrumentationType.GEP
-            else -> {
-                println("WARN: Skipping unknown instrumentation type $it")
-                emptySet()
-            }
-        }
-    }.toSet()
-    val idSyncFile = Opt.idSyncFile.takeUnless { it.isEmpty() }?.let {
-        Paths.get(it).also { path ->
-            println("INFO: Synchronizing coverage IDs in ${path.toAbsolutePath()}")
-        }
-    }
-    val dumpClassesDir = Opt.dumpClassesDir.takeUnless { it.isEmpty() }?.let {
-        Paths.get(it).toAbsolutePath().also { path ->
-            if (path.exists() && path.isDirectory()) {
-                println("INFO: Dumping instrumented classes into $path")
-            } else {
-                println("ERROR: Cannot dump instrumented classes into $path; does not exist or not a directory")
-            }
-        }
-    }
-    val includedHookNames = instrumentationTypes
-        .mapNotNull { type ->
-            when (type) {
-                InstrumentationType.CMP -> TraceCmpHooks::class.java.name
-                InstrumentationType.DIV -> TraceDivHooks::class.java.name
-                InstrumentationType.INDIR -> TraceIndirHooks::class.java.name
-                InstrumentationType.NATIVE -> NativeLibHooks::class.java.name
-                else -> null
-            }
-        }
-    val coverageIdSynchronizer = if (idSyncFile != null)
-        FileSyncCoverageIdStrategy(idSyncFile)
-    else
-        MemSyncCoverageIdStrategy()
-
-    val (includedHooks, customHooks) = Hooks.loadHooks(includedHookNames.toSet(), customHookNames.toSet())
-    // If we don't append the JARs containing the custom hooks to the bootstrap class loader,
-    // third-party hooks not contained in the agent JAR will not be able to instrument Java standard
-    // library classes. These classes are loaded by the bootstrap / system class loader and would
-    // not be considered when resolving references to hook methods, leading to NoClassDefFoundError
-    // being thrown.
-    customHooks.hookClasses
-        .mapNotNull { jarUriForClass(it) }
-        .toSet()
-        .map { JarFile(File(it)) }
-        .forEach { instrumentation.appendToBootstrapClassLoaderSearch(it) }
-
-    val runtimeInstrumentor = RuntimeInstrumentor(
-        instrumentation,
-        classNameGlobber,
-        customHookClassNameGlobber,
-        instrumentationTypes,
-        includedHooks.hooks,
-        customHooks.hooks,
-        customHooks.additionalHookClassNameGlobber,
-        coverageIdSynchronizer,
-        dumpClassesDir,
-    )
-
-    // These classes are e.g. dependencies of the RuntimeInstrumentor or hooks and thus were loaded
-    // before the instrumentor was ready. Since we haven't enabled it yet, they can safely be
-    // "retransformed": They haven't been transformed yet.
-    val classesToRetransform = instrumentation.allLoadedClasses
-        .filter {
-            classNameGlobber.includes(it.name) ||
-                customHookClassNameGlobber.includes(it.name) ||
-                customHooks.additionalHookClassNameGlobber.includes(it.name)
-        }
-        .filter {
-            instrumentation.isModifiableClass(it)
-        }
-        .toTypedArray()
-
-    instrumentation.addTransformer(runtimeInstrumentor, true)
-
-    if (classesToRetransform.isNotEmpty()) {
-        if (instrumentation.isRetransformClassesSupported) {
-            instrumentation.retransformClasses(*classesToRetransform)
-        } else {
-            println("WARN: Instrumentation was not applied to the following classes as they are dependencies of hooks:")
-            println("WARN: ${classesToRetransform.joinToString()}")
-        }
-    }
-}
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/agent/BUILD.bazel b/agent/src/main/java/com/code_intelligence/jazzer/agent/BUILD.bazel
deleted file mode 100644
index db6ae26..0000000
--- a/agent/src/main/java/com/code_intelligence/jazzer/agent/BUILD.bazel
+++ /dev/null
@@ -1,16 +0,0 @@
-load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library")
-
-kt_jvm_library(
-    name = "agent_lib",
-    srcs = [
-        "Agent.kt",
-        "CoverageIdStrategy.kt",
-        "RuntimeInstrumentor.kt",
-    ],
-    visibility = ["//visibility:public"],
-    deps = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/instrumentor",
-        "//agent/src/main/java/com/code_intelligence/jazzer/runtime",
-        "//driver/src/main/java/com/code_intelligence/jazzer/driver:opt",
-    ],
-)
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/agent/RuntimeInstrumentor.kt b/agent/src/main/java/com/code_intelligence/jazzer/agent/RuntimeInstrumentor.kt
deleted file mode 100644
index fe2efd5..0000000
--- a/agent/src/main/java/com/code_intelligence/jazzer/agent/RuntimeInstrumentor.kt
+++ /dev/null
@@ -1,181 +0,0 @@
-// Copyright 2021 Code Intelligence GmbH
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.code_intelligence.jazzer.agent
-
-import com.code_intelligence.jazzer.instrumentor.ClassInstrumentor
-import com.code_intelligence.jazzer.instrumentor.CoverageRecorder
-import com.code_intelligence.jazzer.instrumentor.Hook
-import com.code_intelligence.jazzer.instrumentor.InstrumentationType
-import com.code_intelligence.jazzer.utils.ClassNameGlobber
-import java.lang.instrument.ClassFileTransformer
-import java.lang.instrument.Instrumentation
-import java.nio.file.Path
-import java.security.ProtectionDomain
-import kotlin.math.roundToInt
-import kotlin.system.exitProcess
-import kotlin.time.measureTimedValue
-
-class RuntimeInstrumentor(
-    private val instrumentation: Instrumentation,
-    private val classesToFullyInstrument: ClassNameGlobber,
-    private val classesToHookInstrument: ClassNameGlobber,
-    private val instrumentationTypes: Set<InstrumentationType>,
-    private val includedHooks: List<Hook>,
-    private val customHooks: List<Hook>,
-    // Dedicated name globber for additional classes to hook stated in hook annotations is needed due to
-    // existing include and exclude pattern of classesToHookInstrument. All classes are included in hook
-    // instrumentation except the ones from default excludes, like JDK and Kotlin classes. But additional
-    // classes to hook, based on annotations, are allowed to reference normally ignored ones, like JDK
-    // and Kotlin internals.
-    // FIXME: Adding an additional class to hook will apply _all_ hooks to it and not only the one it's
-    // defined in. At some point we might want to track the list of classes per custom hook rather than globally.
-    private val additionalClassesToHookInstrument: ClassNameGlobber,
-    private val coverageIdSynchronizer: CoverageIdStrategy,
-    private val dumpClassesDir: Path?,
-) : ClassFileTransformer {
-
-    @OptIn(kotlin.time.ExperimentalTime::class)
-    override fun transform(
-        loader: ClassLoader?,
-        internalClassName: String,
-        classBeingRedefined: Class<*>?,
-        protectionDomain: ProtectionDomain?,
-        classfileBuffer: ByteArray,
-    ): ByteArray? {
-        return try {
-            // Bail out early if we would instrument ourselves. This prevents ClassCircularityErrors as we might need to
-            // load additional Jazzer classes until we reach the full exclusion logic.
-            if (internalClassName.startsWith("com/code_intelligence/jazzer/"))
-                return null
-            transformInternal(internalClassName, classfileBuffer)
-        } catch (t: Throwable) {
-            // Throwables raised from transform are silently dropped, making it extremely hard to detect instrumentation
-            // failures. The docs advise to use a top-level try-catch.
-            // https://docs.oracle.com/javase/9/docs/api/java/lang/instrument/ClassFileTransformer.html
-            t.printStackTrace()
-            throw t
-        }.also { instrumentedByteCode ->
-            // Only dump classes that were instrumented.
-            if (instrumentedByteCode != null && dumpClassesDir != null) {
-                dumpToClassFile(internalClassName, instrumentedByteCode)
-                dumpToClassFile(internalClassName, classfileBuffer, basenameSuffix = ".original")
-            }
-        }
-    }
-
-    private fun dumpToClassFile(internalClassName: String, bytecode: ByteArray, basenameSuffix: String = "") {
-        val relativePath = "$internalClassName$basenameSuffix.class"
-        val absolutePath = dumpClassesDir!!.resolve(relativePath)
-        val dumpFile = absolutePath.toFile()
-        dumpFile.parentFile.mkdirs()
-        dumpFile.writeBytes(bytecode)
-    }
-
-    override fun transform(
-        module: Module?,
-        loader: ClassLoader?,
-        internalClassName: String,
-        classBeingRedefined: Class<*>?,
-        protectionDomain: ProtectionDomain?,
-        classfileBuffer: ByteArray
-    ): ByteArray? {
-        return try {
-            if (module != null && !module.canRead(RuntimeInstrumentor::class.java.module)) {
-                // Make all other modules read our (unnamed) module, which allows them to access the classes needed by the
-                // instrumentations, e.g. CoverageMap. If a module can't be modified, it should not be instrumented as the
-                // injected bytecode might throw NoClassDefFoundError.
-                // https://mail.openjdk.java.net/pipermail/jigsaw-dev/2021-May/014663.html
-                if (!instrumentation.isModifiableModule(module)) {
-                    val prettyClassName = internalClassName.replace('/', '.')
-                    println("WARN: Failed to instrument $prettyClassName in unmodifiable module ${module.name}, skipping")
-                    return null
-                }
-                instrumentation.redefineModule(
-                    module,
-                    /* extraReads */ setOf(RuntimeInstrumentor::class.java.module),
-                    emptyMap(),
-                    emptyMap(),
-                    emptySet(),
-                    emptyMap()
-                )
-            }
-            transform(loader, internalClassName, classBeingRedefined, protectionDomain, classfileBuffer)
-        } catch (t: Throwable) {
-            // Throwables raised from transform are silently dropped, making it extremely hard to detect instrumentation
-            // failures. The docs advise to use a top-level try-catch.
-            // https://docs.oracle.com/javase/9/docs/api/java/lang/instrument/ClassFileTransformer.html
-            t.printStackTrace()
-            throw t
-        }
-    }
-
-    @OptIn(kotlin.time.ExperimentalTime::class)
-    fun transformInternal(internalClassName: String, classfileBuffer: ByteArray): ByteArray? {
-        val fullInstrumentation = when {
-            classesToFullyInstrument.includes(internalClassName) -> true
-            classesToHookInstrument.includes(internalClassName) -> false
-            additionalClassesToHookInstrument.includes(internalClassName) -> false
-            else -> return null
-        }
-        val prettyClassName = internalClassName.replace('/', '.')
-        val (instrumentedBytecode, duration) = measureTimedValue {
-            try {
-                instrument(internalClassName, classfileBuffer, fullInstrumentation)
-            } catch (e: CoverageIdException) {
-                System.err.println("ERROR: Coverage IDs are out of sync")
-                e.printStackTrace()
-                exitProcess(1)
-            } catch (e: Exception) {
-                println("WARN: Failed to instrument $prettyClassName, skipping")
-                e.printStackTrace()
-                return null
-            }
-        }
-        val durationInMs = duration.inWholeMilliseconds
-        val sizeIncrease = ((100.0 * (instrumentedBytecode.size - classfileBuffer.size)) / classfileBuffer.size).roundToInt()
-        if (fullInstrumentation) {
-            println("INFO: Instrumented $prettyClassName (took $durationInMs ms, size +$sizeIncrease%)")
-        } else {
-            println("INFO: Instrumented $prettyClassName with custom hooks only (took $durationInMs ms, size +$sizeIncrease%)")
-        }
-        return instrumentedBytecode
-    }
-
-    private fun instrument(internalClassName: String, bytecode: ByteArray, fullInstrumentation: Boolean): ByteArray {
-        return ClassInstrumentor(bytecode).run {
-            if (fullInstrumentation) {
-                // Hook instrumentation must be performed after data flow tracing as the injected
-                // bytecode would trigger the GEP callbacks for byte[]. Coverage instrumentation
-                // must be performed after hook instrumentation as the injected bytecode would
-                // trigger the GEP callbacks for ByteBuffer.
-                traceDataFlow(instrumentationTypes)
-                hooks(includedHooks + customHooks)
-                coverageIdSynchronizer.withIdForClass(internalClassName) { firstId ->
-                    coverage(firstId).also { actualNumEdgeIds ->
-                        CoverageRecorder.recordInstrumentedClass(
-                            internalClassName,
-                            bytecode,
-                            firstId,
-                            actualNumEdgeIds
-                        )
-                    }
-                }
-            } else {
-                hooks(customHooks)
-            }
-            instrumentedBytecode
-        }
-    }
-}
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/autofuzz/BUILD.bazel b/agent/src/main/java/com/code_intelligence/jazzer/autofuzz/BUILD.bazel
deleted file mode 100644
index 779f79c..0000000
--- a/agent/src/main/java/com/code_intelligence/jazzer/autofuzz/BUILD.bazel
+++ /dev/null
@@ -1,17 +0,0 @@
-java_library(
-    name = "autofuzz",
-    srcs = [
-        "AutofuzzCodegenVisitor.java",
-        "AutofuzzError.java",
-        "FuzzTarget.java",
-        "Meta.java",
-        "YourAverageJavaClass.java",
-    ],
-    visibility = ["//visibility:public"],
-    deps = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/api",
-        "//agent/src/main/java/com/code_intelligence/jazzer/utils",
-        "@com_github_classgraph_classgraph//:classgraph",
-        "@com_github_jhalterman_typetools//:typetools",
-    ],
-)
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/autofuzz/FuzzTarget.java b/agent/src/main/java/com/code_intelligence/jazzer/autofuzz/FuzzTarget.java
deleted file mode 100644
index 3b0d046..0000000
--- a/agent/src/main/java/com/code_intelligence/jazzer/autofuzz/FuzzTarget.java
+++ /dev/null
@@ -1,317 +0,0 @@
-// Copyright 2021 Code Intelligence GmbH
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.code_intelligence.jazzer.autofuzz;
-
-import com.code_intelligence.jazzer.api.AutofuzzConstructionException;
-import com.code_intelligence.jazzer.api.AutofuzzInvocationException;
-import com.code_intelligence.jazzer.api.FuzzedDataProvider;
-import com.code_intelligence.jazzer.utils.SimpleGlobMatcher;
-import com.code_intelligence.jazzer.utils.Utils;
-import java.io.Closeable;
-import java.io.IOException;
-import java.io.UnsupportedEncodingException;
-import java.lang.reflect.Constructor;
-import java.lang.reflect.Executable;
-import java.lang.reflect.Method;
-import java.lang.reflect.Modifier;
-import java.net.URLDecoder;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.nio.file.StandardOpenOption;
-import java.util.Arrays;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-
-public final class FuzzTarget {
-  private static final String AUTOFUZZ_REPRODUCER_TEMPLATE = "public class Crash_%s {\n"
-      + "  public static void main(String[] args) throws Throwable {\n"
-      + "    %s;\n"
-      + "  }\n"
-      + "}";
-  private static final long MAX_EXECUTIONS_WITHOUT_INVOCATION = 100;
-
-  private static String methodReference;
-  private static Executable[] targetExecutables;
-  private static Map<Executable, Class<?>[]> throwsDeclarations;
-  private static Set<SimpleGlobMatcher> ignoredExceptionMatchers;
-  private static long executionsSinceLastInvocation = 0;
-
-  public static void fuzzerInitialize(String[] args) {
-    if (args.length == 0 || !args[0].contains("::")) {
-      System.err.println(
-          "Expected the argument to --autofuzz to be a method reference (e.g. System.out::println)");
-      System.exit(1);
-    }
-    methodReference = args[0];
-    String[] parts = methodReference.split("::", 2);
-    String className = parts[0];
-    String methodNameAndOptionalDescriptor = parts[1];
-    String methodName;
-    String descriptor;
-    int descriptorStart = methodNameAndOptionalDescriptor.indexOf('(');
-    if (descriptorStart != -1) {
-      methodName = methodNameAndOptionalDescriptor.substring(0, descriptorStart);
-      // URL decode the descriptor to allow copy-pasting from javadoc links such as:
-      // https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/lang/String.html#valueOf(char%5B%5D)
-      try {
-        descriptor =
-            URLDecoder.decode(methodNameAndOptionalDescriptor.substring(descriptorStart), "UTF-8");
-      } catch (UnsupportedEncodingException e) {
-        // UTF-8 is always supported.
-        e.printStackTrace();
-        System.exit(1);
-        return;
-      }
-    } else {
-      methodName = methodNameAndOptionalDescriptor;
-      descriptor = null;
-    }
-
-    Class<?> targetClass = null;
-    String targetClassName = className;
-    do {
-      try {
-        // Explicitly invoking static initializers to trigger some coverage in the code.
-        targetClass = Class.forName(targetClassName, true, ClassLoader.getSystemClassLoader());
-      } catch (ClassNotFoundException e) {
-        int classSeparatorIndex = targetClassName.lastIndexOf(".");
-        if (classSeparatorIndex == -1) {
-          System.err.printf(
-              "Failed to find class %s for autofuzz, please ensure it is contained in the classpath "
-                  + "specified with --cp and specify the full package name%n",
-              className);
-          e.printStackTrace();
-          System.exit(1);
-          return;
-        }
-        StringBuilder classNameBuilder = new StringBuilder(targetClassName);
-        classNameBuilder.setCharAt(classSeparatorIndex, '$');
-        targetClassName = classNameBuilder.toString();
-      }
-    } while (targetClass == null);
-
-    boolean isConstructor = methodName.equals("new");
-    if (isConstructor) {
-      targetExecutables =
-          Arrays.stream(targetClass.getConstructors())
-              .filter(constructor
-                  -> descriptor == null
-                      || Utils.getReadableDescriptor(constructor).equals(descriptor))
-              .toArray(Executable[] ::new);
-    } else {
-      // We use getDeclaredMethods and filter for the public access modifier instead of using
-      // getMethods as we want to exclude methods inherited from superclasses or interfaces, which
-      // can lead to unexpected results when autofuzzing. If desired, these can be autofuzzed
-      // explicitly instead.
-      targetExecutables =
-          Arrays.stream(targetClass.getDeclaredMethods())
-              .filter(method -> Modifier.isPublic(method.getModifiers()))
-              .filter(method
-                  -> method.getName().equals(methodName)
-                      && (descriptor == null
-                          || Utils.getReadableDescriptor(method).equals(descriptor)))
-              .toArray(Executable[] ::new);
-    }
-    if (targetExecutables.length == 0) {
-      if (isConstructor) {
-        if (descriptor == null) {
-          System.err.printf(
-              "Failed to find accessible constructors in class %s for autofuzz.%n", className);
-        } else {
-          System.err.printf(
-              "Failed to find accessible constructors with signature %s in class %s for autofuzz.%n"
-                  + "Accessible constructors:%n%s",
-              descriptor, className,
-              Arrays.stream(targetClass.getConstructors())
-                  .map(method
-                      -> String.format("%s::new%s", method.getDeclaringClass().getName(),
-                          Utils.getReadableDescriptor(method)))
-                  .distinct()
-                  .collect(Collectors.joining(System.lineSeparator())));
-        }
-      } else {
-        if (descriptor == null) {
-          System.err.printf("Failed to find accessible methods named %s in class %s for autofuzz.%n"
-                  + "Accessible methods:%n%s",
-              methodName, className,
-              Arrays.stream(targetClass.getMethods())
-                  .map(method
-                      -> String.format(
-                          "%s::%s", method.getDeclaringClass().getName(), method.getName()))
-                  .distinct()
-                  .collect(Collectors.joining(System.lineSeparator())));
-        } else {
-          System.err.printf(
-              "Failed to find accessible methods named %s with signature %s in class %s for autofuzz.%n"
-                  + "Accessible methods with that name:%n%s",
-              methodName, descriptor, className,
-              Arrays.stream(targetClass.getMethods())
-                  .filter(method -> method.getName().equals(methodName))
-                  .map(method
-                      -> String.format("%s::%s%s", method.getDeclaringClass().getName(),
-                          method.getName(), Utils.getReadableDescriptor(method)))
-                  .distinct()
-                  .collect(Collectors.joining(System.lineSeparator())));
-        }
-      }
-      System.exit(1);
-    }
-
-    ignoredExceptionMatchers = Arrays.stream(args)
-                                   .skip(1)
-                                   .filter(s -> s.contains("*"))
-                                   .map(SimpleGlobMatcher::new)
-                                   .collect(Collectors.toSet());
-
-    List<Class<?>> alwaysIgnore =
-        Arrays.stream(args)
-            .skip(1)
-            .filter(s -> !s.contains("*"))
-            .map(name -> {
-              try {
-                return ClassLoader.getSystemClassLoader().loadClass(name);
-              } catch (ClassNotFoundException e) {
-                System.err.printf("Failed to find class '%s' specified in --autofuzz_ignore", name);
-                System.exit(1);
-              }
-              throw new Error("Not reached");
-            })
-            .collect(Collectors.toList());
-    throwsDeclarations =
-        Arrays.stream(targetExecutables)
-            .collect(Collectors.toMap(method
-                -> method,
-                method
-                -> Stream.concat(Arrays.stream(method.getExceptionTypes()), alwaysIgnore.stream())
-                       .toArray(Class[] ::new)));
-  }
-
-  public static void fuzzerTestOneInput(FuzzedDataProvider data) throws Throwable {
-    AutofuzzCodegenVisitor codegenVisitor = null;
-    if (Meta.isDebug()) {
-      codegenVisitor = new AutofuzzCodegenVisitor();
-    }
-    fuzzerTestOneInput(data, codegenVisitor);
-    if (codegenVisitor != null) {
-      System.err.println(codegenVisitor.generate());
-    }
-  }
-
-  public static void dumpReproducer(FuzzedDataProvider data, String reproducerPath, String sha) {
-    AutofuzzCodegenVisitor codegenVisitor = new AutofuzzCodegenVisitor();
-    try {
-      fuzzerTestOneInput(data, codegenVisitor);
-    } catch (Throwable ignored) {
-    }
-    String javaSource = String.format(AUTOFUZZ_REPRODUCER_TEMPLATE, sha, codegenVisitor.generate());
-    Path javaPath = Paths.get(reproducerPath, String.format("Crash_%s.java", sha));
-    try {
-      Files.write(javaPath, javaSource.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
-    } catch (IOException e) {
-      System.err.printf("ERROR: Failed to write Java reproducer to %s%n", javaPath);
-      e.printStackTrace();
-    }
-    System.out.printf(
-        "reproducer_path='%s'; Java reproducer written to %s%n", reproducerPath, javaPath);
-  }
-
-  private static void fuzzerTestOneInput(
-      FuzzedDataProvider data, AutofuzzCodegenVisitor codegenVisitor) throws Throwable {
-    Executable targetExecutable;
-    if (FuzzTarget.targetExecutables.length == 1) {
-      targetExecutable = FuzzTarget.targetExecutables[0];
-    } else {
-      targetExecutable = data.pickValue(FuzzTarget.targetExecutables);
-    }
-    Object returnValue = null;
-    try {
-      if (targetExecutable instanceof Method) {
-        returnValue = Meta.autofuzz(data, (Method) targetExecutable, codegenVisitor);
-      } else {
-        returnValue = Meta.autofuzz(data, (Constructor<?>) targetExecutable, codegenVisitor);
-      }
-      executionsSinceLastInvocation = 0;
-    } catch (AutofuzzConstructionException e) {
-      if (Meta.isDebug()) {
-        e.printStackTrace();
-      }
-      // Ignore exceptions thrown while constructing the parameters for the target method. We can
-      // only guess how to generate valid parameters and any exceptions thrown while doing so
-      // are most likely on us. However, if this happens too often, Autofuzz got stuck and we should
-      // let the user know.
-      executionsSinceLastInvocation++;
-      if (executionsSinceLastInvocation >= MAX_EXECUTIONS_WITHOUT_INVOCATION) {
-        System.err.printf("Failed to generate valid arguments to '%s' in %d attempts; giving up%n",
-            methodReference, executionsSinceLastInvocation);
-        System.exit(1);
-      } else if (executionsSinceLastInvocation == MAX_EXECUTIONS_WITHOUT_INVOCATION / 2) {
-        // The application under test might perform classpath modifications or create classes
-        // dynamically that implement interfaces or extend abstract classes. Rescanning the
-        // classpath might help with constructing objects.
-        Meta.rescanClasspath();
-      }
-    } catch (AutofuzzInvocationException e) {
-      executionsSinceLastInvocation = 0;
-      Throwable cause = e.getCause();
-      Class<?> causeClass = cause.getClass();
-      // Do not report exceptions declared to be thrown by the method under test.
-      for (Class<?> declaredThrow : throwsDeclarations.get(targetExecutable)) {
-        if (declaredThrow.isAssignableFrom(causeClass)) {
-          return;
-        }
-      }
-
-      if (ignoredExceptionMatchers.stream().anyMatch(m -> m.matches(causeClass.getName()))) {
-        return;
-      }
-      cleanStackTraces(cause);
-      throw cause;
-    } catch (Throwable t) {
-      System.err.println("Unexpected exception encountered during autofuzz");
-      t.printStackTrace();
-      System.exit(1);
-    } finally {
-      if (returnValue instanceof Closeable) {
-        ((Closeable) returnValue).close();
-      }
-    }
-  }
-
-  // Removes all stack trace elements that live in the Java reflection packages or the autofuzz
-  // package from the bottom of all stack frames.
-  private static void cleanStackTraces(Throwable t) {
-    Throwable cause = t;
-    while (cause != null) {
-      StackTraceElement[] elements = cause.getStackTrace();
-      int firstInterestingPos;
-      for (firstInterestingPos = elements.length - 1; firstInterestingPos > 0;
-           firstInterestingPos--) {
-        String className = elements[firstInterestingPos].getClassName();
-        if (!className.startsWith("com.code_intelligence.jazzer.autofuzz.")
-            && !className.startsWith("java.lang.reflect.")
-            && !className.startsWith("jdk.internal.reflect.")) {
-          break;
-        }
-      }
-      cause.setStackTrace(Arrays.copyOfRange(elements, 0, firstInterestingPos + 1));
-      cause = cause.getCause();
-    }
-  }
-}
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel b/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel
deleted file mode 100644
index db93dca..0000000
--- a/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel
+++ /dev/null
@@ -1,35 +0,0 @@
-load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library")
-load("@com_github_johnynek_bazel_jar_jar//:jar_jar.bzl", "jar_jar")
-
-kt_jvm_library(
-    name = "instrumentor",
-    srcs = [
-        "ClassInstrumentor.kt",
-        "CoverageRecorder.kt",
-        "DescriptorUtils.kt",
-        "DeterministicRandom.kt",
-        "EdgeCoverageInstrumentor.kt",
-        "Hook.kt",
-        "HookInstrumentor.kt",
-        "HookMethodVisitor.kt",
-        "Hooks.kt",
-        "Instrumentor.kt",
-        "StaticMethodStrategy.java",
-        "TraceDataFlowInstrumentor.kt",
-    ],
-    visibility = [
-        "//agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor:__pkg__",
-        "//agent/src/main/java/com/code_intelligence/jazzer/agent:__pkg__",
-        "//agent/src/test/java/com/code_intelligence/jazzer/instrumentor:__pkg__",
-        "//driver/src/main/java/com/code_intelligence/jazzer/driver:__pkg__",
-    ],
-    deps = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/runtime",
-        "//agent/src/main/java/com/code_intelligence/jazzer/utils",
-        "@com_github_classgraph_classgraph//:classgraph",
-        "@com_github_jetbrains_kotlin//:kotlin-reflect",
-        "@jazzer_jacoco//:jacoco_internal",
-        "@org_ow2_asm_asm//jar",
-        "@org_ow2_asm_asm_commons//jar",
-    ],
-)
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/replay/BUILD.bazel b/agent/src/main/java/com/code_intelligence/jazzer/replay/BUILD.bazel
deleted file mode 100644
index 08bd765..0000000
--- a/agent/src/main/java/com/code_intelligence/jazzer/replay/BUILD.bazel
+++ /dev/null
@@ -1,17 +0,0 @@
-load("@fmeum_rules_jni//jni:defs.bzl", "java_jni_library")
-
-java_jni_library(
-    name = "replay",
-    srcs = ["Replayer.java"],
-    native_libs = ["//driver/src/main/native/com/code_intelligence/jazzer/driver:fuzzed_data_provider_standalone"],
-    deps = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/api",
-        "//agent/src/main/java/com/code_intelligence/jazzer/runtime:fuzzed_data_provider",
-    ],
-)
-
-java_binary(
-    name = "Replayer",
-    visibility = ["//visibility:public"],
-    runtime_deps = [":replay"],
-)
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/runtime/BUILD.bazel b/agent/src/main/java/com/code_intelligence/jazzer/runtime/BUILD.bazel
deleted file mode 100644
index 0d8162d..0000000
--- a/agent/src/main/java/com/code_intelligence/jazzer/runtime/BUILD.bazel
+++ /dev/null
@@ -1,88 +0,0 @@
-load("@fmeum_rules_jni//jni:defs.bzl", "java_jni_library")
-
-java_jni_library(
-    name = "fuzzed_data_provider",
-    srcs = [
-        "FuzzedDataProviderImpl.java",
-    ],
-    visibility = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/replay:__pkg__",
-        "//agent/src/test/java/com/code_intelligence/jazzer/runtime:__pkg__",
-        "//driver/src/main/java/com/code_intelligence/jazzer/driver:__pkg__",
-        "//driver/src/main/native/com/code_intelligence/jazzer/driver:__pkg__",
-    ],
-    deps = [
-        ":unsafe_provider",
-        "//agent/src/main/java/com/code_intelligence/jazzer/api",
-    ],
-)
-
-java_jni_library(
-    name = "coverage_map",
-    srcs = ["CoverageMap.java"],
-    visibility = [
-        "//agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor:__pkg__",
-        "//agent/src/main/java/com/code_intelligence/jazzer/instrumentor:__pkg__",
-        "//driver/src/main/java/com/code_intelligence/jazzer/driver:__pkg__",
-        "//driver/src/main/native/com/code_intelligence/jazzer/driver:__pkg__",
-        "//driver/src/test:__subpackages__",
-    ],
-    deps = [
-        ":unsafe_provider",
-    ],
-)
-
-java_jni_library(
-    name = "signal_handler",
-    srcs = ["SignalHandler.java"],
-    native_libs = ["//agent/src/main/native/com/code_intelligence/jazzer/runtime:jazzer_signal_handler"],
-    visibility = [
-        "//agent/src/main/native/com/code_intelligence/jazzer/runtime:__pkg__",
-        "//driver/src/main/java/com/code_intelligence/jazzer/driver:__pkg__",
-    ],
-)
-
-java_jni_library(
-    name = "trace_data_flow_native_callbacks",
-    srcs = ["TraceDataFlowNativeCallbacks.java"],
-    visibility = [
-        "//driver/src/main/native/com/code_intelligence/jazzer/driver:__pkg__",
-    ],
-    deps = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/utils",
-    ],
-)
-
-java_library(
-    name = "unsafe_provider",
-    srcs = ["UnsafeProvider.java"],
-    visibility = [
-        "//driver/src:__subpackages__",
-        "//sanitizers/src/main/java:__subpackages__",
-    ],
-)
-
-java_library(
-    name = "runtime",
-    srcs = [
-        "HardToCatchError.java",
-        "JazzerInternal.java",
-        "NativeLibHooks.java",
-        "RecordingFuzzedDataProvider.java",
-        "TraceCmpHooks.java",
-        "TraceDivHooks.java",
-        "TraceIndirHooks.java",
-    ],
-    visibility = ["//visibility:public"],
-    runtime_deps = [
-        ":signal_handler",
-        "//agent/src/main/java/com/code_intelligence/jazzer/autofuzz",
-    ],
-    deps = [
-        ":coverage_map",
-        ":fuzzed_data_provider",
-        ":trace_data_flow_native_callbacks",
-        "//agent/src/main/java/com/code_intelligence/jazzer/api",
-        "//agent/src/main/java/com/code_intelligence/jazzer/utils",
-    ],
-)
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/runtime/TraceCmpHooks.java b/agent/src/main/java/com/code_intelligence/jazzer/runtime/TraceCmpHooks.java
deleted file mode 100644
index 37e8eae..0000000
--- a/agent/src/main/java/com/code_intelligence/jazzer/runtime/TraceCmpHooks.java
+++ /dev/null
@@ -1,347 +0,0 @@
-// Copyright 2021 Code Intelligence GmbH
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.code_intelligence.jazzer.runtime;
-
-import com.code_intelligence.jazzer.api.HookType;
-import com.code_intelligence.jazzer.api.MethodHook;
-import java.lang.invoke.MethodHandle;
-import java.util.Arrays;
-import java.util.ConcurrentModificationException;
-import java.util.Map;
-import java.util.TreeMap;
-
-@SuppressWarnings("unused")
-final public class TraceCmpHooks {
-  @MethodHook(type = HookType.BEFORE, targetClassName = "java.lang.Byte", targetMethod = "compare",
-      targetMethodDescriptor = "(BB)I")
-  @MethodHook(type = HookType.BEFORE, targetClassName = "java.lang.Byte",
-      targetMethod = "compareUnsigned", targetMethodDescriptor = "(BB)I")
-  @MethodHook(type = HookType.BEFORE, targetClassName = "java.lang.Short", targetMethod = "compare",
-      targetMethodDescriptor = "(SS)I")
-  @MethodHook(type = HookType.BEFORE, targetClassName = "java.lang.Short",
-      targetMethod = "compareUnsigned", targetMethodDescriptor = "(SS)I")
-  @MethodHook(type = HookType.BEFORE, targetClassName = "java.lang.Integer",
-      targetMethod = "compare", targetMethodDescriptor = "(II)I")
-  @MethodHook(type = HookType.BEFORE, targetClassName = "java.lang.Integer",
-      targetMethod = "compareUnsigned", targetMethodDescriptor = "(II)I")
-  public static void
-  integerCompare(MethodHandle method, Object thisObject, Object[] arguments, int hookId) {
-    TraceDataFlowNativeCallbacks.traceCmpInt((int) arguments[0], (int) arguments[1], hookId);
-  }
-
-  @MethodHook(type = HookType.BEFORE, targetClassName = "java.lang.Byte",
-      targetMethod = "compareTo", targetMethodDescriptor = "(Ljava/lang/Byte;)I")
-  @MethodHook(type = HookType.BEFORE, targetClassName = "java.lang.Short",
-      targetMethod = "compareTo", targetMethodDescriptor = "(Ljava/lang/Short;)I")
-  @MethodHook(type = HookType.BEFORE, targetClassName = "java.lang.Integer",
-      targetMethod = "compareTo", targetMethodDescriptor = "(Ljava/lang/Integer;)I")
-  public static void
-  integerCompareTo(MethodHandle method, Object thisObject, Object[] arguments, int hookId) {
-    TraceDataFlowNativeCallbacks.traceCmpInt((int) thisObject, (int) arguments[0], hookId);
-  }
-
-  @MethodHook(type = HookType.BEFORE, targetClassName = "java.lang.Long", targetMethod = "compare",
-      targetMethodDescriptor = "(JJ)I")
-  @MethodHook(type = HookType.BEFORE, targetClassName = "java.lang.Long",
-      targetMethod = "compareUnsigned", targetMethodDescriptor = "(JJ)I")
-  public static void
-  longCompare(MethodHandle method, Object thisObject, Object[] arguments, int hookId) {
-    TraceDataFlowNativeCallbacks.traceCmpLong((long) arguments[0], (long) arguments[1], hookId);
-  }
-
-  @MethodHook(type = HookType.BEFORE, targetClassName = "java.lang.Long",
-      targetMethod = "compareTo", targetMethodDescriptor = "(Ljava/lang/Long;)I")
-  public static void
-  longCompareTo(MethodHandle method, Long thisObject, Object[] arguments, int hookId) {
-    TraceDataFlowNativeCallbacks.traceCmpLong(thisObject, (long) arguments[0], hookId);
-  }
-
-  @MethodHook(type = HookType.AFTER, targetClassName = "java.lang.String", targetMethod = "equals")
-  @MethodHook(type = HookType.AFTER, targetClassName = "java.lang.String",
-      targetMethod = "equalsIgnoreCase")
-  public static void
-  equals(
-      MethodHandle method, String thisObject, Object[] arguments, int hookId, Boolean returnValue) {
-    if (arguments[0] instanceof String && !returnValue) {
-      // The precise value of the result of the comparison is not used by libFuzzer as long as it is
-      // non-zero.
-      TraceDataFlowNativeCallbacks.traceStrcmp(thisObject, (String) arguments[0], 1, hookId);
-    }
-  }
-
-  @MethodHook(type = HookType.AFTER, targetClassName = "java.lang.Object", targetMethod = "equals")
-  @MethodHook(
-      type = HookType.AFTER, targetClassName = "java.lang.CharSequence", targetMethod = "equals")
-  @MethodHook(type = HookType.AFTER, targetClassName = "java.lang.Number", targetMethod = "equals")
-  public static void
-  genericEquals(
-      MethodHandle method, Object thisObject, Object[] arguments, int hookId, Boolean returnValue) {
-    if (!returnValue && arguments[0] != null && thisObject.getClass() == arguments[0].getClass()) {
-      TraceDataFlowNativeCallbacks.traceGenericCmp(thisObject, arguments[0], hookId);
-    }
-  }
-
-  @MethodHook(
-      type = HookType.AFTER, targetClassName = "java.lang.String", targetMethod = "compareTo")
-  @MethodHook(type = HookType.AFTER, targetClassName = "java.lang.String",
-      targetMethod = "compareToIgnoreCase")
-  public static void
-  compareTo(
-      MethodHandle method, String thisObject, Object[] arguments, int hookId, Integer returnValue) {
-    if (arguments[0] instanceof String && returnValue != 0) {
-      TraceDataFlowNativeCallbacks.traceStrcmp(
-          thisObject, (String) arguments[0], returnValue, hookId);
-    }
-  }
-
-  @MethodHook(
-      type = HookType.AFTER, targetClassName = "java.lang.String", targetMethod = "contentEquals")
-  public static void
-  contentEquals(
-      MethodHandle method, String thisObject, Object[] arguments, int hookId, Boolean returnValue) {
-    if (arguments[0] instanceof CharSequence && !returnValue) {
-      TraceDataFlowNativeCallbacks.traceStrcmp(
-          thisObject, ((CharSequence) arguments[0]).toString(), 1, hookId);
-    }
-  }
-
-  @MethodHook(type = HookType.AFTER, targetClassName = "java.lang.String",
-      targetMethod = "regionMatches", targetMethodDescriptor = "(ZILjava/lang/String;II)Z")
-  public static void
-  regionsMatches5(
-      MethodHandle method, Object thisObject, Object[] arguments, int hookId, Boolean returnValue) {
-    if (!returnValue) {
-      int toffset = (int) arguments[1];
-      String other = (String) arguments[2];
-      int ooffset = (int) arguments[3];
-      int len = (int) arguments[4];
-      regionMatchesInternal((String) thisObject, toffset, other, ooffset, len, hookId);
-    }
-  }
-
-  @MethodHook(type = HookType.AFTER, targetClassName = "java.lang.String",
-      targetMethod = "regionMatches", targetMethodDescriptor = "(ILjava/lang/String;II)Z")
-  public static void
-  regionMatches4(
-      MethodHandle method, Object thisObject, Object[] arguments, int hookId, Boolean returnValue) {
-    if (!returnValue) {
-      int toffset = (int) arguments[0];
-      String other = (String) arguments[1];
-      int ooffset = (int) arguments[2];
-      int len = (int) arguments[3];
-      regionMatchesInternal((String) thisObject, toffset, other, ooffset, len, hookId);
-    }
-  }
-
-  private static void regionMatchesInternal(
-      String thisString, int toffset, String other, int ooffset, int len, int hookId) {
-    if (toffset < 0 || ooffset < 0)
-      return;
-    int cappedThisStringEnd = Math.min(toffset + len, thisString.length());
-    int cappedOtherStringEnd = Math.min(ooffset + len, other.length());
-    String thisPart = thisString.substring(toffset, cappedThisStringEnd);
-    String otherPart = other.substring(ooffset, cappedOtherStringEnd);
-    TraceDataFlowNativeCallbacks.traceStrcmp(thisPart, otherPart, 1, hookId);
-  }
-
-  @MethodHook(
-      type = HookType.AFTER, targetClassName = "java.lang.String", targetMethod = "contains")
-  public static void
-  contains(
-      MethodHandle method, String thisObject, Object[] arguments, int hookId, Boolean returnValue) {
-    if (arguments[0] instanceof CharSequence && !returnValue) {
-      TraceDataFlowNativeCallbacks.traceStrstr(
-          thisObject, ((CharSequence) arguments[0]).toString(), hookId);
-    }
-  }
-
-  @MethodHook(type = HookType.AFTER, targetClassName = "java.lang.String", targetMethod = "indexOf")
-  @MethodHook(
-      type = HookType.AFTER, targetClassName = "java.lang.String", targetMethod = "lastIndexOf")
-  @MethodHook(
-      type = HookType.AFTER, targetClassName = "java.lang.StringBuffer", targetMethod = "indexOf")
-  @MethodHook(type = HookType.AFTER, targetClassName = "java.lang.StringBuffer",
-      targetMethod = "lastIndexOf")
-  @MethodHook(
-      type = HookType.AFTER, targetClassName = "java.lang.StringBuilder", targetMethod = "indexOf")
-  @MethodHook(type = HookType.AFTER, targetClassName = "java.lang.StringBuilder",
-      targetMethod = "lastIndexOf")
-  public static void
-  indexOf(
-      MethodHandle method, Object thisObject, Object[] arguments, int hookId, Integer returnValue) {
-    if (arguments[0] instanceof String && returnValue == -1) {
-      TraceDataFlowNativeCallbacks.traceStrstr(
-          thisObject.toString(), (String) arguments[0], hookId);
-    }
-  }
-
-  @MethodHook(
-      type = HookType.AFTER, targetClassName = "java.lang.String", targetMethod = "startsWith")
-  @MethodHook(
-      type = HookType.AFTER, targetClassName = "java.lang.String", targetMethod = "endsWith")
-  public static void
-  startsWith(
-      MethodHandle method, String thisObject, Object[] arguments, int hookId, Boolean returnValue) {
-    if (!returnValue) {
-      TraceDataFlowNativeCallbacks.traceStrstr(thisObject, (String) arguments[0], hookId);
-    }
-  }
-
-  @MethodHook(type = HookType.AFTER, targetClassName = "java.lang.String", targetMethod = "replace",
-      targetMethodDescriptor =
-          "(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Ljava/lang/String;")
-  public static void
-  replace(
-      MethodHandle method, Object thisObject, Object[] arguments, int hookId, String returnValue) {
-    String original = (String) thisObject;
-    // Report only if the replacement was not successful.
-    if (original.equals(returnValue)) {
-      String target = arguments[0].toString();
-      TraceDataFlowNativeCallbacks.traceStrstr(original, target, hookId);
-    }
-  }
-
-  @MethodHook(type = HookType.AFTER, targetClassName = "java.util.Arrays", targetMethod = "equals",
-      targetMethodDescriptor = "([B[B)Z")
-  public static void
-  arraysEquals(
-      MethodHandle method, Object thisObject, Object[] arguments, int hookId, Boolean returnValue) {
-    if (returnValue)
-      return;
-    byte[] first = (byte[]) arguments[0];
-    byte[] second = (byte[]) arguments[1];
-    TraceDataFlowNativeCallbacks.traceMemcmp(first, second, 1, hookId);
-  }
-
-  @MethodHook(type = HookType.AFTER, targetClassName = "java.util.Arrays", targetMethod = "equals",
-      targetMethodDescriptor = "([BII[BII)Z")
-  public static void
-  arraysEqualsRange(
-      MethodHandle method, Object thisObject, Object[] arguments, int hookId, Boolean returnValue) {
-    if (returnValue)
-      return;
-    byte[] first =
-        Arrays.copyOfRange((byte[]) arguments[0], (int) arguments[1], (int) arguments[2]);
-    byte[] second =
-        Arrays.copyOfRange((byte[]) arguments[3], (int) arguments[4], (int) arguments[5]);
-    TraceDataFlowNativeCallbacks.traceMemcmp(first, second, 1, hookId);
-  }
-
-  @MethodHook(type = HookType.AFTER, targetClassName = "java.util.Arrays", targetMethod = "compare",
-      targetMethodDescriptor = "([B[B)I")
-  @MethodHook(type = HookType.AFTER, targetClassName = "java.util.Arrays",
-      targetMethod = "compareUnsigned", targetMethodDescriptor = "([B[B)I")
-  public static void
-  arraysCompare(
-      MethodHandle method, Object thisObject, Object[] arguments, int hookId, Integer returnValue) {
-    if (returnValue == 0)
-      return;
-    byte[] first = (byte[]) arguments[0];
-    byte[] second = (byte[]) arguments[1];
-    TraceDataFlowNativeCallbacks.traceMemcmp(first, second, returnValue, hookId);
-  }
-
-  @MethodHook(type = HookType.AFTER, targetClassName = "java.util.Arrays", targetMethod = "compare",
-      targetMethodDescriptor = "([BII[BII)I")
-  @MethodHook(type = HookType.AFTER, targetClassName = "java.util.Arrays",
-      targetMethod = "compareUnsigned", targetMethodDescriptor = "([BII[BII)I")
-  public static void
-  arraysCompareRange(
-      MethodHandle method, Object thisObject, Object[] arguments, int hookId, Integer returnValue) {
-    if (returnValue == 0)
-      return;
-    byte[] first =
-        Arrays.copyOfRange((byte[]) arguments[0], (int) arguments[1], (int) arguments[2]);
-    byte[] second =
-        Arrays.copyOfRange((byte[]) arguments[3], (int) arguments[4], (int) arguments[5]);
-    TraceDataFlowNativeCallbacks.traceMemcmp(first, second, returnValue, hookId);
-  }
-
-  // The maximal number of elements of a non-TreeMap Map that will be sorted and searched for the
-  // key closest to the current lookup key in the mapGet hook.
-  private static final int MAX_NUM_KEYS_TO_ENUMERATE = 100;
-
-  @SuppressWarnings({"rawtypes", "unchecked"})
-  @MethodHook(type = HookType.AFTER, targetClassName = "java.util.Map", targetMethod = "get")
-  public static void mapGet(
-      MethodHandle method, Object thisObject, Object[] arguments, int hookId, Object returnValue) {
-    if (returnValue != null)
-      return;
-    if (thisObject == null)
-      return;
-    final Map map = (Map) thisObject;
-    if (map.size() == 0)
-      return;
-    final Object currentKey = arguments[0];
-    if (currentKey == null)
-      return;
-    // Find two valid map keys that bracket currentKey.
-    // This is a generalization of libFuzzer's __sanitizer_cov_trace_switch:
-    // https://github.com/llvm/llvm-project/blob/318942de229beb3b2587df09e776a50327b5cef0/compiler-rt/lib/fuzzer/FuzzerTracePC.cpp#L564
-    Object lowerBoundKey = null;
-    Object upperBoundKey = null;
-    try {
-      if (map instanceof TreeMap) {
-        final TreeMap treeMap = (TreeMap) map;
-        try {
-          lowerBoundKey = treeMap.floorKey(currentKey);
-          upperBoundKey = treeMap.ceilingKey(currentKey);
-        } catch (ClassCastException ignored) {
-          // Can be thrown by floorKey and ceilingKey if currentKey is of a type that can't be
-          // compared to the maps keys.
-        }
-      } else if (currentKey instanceof Comparable) {
-        final Comparable comparableCurrentKey = (Comparable) currentKey;
-        // Find two keys that bracket currentKey.
-        // Note: This is not deterministic if map.size() > MAX_NUM_KEYS_TO_ENUMERATE.
-        int enumeratedKeys = 0;
-        for (Object validKey : map.keySet()) {
-          if (!(validKey instanceof Comparable))
-            continue;
-          final Comparable comparableValidKey = (Comparable) validKey;
-          // If the key sorts lower than the non-existing key, but higher than the current lower
-          // bound, update the lower bound and vice versa for the upper bound.
-          try {
-            if (comparableValidKey.compareTo(comparableCurrentKey) < 0
-                && (lowerBoundKey == null || comparableValidKey.compareTo(lowerBoundKey) > 0)) {
-              lowerBoundKey = validKey;
-            }
-            if (comparableValidKey.compareTo(comparableCurrentKey) > 0
-                && (upperBoundKey == null || comparableValidKey.compareTo(upperBoundKey) < 0)) {
-              upperBoundKey = validKey;
-            }
-          } catch (ClassCastException ignored) {
-            // Can be thrown by floorKey and ceilingKey if currentKey is of a type that can't be
-            // compared to the maps keys.
-          }
-          if (enumeratedKeys++ > MAX_NUM_KEYS_TO_ENUMERATE)
-            break;
-        }
-      }
-    } catch (ConcurrentModificationException ignored) {
-      // map was modified by another thread, skip this invocation
-      return;
-    }
-    // Modify the hook ID so that compares against distinct valid keys are traced separately.
-    if (lowerBoundKey != null) {
-      TraceDataFlowNativeCallbacks.traceGenericCmp(
-          currentKey, lowerBoundKey, hookId + lowerBoundKey.hashCode());
-    }
-    if (upperBoundKey != null) {
-      TraceDataFlowNativeCallbacks.traceGenericCmp(
-          currentKey, upperBoundKey, hookId + upperBoundKey.hashCode());
-    }
-  }
-}
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/runtime/UnsafeProvider.java b/agent/src/main/java/com/code_intelligence/jazzer/runtime/UnsafeProvider.java
deleted file mode 100644
index 81f2a20..0000000
--- a/agent/src/main/java/com/code_intelligence/jazzer/runtime/UnsafeProvider.java
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright 2022 Code Intelligence GmbH
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.code_intelligence.jazzer.runtime;
-
-import java.lang.reflect.Field;
-import sun.misc.Unsafe;
-
-public final class UnsafeProvider {
-  private static final Unsafe UNSAFE = getUnsafeInternal();
-
-  public static Unsafe getUnsafe() {
-    return UNSAFE;
-  }
-
-  private static Unsafe getUnsafeInternal() {
-    try {
-      // The Java agent is loaded by the bootstrap class loader and should thus
-      // pass the security checks in getUnsafe.
-      return Unsafe.getUnsafe();
-    } catch (Throwable unused) {
-      // If not running as an agent, use the classical reflection trick to get an Unsafe instance,
-      // taking into account that the private field may have a name other than "theUnsafe":
-      // https://android.googlesource.com/platform/libcore/+/gingerbread/luni/src/main/java/sun/misc/Unsafe.java#32
-      try {
-        for (Field f : Unsafe.class.getDeclaredFields()) {
-          if (f.getType() == Unsafe.class) {
-            f.setAccessible(true);
-            return (Unsafe) f.get(null);
-          }
-        }
-        return null;
-      } catch (Throwable t) {
-        t.printStackTrace();
-        return null;
-      }
-    }
-  }
-}
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/utils/BUILD.bazel b/agent/src/main/java/com/code_intelligence/jazzer/utils/BUILD.bazel
deleted file mode 100644
index 10e3477..0000000
--- a/agent/src/main/java/com/code_intelligence/jazzer/utils/BUILD.bazel
+++ /dev/null
@@ -1,15 +0,0 @@
-load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library")
-
-kt_jvm_library(
-    name = "utils",
-    srcs = [
-        "ClassNameGlobber.kt",
-        "ExceptionUtils.kt",
-        "ManifestUtils.kt",
-        "Utils.kt",
-    ],
-    visibility = ["//visibility:public"],
-    deps = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/api",
-    ],
-)
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/utils/ClassNameGlobber.kt b/agent/src/main/java/com/code_intelligence/jazzer/utils/ClassNameGlobber.kt
deleted file mode 100644
index 44249c8..0000000
--- a/agent/src/main/java/com/code_intelligence/jazzer/utils/ClassNameGlobber.kt
+++ /dev/null
@@ -1,115 +0,0 @@
-// Copyright 2021 Code Intelligence GmbH
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.code_intelligence.jazzer.utils
-
-private val BASE_INCLUDED_CLASS_NAME_GLOBS = listOf(
-    "**", // everything
-)
-
-// We use both a strong indicator for running as a Bazel test together with an indicator for a
-// Bazel coverage run to rule out false positives.
-private val IS_BAZEL_COVERAGE_RUN = System.getenv("TEST_UNDECLARED_OUTPUTS_DIR") != null &&
-    System.getenv("COVERAGE_DIR") != null
-
-private val ADDITIONAL_EXCLUDED_NAME_GLOBS_FOR_BAZEL_COVERAGE = listOf(
-    "com.google.testing.coverage.**",
-    "org.jacoco.**",
-)
-
-private val BASE_EXCLUDED_CLASS_NAME_GLOBS = listOf(
-    // JDK internals
-    "\\[**", // array types
-    "java.**",
-    "javax.**",
-    "jdk.**",
-    "sun.**",
-    "com.sun.**", // package for Proxy objects
-    // Azul JDK internals
-    "com.azul.tooling.**",
-    // Kotlin internals
-    "kotlin.**",
-    // Jazzer internals
-    "com.code_intelligence.jazzer.**",
-    "jaz.Ter", // safe companion of the honeypot class used by sanitizers
-    "jaz.Zer", // honeypot class used by sanitizers
-) + if (IS_BAZEL_COVERAGE_RUN) ADDITIONAL_EXCLUDED_NAME_GLOBS_FOR_BAZEL_COVERAGE else listOf()
-
-class ClassNameGlobber(includes: List<String>, excludes: List<String>) {
-    // If no include globs are provided, start with all classes.
-    private val includeMatchers = includes.ifEmpty { BASE_INCLUDED_CLASS_NAME_GLOBS }
-        .map(::SimpleGlobMatcher)
-
-    // If no include globs are provided, additionally exclude stdlib classes as well as our own classes.
-    private val excludeMatchers = (if (includes.isEmpty()) BASE_EXCLUDED_CLASS_NAME_GLOBS + excludes else excludes)
-        .map(::SimpleGlobMatcher)
-
-    fun includes(className: String): Boolean {
-        return includeMatchers.any { it.matches(className) } && excludeMatchers.none { it.matches(className) }
-    }
-}
-
-class SimpleGlobMatcher(val glob: String) {
-    private enum class Type {
-        // foo.bar (matches foo.bar only)
-        FULL_MATCH,
-        // foo.** (matches foo.bar and foo.bar.baz)
-        PATH_WILDCARD_SUFFIX,
-        // foo.* (matches foo.bar, but not foo.bar.baz)
-        SEGMENT_WILDCARD_SUFFIX,
-    }
-
-    private val type: Type
-    private val prefix: String
-
-    init {
-        // Remain compatible with globs such as "\\[" that use escaping.
-        val pattern = glob.replace("\\", "")
-        when {
-            !pattern.contains('*') -> {
-                type = Type.FULL_MATCH
-                prefix = pattern
-            }
-            // Ends with "**" and contains no other '*'.
-            pattern.endsWith("**") && pattern.indexOf('*') == pattern.length - 2 -> {
-                type = Type.PATH_WILDCARD_SUFFIX
-                prefix = pattern.removeSuffix("**")
-            }
-            // Ends with "*" and contains no other '*'.
-            pattern.endsWith('*') && pattern.indexOf('*') == pattern.length - 1 -> {
-                type = Type.SEGMENT_WILDCARD_SUFFIX
-                prefix = pattern.removeSuffix("*")
-            }
-            else -> throw IllegalArgumentException(
-                "Unsupported glob pattern (only foo.bar, foo.* and foo.** are supported): $pattern"
-            )
-        }
-    }
-
-    /**
-     * Checks whether [maybeInternalClassName], which may be internal (foo/bar) or not (foo.bar), matches [glob].
-     */
-    fun matches(maybeInternalClassName: String): Boolean {
-        val className = maybeInternalClassName.replace('/', '.')
-        return when (type) {
-            Type.FULL_MATCH -> className == prefix
-            Type.PATH_WILDCARD_SUFFIX -> className.startsWith(prefix)
-            Type.SEGMENT_WILDCARD_SUFFIX -> {
-                // className starts with prefix and contains no further '.'.
-                className.startsWith(prefix) &&
-                    className.indexOf('.', startIndex = prefix.length) == -1
-            }
-        }
-    }
-}
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/utils/Utils.kt b/agent/src/main/java/com/code_intelligence/jazzer/utils/Utils.kt
deleted file mode 100644
index 1b399ba..0000000
--- a/agent/src/main/java/com/code_intelligence/jazzer/utils/Utils.kt
+++ /dev/null
@@ -1,107 +0,0 @@
-// Copyright 2021 Code Intelligence GmbH
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-@file:JvmName("Utils")
-
-package com.code_intelligence.jazzer.utils
-
-import java.lang.reflect.Executable
-import java.lang.reflect.Method
-import java.nio.ByteBuffer
-import java.nio.channels.FileChannel
-
-val Class<*>.descriptor: String
-    get() = when {
-        isPrimitive -> {
-            when (this) {
-                Boolean::class.javaPrimitiveType -> "Z"
-                Byte::class.javaPrimitiveType -> "B"
-                Char::class.javaPrimitiveType -> "C"
-                Short::class.javaPrimitiveType -> "S"
-                Int::class.javaPrimitiveType -> "I"
-                Long::class.javaPrimitiveType -> "J"
-                Float::class.javaPrimitiveType -> "F"
-                Double::class.javaPrimitiveType -> "D"
-                java.lang.Void::class.javaPrimitiveType -> "V"
-                else -> throw IllegalStateException("Unknown primitive type: $name")
-            }
-        }
-        isArray -> "[${componentType.descriptor}"
-        java.lang.Object::class.java.isAssignableFrom(this) -> "L${name.replace('.', '/')};"
-        else -> throw IllegalArgumentException("Unknown class type: $name")
-    }
-
-val Class<*>.readableDescriptor: String
-    get() = when {
-        isPrimitive -> {
-            when (this) {
-                Boolean::class.javaPrimitiveType -> "boolean"
-                Byte::class.javaPrimitiveType -> "byte"
-                Char::class.javaPrimitiveType -> "char"
-                Short::class.javaPrimitiveType -> "short"
-                Int::class.javaPrimitiveType -> "int"
-                Long::class.javaPrimitiveType -> "long"
-                Float::class.javaPrimitiveType -> "float"
-                Double::class.javaPrimitiveType -> "double"
-                java.lang.Void::class.javaPrimitiveType -> "void"
-                else -> throw IllegalStateException("Unknown primitive type: $name")
-            }
-        }
-        isArray -> "${componentType.readableDescriptor}[]"
-        java.lang.Object::class.java.isAssignableFrom(this) -> name
-        else -> throw IllegalArgumentException("Unknown class type: $name")
-    }
-
-val Executable.descriptor: String
-    get() = parameterTypes.joinToString(separator = "", prefix = "(", postfix = ")") { parameterType ->
-        parameterType.descriptor
-    } + if (this is Method) returnType.descriptor else "V"
-
-// This does not include the return type as the parameter descriptors already uniquely identify the executable.
-val Executable.readableDescriptor: String
-    get() = parameterTypes.joinToString(separator = ",", prefix = "(", postfix = ")") { parameterType ->
-        parameterType.readableDescriptor
-    }
-
-fun simpleFastHash(vararg strings: String): Int {
-    var hash = 0
-    for (string in strings) {
-        for (c in string) {
-            hash = hash * 11 + c.code
-        }
-    }
-    return hash
-}
-
-/**
- * Reads the [FileChannel] to the end as a UTF-8 string.
- */
-fun FileChannel.readFully(): String {
-    check(size() <= Int.MAX_VALUE)
-    val buffer = ByteBuffer.allocate(size().toInt())
-    while (buffer.hasRemaining()) {
-        when (read(buffer)) {
-            0 -> throw IllegalStateException("No bytes read")
-            -1 -> break
-        }
-    }
-    return String(buffer.array())
-}
-
-/**
- * Appends [string] to the end of the [FileChannel].
- */
-fun FileChannel.append(string: String) {
-    position(size())
-    write(ByteBuffer.wrap(string.toByteArray()))
-}
diff --git a/agent/src/main/java/jaz/BUILD.bazel b/agent/src/main/java/jaz/BUILD.bazel
deleted file mode 100644
index c6cdcf1..0000000
--- a/agent/src/main/java/jaz/BUILD.bazel
+++ /dev/null
@@ -1,8 +0,0 @@
-filegroup(
-    name = "jaz",
-    srcs = [
-        "Ter.java",
-        "Zer.java",
-    ],
-    visibility = ["//agent/src/main/java/com/code_intelligence/jazzer/api:__pkg__"],
-)
diff --git a/agent/src/main/java/jaz/Zer.java b/agent/src/main/java/jaz/Zer.java
deleted file mode 100644
index 08ca3d2..0000000
--- a/agent/src/main/java/jaz/Zer.java
+++ /dev/null
@@ -1,234 +0,0 @@
-// Copyright 2021 Code Intelligence GmbH
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package jaz;
-
-import com.code_intelligence.jazzer.api.FuzzerSecurityIssueHigh;
-import com.code_intelligence.jazzer.api.Jazzer;
-import java.io.Closeable;
-import java.io.Flushable;
-import java.io.Serializable;
-import java.util.*;
-import java.util.concurrent.Callable;
-import java.util.function.Function;
-
-/**
- * A honeypot class that reports a finding on initialization.
- *
- * Class loading based on externally controlled data could lead to RCE
- * depending on available classes on the classpath. Even if no applicable
- * gadget class is available, allowing input to control class loading is a bad
- * idea and should be prevented. A finding is generated whenever the class
- * is loaded and initialized, regardless of its further use.
- * <p>
- * This class needs to implement {@link Serializable} to be considered in
- * deserialization scenarios. It also implements common constructors, getter
- * and setter and common interfaces to increase chances of passing
- * deserialization checks.
- * <p>
- * <b>Note</b>: Jackson provides a nice list of "nasty classes" at
- * <a
- * href=https://github.com/FasterXML/jackson-databind/blob/2.14/src/main/java/com/fasterxml/jackson/databind/jsontype/impl/SubTypeValidator.java>SubTypeValidator</a>.
- * <p>
- * <b>Note</b>: This class must not be referenced in any way by the rest of the code, not even
- * statically. When referring to it, always use its hardcoded class name {@code jaz.Zer}.
- */
-@SuppressWarnings({"rawtypes", "unused"})
-public class Zer
-    implements Serializable, Cloneable, Comparable<Zer>, Comparator, Closeable, Flushable, Iterable,
-               Iterator, Runnable, Callable, Function, Collection, List {
-  static final long serialVersionUID = 42L;
-
-  static {
-    Jazzer.reportFindingFromHook(new FuzzerSecurityIssueHigh("Remote Code Execution\n"
-        + "Unrestricted class loading based on externally controlled data may allow\n"
-        + "remote code execution depending on available classes on the classpath."));
-  }
-
-  // Common constructors
-
-  public Zer() {}
-
-  public Zer(String arg1) {}
-
-  public Zer(String arg1, Throwable arg2) {}
-
-  // Getter/Setter
-
-  public Object getJaz() {
-    return this;
-  }
-
-  public void setJaz(String jaz) {}
-
-  // Common interface stubs
-
-  @Override
-  public void close() {}
-
-  @Override
-  public void flush() {}
-
-  @Override
-  public int compareTo(Zer o) {
-    return 0;
-  }
-
-  @Override
-  public int compare(Object o1, Object o2) {
-    return 0;
-  }
-
-  @Override
-  public int size() {
-    return 0;
-  }
-
-  @Override
-  public boolean isEmpty() {
-    return false;
-  }
-
-  @Override
-  public boolean contains(Object o) {
-    return false;
-  }
-
-  @Override
-  public Object[] toArray() {
-    return new Object[0];
-  }
-
-  @Override
-  public boolean add(Object o) {
-    return false;
-  }
-
-  @Override
-  public boolean remove(Object o) {
-    return false;
-  }
-
-  @Override
-  public boolean addAll(Collection c) {
-    return false;
-  }
-
-  @Override
-  public boolean addAll(int index, Collection c) {
-    return false;
-  }
-
-  @Override
-  public void clear() {}
-
-  @Override
-  public Object get(int index) {
-    return this;
-  }
-
-  @Override
-  public Object set(int index, Object element) {
-    return this;
-  }
-
-  @Override
-  public void add(int index, Object element) {}
-
-  @Override
-  public Object remove(int index) {
-    return this;
-  }
-
-  @Override
-  public int indexOf(Object o) {
-    return 0;
-  }
-
-  @Override
-  public int lastIndexOf(Object o) {
-    return 0;
-  }
-
-  @Override
-  @SuppressWarnings("ConstantConditions")
-  public ListIterator listIterator() {
-    return null;
-  }
-
-  @Override
-  @SuppressWarnings("ConstantConditions")
-  public ListIterator listIterator(int index) {
-    return null;
-  }
-
-  @Override
-  public List subList(int fromIndex, int toIndex) {
-    return this;
-  }
-
-  @Override
-  public boolean retainAll(Collection c) {
-    return false;
-  }
-
-  @Override
-  public boolean removeAll(Collection c) {
-    return false;
-  }
-
-  @Override
-  public boolean containsAll(Collection c) {
-    return false;
-  }
-
-  @Override
-  public Object[] toArray(Object[] a) {
-    return new Object[0];
-  }
-
-  @Override
-  public Iterator iterator() {
-    return this;
-  }
-
-  @Override
-  public void run() {}
-
-  @Override
-  public boolean hasNext() {
-    return false;
-  }
-
-  @Override
-  public Object next() {
-    return this;
-  }
-
-  @Override
-  public Object call() throws Exception {
-    return this;
-  }
-
-  @Override
-  public Object apply(Object o) {
-    return this;
-  }
-
-  @Override
-  @SuppressWarnings("MethodDoesntCallSuperMethod")
-  public Object clone() {
-    return this;
-  }
-}
diff --git a/agent/src/main/native/com/code_intelligence/jazzer/runtime/BUILD.bazel b/agent/src/main/native/com/code_intelligence/jazzer/runtime/BUILD.bazel
deleted file mode 100644
index 7d91047..0000000
--- a/agent/src/main/native/com/code_intelligence/jazzer/runtime/BUILD.bazel
+++ /dev/null
@@ -1,8 +0,0 @@
-load("@fmeum_rules_jni//jni:defs.bzl", "cc_jni_library")
-
-cc_jni_library(
-    name = "jazzer_signal_handler",
-    srcs = ["signal_handler.cpp"],
-    visibility = ["//agent/src/main/java/com/code_intelligence/jazzer/runtime:__pkg__"],
-    deps = ["//agent/src/main/java/com/code_intelligence/jazzer/runtime:signal_handler.hdrs"],
-)
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/api/BUILD.bazel b/agent/src/test/java/com/code_intelligence/jazzer/api/BUILD.bazel
deleted file mode 100644
index f2537b7..0000000
--- a/agent/src/test/java/com/code_intelligence/jazzer/api/BUILD.bazel
+++ /dev/null
@@ -1,22 +0,0 @@
-java_test(
-    name = "AutofuzzTest",
-    size = "small",
-    srcs = [
-        "AutofuzzTest.java",
-    ],
-    env = {
-        # Also consider implementing classes from com.code_intelligence.jazzer.*.
-        "JAZZER_AUTOFUZZ_TESTING": "1",
-    },
-    test_class = "com.code_intelligence.jazzer.api.AutofuzzTest",
-    runtime_deps = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/autofuzz",
-        # Needed for JazzerInternal.
-        "//agent/src/main/java/com/code_intelligence/jazzer/runtime",
-    ],
-    deps = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/api",
-        "//driver/src/main/native/com/code_intelligence/jazzer/driver:jazzer_driver",
-        "@maven//:junit_junit",
-    ],
-)
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/autofuzz/BUILD.bazel b/agent/src/test/java/com/code_intelligence/jazzer/autofuzz/BUILD.bazel
deleted file mode 100644
index f8448f0..0000000
--- a/agent/src/test/java/com/code_intelligence/jazzer/autofuzz/BUILD.bazel
+++ /dev/null
@@ -1,69 +0,0 @@
-java_test(
-    name = "MetaTest",
-    size = "small",
-    srcs = [
-        "MetaTest.java",
-    ],
-    test_class = "com.code_intelligence.jazzer.autofuzz.MetaTest",
-    deps = [
-        ":test_helpers",
-        "//agent/src/main/java/com/code_intelligence/jazzer/api",
-        "//agent/src/main/java/com/code_intelligence/jazzer/autofuzz",
-        "@maven//:com_mikesamuel_json_sanitizer",
-        "@maven//:junit_junit",
-    ],
-)
-
-java_test(
-    name = "InterfaceCreationTest",
-    size = "small",
-    srcs = [
-        "InterfaceCreationTest.java",
-    ],
-    env = {
-        # Also consider implementing classes from com.code_intelligence.jazzer.*.
-        "JAZZER_AUTOFUZZ_TESTING": "1",
-    },
-    test_class = "com.code_intelligence.jazzer.autofuzz.InterfaceCreationTest",
-    deps = [
-        ":test_helpers",
-        "@maven//:junit_junit",
-    ],
-)
-
-java_test(
-    name = "BuilderPatternTest",
-    size = "small",
-    srcs = [
-        "BuilderPatternTest.java",
-    ],
-    test_class = "com.code_intelligence.jazzer.autofuzz.BuilderPatternTest",
-    deps = [
-        ":test_helpers",
-        "@maven//:junit_junit",
-    ],
-)
-
-java_test(
-    name = "SettersTest",
-    size = "small",
-    srcs = [
-        "SettersTest.java",
-    ],
-    test_class = "com.code_intelligence.jazzer.autofuzz.SettersTest",
-    deps = [
-        ":test_helpers",
-        "//agent/src/test/java/com/code_intelligence/jazzer/autofuzz/testdata:test_data",
-        "@maven//:junit_junit",
-    ],
-)
-
-java_library(
-    name = "test_helpers",
-    srcs = ["TestHelpers.java"],
-    deps = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/api",
-        "//agent/src/main/java/com/code_intelligence/jazzer/autofuzz",
-        "@maven//:junit_junit",
-    ],
-)
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/autofuzz/InterfaceCreationTest.java b/agent/src/test/java/com/code_intelligence/jazzer/autofuzz/InterfaceCreationTest.java
deleted file mode 100644
index 4d85ca6..0000000
--- a/agent/src/test/java/com/code_intelligence/jazzer/autofuzz/InterfaceCreationTest.java
+++ /dev/null
@@ -1,111 +0,0 @@
-// Copyright 2021 Code Intelligence GmbH
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.code_intelligence.jazzer.autofuzz;
-
-import static com.code_intelligence.jazzer.autofuzz.TestHelpers.consumeTestCase;
-
-import java.util.Arrays;
-import java.util.Objects;
-import org.junit.Test;
-
-interface InterfaceA {
-  void foo();
-
-  void bar();
-}
-
-abstract class ClassA1 implements InterfaceA {
-  @Override
-  public void foo() {}
-}
-
-class ClassB1 extends ClassA1 {
-  int n;
-
-  public ClassB1(int _n) {
-    n = _n;
-  }
-
-  @Override
-  public void bar() {}
-
-  @Override
-  public boolean equals(Object o) {
-    if (this == o)
-      return true;
-    if (o == null || getClass() != o.getClass())
-      return false;
-    ClassB1 classB1 = (ClassB1) o;
-    return n == classB1.n;
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(n);
-  }
-}
-
-class ClassB2 implements InterfaceA {
-  String s;
-
-  public ClassB2(String _s) {
-    s = _s;
-  }
-
-  @Override
-  public void foo() {}
-
-  @Override
-  public void bar() {}
-
-  @Override
-  public boolean equals(Object o) {
-    if (this == o)
-      return true;
-    if (o == null || getClass() != o.getClass())
-      return false;
-    ClassB2 classB2 = (ClassB2) o;
-    return Objects.equals(s, classB2.s);
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(s);
-  }
-}
-
-public class InterfaceCreationTest {
-  @Test
-  public void testConsumeInterface() {
-    consumeTestCase(InterfaceA.class, new ClassB1(5),
-        "(com.code_intelligence.jazzer.autofuzz.InterfaceA) new com.code_intelligence.jazzer.autofuzz.ClassB1(5)",
-        Arrays.asList((byte) 1, // do not return null
-            0, // pick ClassB1
-            (byte) 1, // do not return null
-            0, // pick first constructor
-            5 // arg for ClassB1 constructor
-            ));
-    consumeTestCase(InterfaceA.class, new ClassB2("test"),
-        "(com.code_intelligence.jazzer.autofuzz.InterfaceA) new com.code_intelligence.jazzer.autofuzz.ClassB2(\"test\")",
-        Arrays.asList((byte) 1, // do not return null
-            1, // pick ClassB2
-            (byte) 1, // do not return null
-            0, // pick first constructor
-            (byte) 1, // do not return null
-            8, // remaining bytes
-            "test" // arg for ClassB2 constructor
-            ));
-  }
-}
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/runtime/BUILD.bazel b/agent/src/test/java/com/code_intelligence/jazzer/runtime/BUILD.bazel
deleted file mode 100644
index 97ac4f6..0000000
--- a/agent/src/test/java/com/code_intelligence/jazzer/runtime/BUILD.bazel
+++ /dev/null
@@ -1,38 +0,0 @@
-load("//bazel:compat.bzl", "SKIP_ON_WINDOWS")
-
-java_test(
-    name = "FuzzedDataProviderImplTest",
-    srcs = ["FuzzedDataProviderImplTest.java"],
-    use_testrunner = False,
-    deps = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/api",
-        "//agent/src/main/java/com/code_intelligence/jazzer/runtime:fuzzed_data_provider",
-        "//driver/src/main/native/com/code_intelligence/jazzer/driver:jazzer_driver",
-    ],
-)
-
-java_test(
-    name = "RecordingFuzzedDataProviderTest",
-    srcs = [
-        "RecordingFuzzedDataProviderTest.java",
-    ],
-    deps = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/api",
-        "//agent/src/main/java/com/code_intelligence/jazzer/runtime",
-        "//agent/src/main/java/com/code_intelligence/jazzer/runtime:fuzzed_data_provider",
-        "@maven//:junit_junit",
-    ],
-)
-
-java_test(
-    name = "TraceCmpHooksTest",
-    srcs = [
-        "TraceCmpHooksTest.java",
-    ],
-    target_compatible_with = SKIP_ON_WINDOWS,
-    deps = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/runtime",
-        "//driver/src/main/native/com/code_intelligence/jazzer/driver:jazzer_driver",
-        "@maven//:junit_junit",
-    ],
-)
diff --git a/bazel/compat.bzl b/bazel/compat.bzl
index 2815db2..45db487 100644
--- a/bazel/compat.bzl
+++ b/bazel/compat.bzl
@@ -21,3 +21,16 @@
     "@platforms//os:windows": ["@platforms//:incompatible"],
     "//conditions:default": [],
 })
+
+LINUX_ONLY = select({
+    "@platforms//os:linux": [],
+    "//conditions:default": ["@platforms//:incompatible"],
+})
+
+MULTI_PLATFORM = select({
+    "@platforms//os:macos": [
+        "//bazel/platforms:macos_arm64",
+        "//bazel/platforms:macos_x86_64",
+    ],
+    "//conditions:default": [],
+})
diff --git a/bazel/fuzz_target.bzl b/bazel/fuzz_target.bzl
index c70543b..62c7a7b 100644
--- a/bazel/fuzz_target.bzl
+++ b/bazel/fuzz_target.bzl
@@ -15,59 +15,85 @@
 def java_fuzz_target_test(
         name,
         target_class = None,
+        target_method = None,
         deps = [],
-        hook_classes = [],
+        runtime_deps = [],
+        hook_jar = None,
         data = [],
-        sanitizer = None,
-        visibility = None,
+        launcher_variant = "java",
         tags = [],
         fuzzer_args = [],
         srcs = [],
         size = None,
         timeout = None,
         env = None,
+        env_inherit = None,
         verify_crash_input = True,
         verify_crash_reproducer = True,
-        expect_crash = True,
-        # Default is that the reproducer does not throw any exception.
-        expected_findings = [],
+        # Superset of the findings the fuzzer is expected to find. Since fuzzing runs are not
+        # deterministic across OSes, pinpointing the exact set of findings is difficult.
+        allowed_findings = [],
+        # By default, expect a crash iff allowed_findings isn't empty.
+        expect_crash = None,
         **kwargs):
-    target_name = name + "_target"
-    deploy_manifest_lines = []
     if target_class:
-        deploy_manifest_lines.append("Jazzer-Fuzz-Target-Class: %s" % target_class)
-    if hook_classes:
-        deploy_manifest_lines.append("Jazzer-Hook-Classes: %s" % ":".join(hook_classes))
+        fuzzer_args = fuzzer_args + ["--target_class=" + target_class]
+    if target_method:
+        fuzzer_args = fuzzer_args + ["--target_method=" + target_method]
+    if expect_crash == None:
+        expect_crash = len(allowed_findings) != 0
+
+    target_name = name + "_target"
+    target_deploy_jar = target_name + "_deploy.jar"
 
     # Deps can only be specified on java_binary targets with sources, which
     # excludes e.g. Kotlin libraries wrapped into java_binary via runtime_deps.
-    target_deps = deps + ["//agent:jazzer_api_compile_only"] if srcs else []
+    deps = deps + ["//deploy:jazzer-api"] if srcs else []
     native.java_binary(
         name = target_name,
         srcs = srcs,
-        visibility = ["//visibility:private"],
         create_executable = False,
-        deploy_manifest_lines = deploy_manifest_lines,
-        deps = target_deps,
+        visibility = ["//visibility:private"],
+        deps = deps,
+        runtime_deps = runtime_deps,
         testonly = True,
+        tags = tags,
         **kwargs
     )
 
-    if sanitizer == None:
-        driver = "//driver:jazzer_driver"
-    elif sanitizer == "address":
-        driver = "//driver:jazzer_driver_asan"
-    elif sanitizer == "undefined":
-        driver = "//driver:jazzer_driver_ubsan"
+    if launcher_variant == "java":
+        # With the Java driver, we expect fuzz targets to depend on Jazzer
+        # rather than have the launcher start a JVM with Jazzer on the class
+        # path.
+        native.java_import(
+            name = target_name + "_import",
+            jars = [target_deploy_jar],
+            testonly = True,
+            tags = tags,
+        )
+        target_with_driver_name = target_name + "_driver"
+        native.java_binary(
+            name = target_with_driver_name,
+            runtime_deps = [
+                target_name + "_import",
+                "//src/main/java/com/code_intelligence/jazzer:jazzer_import",
+            ],
+            main_class = "com.code_intelligence.jazzer.Jazzer",
+            testonly = True,
+            tags = tags,
+        )
+
+    if launcher_variant == "native":
+        driver = "//launcher:jazzer"
+    elif launcher_variant == "java":
+        driver = target_with_driver_name
     else:
-        fail("Invalid sanitizer: " + sanitizer)
+        fail("Invalid launcher variant: " + launcher_variant)
 
     native.java_test(
         name = name,
         runtime_deps = [
             "//bazel/tools/java:fuzz_target_test_wrapper",
-            "//agent:jazzer_api_deploy.jar",
-            ":%s_deploy.jar" % target_name,
         ],
         jvm_flags = [
             # Use the same memory settings for reproducers as those suggested by Jazzer when
@@ -78,26 +104,27 @@
         ],
         size = size or "enormous",
         timeout = timeout or "moderate",
+        # args are shell tokenized and thus quotes are required in the case where arguments
+        # are empty.
         args = [
-            "$(rootpath %s)" % driver,
-            "$(rootpath //agent:jazzer_api_deploy.jar)",
-            "$(rootpath :%s_deploy.jar)" % target_name,
+            "$(rlocationpath %s)" % driver,
+            "$(rlocationpath //deploy:jazzer-api)",
+            "$(rlocationpath %s)" % target_deploy_jar,
+            "$(rlocationpath %s)" % hook_jar if hook_jar else "''",
             str(verify_crash_input),
             str(verify_crash_reproducer),
             str(expect_crash),
-            # args are shell tokenized and thus quotes are required in the case where
-            # expected_findings is empty.
-            "'" + ",".join(expected_findings) + "'",
+            str(launcher_variant == "java"),
+            "'" + ",".join(allowed_findings) + "'",
         ] + fuzzer_args,
         data = [
-            ":%s_deploy.jar" % target_name,
-            "//agent:jazzer_agent_deploy",
-            "//agent:jazzer_api_deploy.jar",
+            target_deploy_jar,
+            "//deploy:jazzer-api",
             driver,
-        ] + data,
+        ] + data + ([hook_jar] if hook_jar else []),
         env = env,
+        env_inherit = env_inherit,
         main_class = "com.code_intelligence.jazzer.tools.FuzzTargetTestWrapper",
         use_testrunner = False,
         tags = tags,
-        visibility = visibility,
     )
diff --git a/bazel/jar.bzl b/bazel/jar.bzl
index b4de362..c37b9d4 100644
--- a/bazel/jar.bzl
+++ b/bazel/jar.bzl
@@ -21,6 +21,7 @@
     args.add(ctx.file.jar)
     args.add(out_jar)
     args.add_all(ctx.attr.paths_to_strip)
+    args.add_all(ctx.attr.paths_to_keep, format_each = "+%s")
     ctx.actions.run(
         outputs = [out_jar],
         inputs = [ctx.file.jar],
@@ -31,8 +32,6 @@
     return [
         DefaultInfo(
             files = depset([out_jar]),
-            # Workaround for https://github.com/bazelbuild/bazel/issues/15043.
-            runfiles = ctx.runfiles(files = [out_jar]),
         ),
         coverage_common.instrumented_files_info(
             ctx,
@@ -49,6 +48,7 @@
             allow_single_file = [".jar"],
         ),
         "paths_to_strip": attr.string_list(),
+        "paths_to_keep": attr.string_list(),
         "_jar_stripper": attr.label(
             default = "//bazel/tools/java:JarStripper",
             cfg = "exec",
diff --git a/bazel/kotlin.bzl b/bazel/kotlin.bzl
index 873d439..8b25720 100644
--- a/bazel/kotlin.bzl
+++ b/bazel/kotlin.bzl
@@ -12,7 +12,9 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+load("@io_bazel_rules_kotlin//kotlin:lint.bzl", "ktlint_fix", "ktlint_test")
 load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_test")
+load("//bazel:compat.bzl", "SKIP_ON_WINDOWS")
 
 # A kt_jvm_test wrapped in a java_test for Windows compatibility.
 # Workaround for https://github.com/bazelbuild/rules_kotlin/issues/599: rules_kotlin can only create
@@ -49,3 +51,18 @@
             ":" + kt_jvm_test_name,
         ],
     )
+
+def ktlint(name = "ktlint"):
+    ktlint_test(
+        name = name + "_test",
+        srcs = native.glob(["**/*.kt"]),
+        config = Label("//bazel/toolchains:ktlint_config"),
+        target_compatible_with = SKIP_ON_WINDOWS,
+    )
+
+    ktlint_fix(
+        name = name + "_fix",
+        srcs = native.glob(["**/*.kt"]),
+        config = Label("//bazel/toolchains:ktlint_config"),
+        target_compatible_with = SKIP_ON_WINDOWS,
+    )
diff --git a/bazel/platforms/BUILD.bazel b/bazel/platforms/BUILD.bazel
new file mode 100644
index 0000000..568c41e
--- /dev/null
+++ b/bazel/platforms/BUILD.bazel
@@ -0,0 +1,26 @@
+platform(
+    name = "x64_windows-clang-cl",
+    constraint_values = [
+        "@platforms//cpu:x86_64",
+        "@platforms//os:windows",
+        "@bazel_tools//tools/cpp:clang-cl",
+    ],
+)
+
+platform(
+    name = "macos_x86_64",
+    constraint_values = [
+        "@platforms//cpu:x86_64",
+        "@platforms//os:macos",
+    ],
+    visibility = ["//:__subpackages__"],
+)
+
+platform(
+    name = "macos_arm64",
+    constraint_values = [
+        "@platforms//cpu:arm64",
+        "@platforms//os:macos",
+    ],
+    visibility = ["//:__subpackages__"],
+)
diff --git a/bazel/toolchains/BUILD.bazel b/bazel/toolchains/BUILD.bazel
new file mode 100644
index 0000000..dfce025
--- /dev/null
+++ b/bazel/toolchains/BUILD.bazel
@@ -0,0 +1,32 @@
+load("@bazel_tools//tools/jdk:default_java_toolchain.bzl", "NONPREBUILT_TOOLCHAIN_CONFIGURATION", "default_java_toolchain")
+load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "define_kt_toolchain")
+load("@io_bazel_rules_kotlin//kotlin:lint.bzl", "ktlint_config")
+load("@io_bazel_rules_kotlin//kotlin/internal:opts.bzl", "kt_javac_options", "kt_kotlinc_options")
+
+default_java_toolchain(
+    name = "java_non_prebuilt",
+    configuration = NONPREBUILT_TOOLCHAIN_CONFIGURATION,
+)
+
+kt_kotlinc_options(
+    name = "kotlinc_options",
+)
+
+kt_javac_options(
+    name = "default_javac_options",
+)
+
+define_kt_toolchain(
+    name = "kotlin_toolchain",
+    api_version = "1.5",
+    javac_options = ":default_javac_options",
+    jvm_target = "1.8",
+    kotlinc_options = ":kotlinc_options",
+    language_version = "1.5",
+)
+
+ktlint_config(
+    name = "ktlint_config",
+    editorconfig = "editorconfig.ktlint",
+    visibility = ["//visibility:public"],
+)
diff --git a/bazel/toolchains/editorconfig.ktlint b/bazel/toolchains/editorconfig.ktlint
new file mode 100644
index 0000000..eacf0c4
--- /dev/null
+++ b/bazel/toolchains/editorconfig.ktlint
@@ -0,0 +1,2 @@
+[*.kt]
+ktlint_standard_package-name=disabled
diff --git a/bazel/tools/java/com/code_intelligence/jazzer/tools/FuzzTargetTestWrapper.java b/bazel/tools/java/com/code_intelligence/jazzer/tools/FuzzTargetTestWrapper.java
index 107d852..fe59c05 100644
--- a/bazel/tools/java/com/code_intelligence/jazzer/tools/FuzzTargetTestWrapper.java
+++ b/bazel/tools/java/com/code_intelligence/jazzer/tools/FuzzTargetTestWrapper.java
@@ -13,9 +13,16 @@
 // limitations under the License.
 package com.code_intelligence.jazzer.tools;
 
+import static java.util.stream.Collectors.toList;
+
+import com.google.devtools.build.runfiles.AutoBazelRepository;
 import com.google.devtools.build.runfiles.Runfiles;
+import java.io.BufferedReader;
 import java.io.File;
 import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.lang.ProcessBuilder.Redirect;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 import java.net.URL;
@@ -25,46 +32,58 @@
 import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import javax.tools.JavaCompiler;
 import javax.tools.JavaCompiler.CompilationTask;
 import javax.tools.JavaFileObject;
 import javax.tools.StandardJavaFileManager;
 import javax.tools.ToolProvider;
 
+@AutoBazelRepository
 public class FuzzTargetTestWrapper {
-  private static final boolean JAZZER_CI = "1".equals(System.getenv("JAZZER_CI"));
+  private static final String EXCEPTION_PREFIX = "== Java Exception: ";
+  private static final String FRAME_PREFIX = "\tat ";
+  private static final Pattern SANITIZER_FINDING = Pattern.compile("^SUMMARY: \\w*Sanitizer");
+  private static final String THREAD_DUMP_HEADER = "Stack traces of all JVM threads:";
+  private static final Set<String> PUBLIC_JAZZER_PACKAGES = Collections.unmodifiableSet(
+      Stream.of("api", "replay", "sanitizers").collect(Collectors.toSet()));
 
   public static void main(String[] args) {
     Runfiles runfiles;
-    String driverActualPath;
-    String apiActualPath;
-    String jarActualPath;
-    boolean verifyCrashInput;
-    boolean verifyCrashReproducer;
+    Path driverActualPath;
+    Path apiActualPath;
+    Path targetJarActualPath;
+    Path hookJarActualPath;
+    boolean shouldVerifyCrashInput;
+    boolean shouldVerifyCrashReproducer;
     boolean expectCrash;
-    Set<String> expectedFindings;
+    boolean usesJavaLauncher;
+    Set<String> allowedFindings;
     List<String> arguments;
     try {
-      runfiles = Runfiles.create();
-      driverActualPath = lookUpRunfile(runfiles, args[0]);
-      apiActualPath = lookUpRunfile(runfiles, args[1]);
-      jarActualPath = lookUpRunfile(runfiles, args[2]);
-      verifyCrashInput = Boolean.parseBoolean(args[3]);
-      verifyCrashReproducer = Boolean.parseBoolean(args[4]);
-      expectCrash = Boolean.parseBoolean(args[5]);
-      expectedFindings =
-          Arrays.stream(args[6].split(",")).filter(s -> !s.isEmpty()).collect(Collectors.toSet());
+      runfiles =
+          Runfiles.preload().withSourceRepository(AutoBazelRepository_FuzzTargetTestWrapper.NAME);
+      driverActualPath = Paths.get(runfiles.rlocation(args[0]));
+      apiActualPath = Paths.get(runfiles.rlocation(args[1]));
+      targetJarActualPath = Paths.get(runfiles.rlocation(args[2]));
+      hookJarActualPath = args[3].isEmpty() ? null : Paths.get(runfiles.rlocation(args[3]));
+      shouldVerifyCrashInput = Boolean.parseBoolean(args[4]);
+      shouldVerifyCrashReproducer = Boolean.parseBoolean(args[5]);
+      expectCrash = Boolean.parseBoolean(args[6]);
+      usesJavaLauncher = Boolean.parseBoolean(args[7]);
+      allowedFindings =
+          Arrays.stream(args[8].split(",")).filter(s -> !s.isEmpty()).collect(Collectors.toSet());
       // Map all files/dirs to real location
-      arguments =
-          Arrays.stream(args)
-              .skip(7)
-              .map(arg -> arg.startsWith("-") ? arg : lookUpRunfileWithFallback(runfiles, arg))
-              .collect(Collectors.toList());
+      arguments = Arrays.stream(args)
+                      .skip(9)
+                      .map(arg -> arg.startsWith("-") ? arg : runfiles.rlocation(arg))
+                      .collect(toList());
     } catch (IOException | ArrayIndexOutOfBoundsException e) {
       e.printStackTrace();
       System.exit(1);
@@ -72,34 +91,56 @@
     }
 
     ProcessBuilder processBuilder = new ProcessBuilder();
-    Map<String, String> environment = processBuilder.environment();
     // Ensure that Jazzer can find its runfiles.
-    environment.putAll(runfiles.getEnvVars());
+    processBuilder.environment().putAll(runfiles.getEnvVars());
+    // Ensure that sanitizers behave consistently across OSes and use a dedicated exit code to make
+    // them distinguishable from unexpected crashes.
+    processBuilder.environment().put("ASAN_OPTIONS", "abort_on_error=0:exitcode=76");
+    processBuilder.environment().put("UBSAN_OPTIONS", "abort_on_error=0:exitcode=76");
 
     // Crashes will be available as test outputs. These are cleared on the next run,
     // so this is only useful for examples.
-    String outputDir = System.getenv("TEST_UNDECLARED_OUTPUTS_DIR");
+    Path outputDir = Paths.get(System.getenv("TEST_UNDECLARED_OUTPUTS_DIR"));
 
     List<String> command = new ArrayList<>();
-    command.add(driverActualPath);
+    command.add(driverActualPath.toString());
+    if (usesJavaLauncher) {
+      if (hookJarActualPath != null) {
+        command.add(String.format("--main_advice_classpath=%s", hookJarActualPath));
+      }
+      if (System.getenv("JAZZER_DEBUG") != null) {
+        command.add("--debug");
+      }
+    } else {
+      command.add(String.format("--cp=%s",
+          hookJarActualPath == null
+              ? targetJarActualPath
+              : String.join(System.getProperty("path.separator"), targetJarActualPath.toString(),
+                  hookJarActualPath.toString())));
+    }
     command.add(String.format("-artifact_prefix=%s/", outputDir));
     command.add(String.format("--reproducer_path=%s", outputDir));
-    command.add(String.format("--cp=%s", jarActualPath));
     if (System.getenv("JAZZER_NO_EXPLICIT_SEED") == null) {
       command.add("-seed=2735196724");
     }
     command.addAll(arguments);
 
-    processBuilder.inheritIO();
-    if (JAZZER_CI) {
-      // Make JVM error reports available in test outputs.
-      processBuilder.environment().put(
-          "JAVA_TOOL_OPTIONS", String.format("-XX:ErrorFile=%s/hs_err_pid%%p.log", outputDir));
-    }
+    // Make JVM error reports available in test outputs.
+    processBuilder.environment().put(
+        "JAVA_TOOL_OPTIONS", String.format("-XX:ErrorFile=%s/hs_err_pid%%p.log", outputDir));
+    processBuilder.redirectOutput(Redirect.INHERIT);
+    processBuilder.redirectInput(Redirect.INHERIT);
     processBuilder.command(command);
 
     try {
-      int exitCode = processBuilder.start().waitFor();
+      Process process = processBuilder.start();
+      try {
+        verifyFuzzerOutput(
+            process.getErrorStream(), allowedFindings, arguments.contains("--nohooks"));
+      } finally {
+        process.getErrorStream().close();
+      }
+      int exitCode = process.waitFor();
       if (!expectCrash) {
         if (exitCode != 0) {
           System.err.printf(
@@ -108,26 +149,32 @@
         }
         System.exit(0);
       }
-      // Assert that we either found a crash in Java (exit code 77) or a sanitizer crash (exit code
-      // 76).
-      if (exitCode != 76 && exitCode != 77) {
+      // Assert that we either found a crash in Java (exit code 77), a sanitizer crash (exit code
+      // 76), or a timeout (exit code 70).
+      if (exitCode != 76 && exitCode != 77
+          && !(allowedFindings.contains("timeout") && exitCode == 70)) {
         System.err.printf("Did expect a crash, but Jazzer exited with exit code %d%n", exitCode);
         System.exit(1);
       }
-      String[] outputFiles = new File(outputDir).list();
-      if (outputFiles == null) {
+      List<Path> outputFiles = Files.list(outputDir).collect(toList());
+      if (outputFiles.isEmpty()) {
         System.err.printf("Jazzer did not write a crashing input into %s%n", outputDir);
         System.exit(1);
       }
       // Verify that libFuzzer dumped a crashing input.
-      if (JAZZER_CI && verifyCrashInput
-          && Arrays.stream(outputFiles).noneMatch(name -> name.startsWith("crash-"))) {
+      if (shouldVerifyCrashInput
+          && outputFiles.stream().noneMatch(
+              name -> name.getFileName().toString().startsWith("crash-"))
+          && !(allowedFindings.contains("timeout")
+              && outputFiles.stream().anyMatch(
+                  name -> name.getFileName().toString().startsWith("timeout-")))) {
         System.err.printf("No crashing input found in %s%n", outputDir);
         System.exit(1);
       }
       // Verify that libFuzzer dumped a crash reproducer.
-      if (JAZZER_CI && verifyCrashReproducer
-          && Arrays.stream(outputFiles).noneMatch(name -> name.startsWith("Crash_"))) {
+      if (shouldVerifyCrashReproducer
+          && outputFiles.stream().noneMatch(
+              name -> name.getFileName().toString().startsWith("Crash_"))) {
         System.err.printf("No crash reproducer found in %s%n", outputDir);
         System.exit(1);
       }
@@ -136,10 +183,9 @@
       System.exit(1);
     }
 
-    if (JAZZER_CI && verifyCrashReproducer) {
+    if (shouldVerifyCrashReproducer) {
       try {
-        verifyCrashReproducer(
-            outputDir, driverActualPath, apiActualPath, jarActualPath, expectedFindings);
+        verifyCrashReproducer(outputDir, apiActualPath, targetJarActualPath, allowedFindings);
       } catch (Exception e) {
         e.printStackTrace();
         System.exit(1);
@@ -148,42 +194,85 @@
     System.exit(0);
   }
 
-  // Looks up a Bazel "rootpath" in this binary's runfiles and returns the resulting path.
-  private static String lookUpRunfile(Runfiles runfiles, String rootpath) {
-    return runfiles.rlocation(rlocationPath(rootpath));
-  }
-
-  // Looks up a Bazel "rootpath" in this binary's runfiles and returns the resulting path if it
-  // exists. If not, returns the original path unmodified.
-  private static String lookUpRunfileWithFallback(Runfiles runfiles, String rootpath) {
-    String candidatePath;
-    try {
-      candidatePath = lookUpRunfile(runfiles, rootpath);
-    } catch (IllegalArgumentException unused) {
-      // The argument to Runfiles.rlocation had an invalid format, which indicates that rootpath
-      // is not a Bazel "rootpath" but a user-supplied path that should be returned unchanged.
-      return rootpath;
+  private static void verifyFuzzerOutput(
+      InputStream fuzzerOutput, Set<String> expectedFindings, boolean noHooks) throws IOException {
+    List<String> stackTrace;
+    try (BufferedReader reader = new BufferedReader(new InputStreamReader(fuzzerOutput))) {
+      stackTrace =
+          reader.lines()
+              .peek(System.err::println)
+              .filter(line
+                  -> line.startsWith(EXCEPTION_PREFIX) || line.startsWith(FRAME_PREFIX)
+                      || line.equals(THREAD_DUMP_HEADER) || SANITIZER_FINDING.matcher(line).find())
+              .collect(toList());
     }
-    if (new File(candidatePath).exists()) {
-      return candidatePath;
-    } else {
-      return rootpath;
+    if (expectedFindings.isEmpty()) {
+      if (stackTrace.isEmpty()) {
+        return;
+      }
+      throw new IllegalStateException(String.format(
+          "Did not expect a finding, but got a stack trace:%n%s", String.join("\n", stackTrace)));
+    }
+    if (expectedFindings.contains("native")) {
+      // Expect a native sanitizer finding as well as a thread dump with at least one frame.
+      if (stackTrace.stream().noneMatch(line -> SANITIZER_FINDING.matcher(line).find())) {
+        throw new IllegalStateException("Expected native sanitizer finding, but did not get any");
+      }
+      if (!stackTrace.contains(THREAD_DUMP_HEADER) || stackTrace.size() < 3) {
+        throw new IllegalStateException(
+            "Expected stack traces for all threads, but did not get any");
+      }
+      if (expectedFindings.size() != 1) {
+        throw new IllegalStateException("Cannot expect both a native and other findings");
+      }
+      return;
+    }
+    if (expectedFindings.contains("timeout")) {
+      if (!stackTrace.contains(THREAD_DUMP_HEADER) || stackTrace.size() < 3) {
+        throw new IllegalStateException(
+            "Expected stack traces for all threads, but did not get any");
+      }
+      if (expectedFindings.size() != 1) {
+        throw new IllegalStateException("Cannot expect both a timeout and other findings");
+      }
+      return;
+    }
+    List<String> findings =
+        stackTrace.stream()
+            .filter(line -> line.startsWith(EXCEPTION_PREFIX))
+            .map(line -> line.substring(EXCEPTION_PREFIX.length()).split(":", 2)[0])
+            .collect(toList());
+    if (findings.isEmpty()) {
+      throw new IllegalStateException("Expected a crash, but did not get a stack trace");
+    }
+    for (String finding : findings) {
+      if (!expectedFindings.contains(finding)) {
+        throw new IllegalStateException(String.format("Got finding %s, but expected one of: %s",
+            findings.get(0), String.join(", ", expectedFindings)));
+      }
+    }
+    List<String> unexpectedFrames =
+        stackTrace.stream()
+            .filter(line -> line.startsWith(FRAME_PREFIX))
+            .map(line -> line.substring(FRAME_PREFIX.length()))
+            .filter(line -> line.startsWith("com.code_intelligence.jazzer."))
+            // With --nohooks, Jazzer does not filter out its own stack frames.
+            .filter(line
+                -> !noHooks
+                    && !PUBLIC_JAZZER_PACKAGES.contains(
+                        line.substring("com.code_intelligence.jazzer.".length()).split("\\.")[0]))
+            .collect(toList());
+    if (!unexpectedFrames.isEmpty()) {
+      throw new IllegalStateException(
+          String.format("Unexpected strack trace frames:%n%n%s%n%nin:%n%s",
+              String.join("\n", unexpectedFrames), String.join("\n", stackTrace)));
     }
   }
 
-  // Turns the result of Bazel's `$(rootpath ...)` into the correct format for rlocation.
-  private static String rlocationPath(String rootpath) {
-    if (rootpath.startsWith("external/")) {
-      return rootpath.substring("external/".length());
-    } else {
-      return "jazzer/" + rootpath;
-    }
-  }
-
-  private static void verifyCrashReproducer(String outputDir, String driver, String api, String jar,
-      Set<String> expectedFindings) throws Exception {
+  private static void verifyCrashReproducer(
+      Path outputDir, Path api, Path targetJar, Set<String> expectedFindings) throws Exception {
     File source =
-        Files.list(Paths.get(outputDir))
+        Files.list(outputDir)
             .filter(f -> f.toFile().getName().endsWith(".java"))
             // Verify the crash reproducer that was created last in order to reproduce the last
             // crash when using --keep_going.
@@ -191,17 +280,16 @@
             .map(Path::toFile)
             .orElseThrow(
                 () -> new IllegalStateException("Could not find crash reproducer in " + outputDir));
-    String crashReproducer = compile(source, driver, api, jar);
-    execute(crashReproducer, outputDir, expectedFindings);
+    String reproducerClassName = compile(source, api, targetJar);
+    execute(reproducerClassName, outputDir, api, targetJar, expectedFindings);
   }
 
-  private static String compile(File source, String driver, String api, String jar)
-      throws IOException {
+  private static String compile(File source, Path api, Path targetJar) throws IOException {
     JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
     try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null)) {
       Iterable<? extends JavaFileObject> compilationUnits = fileManager.getJavaFileObjects(source);
-      List<String> options =
-          Arrays.asList("-classpath", String.join(File.pathSeparator, driver, api, jar));
+      List<String> options = Arrays.asList(
+          "-classpath", String.join(File.pathSeparator, api.toString(), targetJar.toString()));
       System.out.printf(
           "Compile crash reproducer %s with options %s%n", source.getAbsolutePath(), options);
       CompilationTask task =
@@ -213,33 +301,52 @@
     }
   }
 
-  private static void execute(String classFile, String outputDir, Set<String> expectedFindings)
-      throws IOException, ReflectiveOperationException {
+  private static void execute(String className, Path outputDir, Path api, Path targetJar,
+      Set<String> expectedFindings) throws IOException, ReflectiveOperationException {
     try {
-      System.out.printf("Execute crash reproducer %s%n", classFile);
-      URLClassLoader classLoader =
-          new URLClassLoader(new URL[] {new URL("file://" + outputDir + "/")});
-      Class<?> crashReproducerClass = classLoader.loadClass(classFile);
+      System.out.printf("Execute crash reproducer %s%n", className);
+      URLClassLoader classLoader = new URLClassLoader(
+          new URL[] {
+              outputDir.toUri().toURL(),
+              api.toUri().toURL(),
+              targetJar.toUri().toURL(),
+          },
+          getPlatformClassLoader());
+      Class<?> crashReproducerClass = classLoader.loadClass(className);
       Method main = crashReproducerClass.getMethod("main", String[].class);
       System.setProperty("jazzer.is_reproducer", "true");
       main.invoke(null, new Object[] {new String[] {}});
       if (!expectedFindings.isEmpty()) {
         throw new IllegalStateException("Expected crash with any of "
-            + String.join(", ", expectedFindings) + " not reproduced by " + classFile);
+            + String.join(", ", expectedFindings) + " not reproduced by " + className);
       }
       System.out.println("Reproducer finished successfully without finding");
     } catch (InvocationTargetException e) {
       // expect the invocation to fail with the prescribed finding
       Throwable finding = e.getCause();
       if (expectedFindings.isEmpty()) {
-        throw new IllegalStateException("Did not expect " + classFile + " to crash", finding);
+        throw new IllegalStateException("Did not expect " + className + " to crash", finding);
       } else if (expectedFindings.contains(finding.getClass().getName())) {
-        System.out.printf("Reproduced exception \"%s\"%n", finding.getMessage());
+        System.out.printf("Reproduced exception \"%s\"%n", finding);
       } else {
         throw new IllegalStateException(
-            classFile + " did not crash with any of " + String.join(", ", expectedFindings),
+            className + " did not crash with any of " + String.join(", ", expectedFindings),
             finding);
       }
     }
   }
+
+  private static ClassLoader getPlatformClassLoader() {
+    try {
+      Method getter = ClassLoader.class.getMethod("getPlatformClassLoader");
+      // Java 9 and higher
+      return (ClassLoader) getter.invoke(null);
+    } catch (NoSuchMethodException e) {
+      // Java 8: All standard library classes are visible through the ClassLoader represented by
+      // null.
+      return null;
+    } catch (InvocationTargetException | IllegalAccessException e) {
+      throw new RuntimeException(e);
+    }
+  }
 }
diff --git a/bazel/tools/java/com/code_intelligence/jazzer/tools/JarStripper.java b/bazel/tools/java/com/code_intelligence/jazzer/tools/JarStripper.java
index 2a567c6..72f53cd 100644
--- a/bazel/tools/java/com/code_intelligence/jazzer/tools/JarStripper.java
+++ b/bazel/tools/java/com/code_intelligence/jazzer/tools/JarStripper.java
@@ -14,21 +14,30 @@
 
 package com.code_intelligence.jazzer.tools;
 
+import static java.util.Collections.unmodifiableMap;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.mapping;
+import static java.util.stream.Collectors.partitioningBy;
+import static java.util.stream.Collectors.toList;
+
 import java.io.IOException;
+import java.io.UncheckedIOException;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.nio.file.FileSystem;
 import java.nio.file.FileSystems;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.nio.file.PathMatcher;
 import java.nio.file.Paths;
+import java.util.AbstractMap.SimpleEntry;
 import java.util.Arrays;
-import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.TimeZone;
-import java.util.stream.Collectors;
+import java.util.stream.IntStream;
 import java.util.stream.Stream;
 
 public class JarStripper {
@@ -43,14 +52,23 @@
     if (args.length < 2) {
       System.err.println(
           "Hermetically removes files and directories from .jar files by relative paths.");
-      System.err.println("Usage: in.jar out.jar [relative path]...");
+      System.err.println("Usage: in.jar out.jar [[+]path]...");
       System.exit(1);
     }
 
     Path inFile = Paths.get(args[0]);
     Path outFile = Paths.get(args[1]);
-    Iterable<String> pathsToDelete =
-        Collections.unmodifiableList(Arrays.stream(args).skip(2).collect(Collectors.toList()));
+    Map<Boolean, List<String>> rawPaths = unmodifiableMap(
+        Arrays.stream(args)
+            .skip(2)
+            .map(arg -> {
+              if (arg.startsWith("+")) {
+                return new SimpleEntry<>(true, arg.substring(1));
+              } else {
+                return new SimpleEntry<>(false, arg);
+              }
+            })
+            .collect(partitioningBy(e -> e.getKey(), mapping(e -> e.getValue(), toList()))));
 
     try {
       Files.copy(inFile, outFile);
@@ -76,19 +94,55 @@
     TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
 
     try (FileSystem zipFs = FileSystems.newFileSystem(outUri, ZIP_FS_PROPERTIES)) {
-      for (String pathToDelete : pathsToDelete) {
-        // Visit files before the directory they are contained in by sorting in reverse order.
-        try (Stream<Path> walk = Files.walk(zipFs.getPath(pathToDelete))) {
-          Iterable<Path> subpaths =
-              walk.sorted(Comparator.reverseOrder()).collect(Collectors.toList());
-          for (Path subpath : subpaths) {
-            Files.delete(subpath);
-          }
-        }
+      PathMatcher pathsToDelete = toPathMatcher(zipFs, rawPaths.get(false), false);
+      PathMatcher pathsToKeep = toPathMatcher(zipFs, rawPaths.get(true), true);
+      try (Stream<Path> walk = Files.walk(zipFs.getPath(""))) {
+        walk.sorted(Comparator.reverseOrder())
+            .filter(path
+                -> (pathsToKeep != null && !pathsToKeep.matches(path))
+                    || (pathsToDelete != null && pathsToDelete.matches(path)))
+            .forEach(path -> {
+              try {
+                Files.delete(path);
+              } catch (IOException e) {
+                throw new UncheckedIOException(e);
+              }
+            });
       }
-    } catch (IOException e) {
-      e.printStackTrace();
+    } catch (Throwable e) {
+      Throwable throwable = e;
+      if (throwable instanceof UncheckedIOException) {
+        throwable = throwable.getCause();
+      }
+      throwable.printStackTrace();
       System.exit(1);
     }
   }
+
+  private static PathMatcher toPathMatcher(FileSystem fs, List<String> paths, boolean keep) {
+    if (paths.isEmpty()) {
+      return null;
+    }
+    return fs.getPathMatcher(String.format("glob:{%s}",
+        paths.stream()
+            .flatMap(pattern -> keep ? toKeepGlobs(pattern) : toRemoveGlobs(pattern))
+            .collect(joining(","))));
+  }
+
+  private static Stream<String> toRemoveGlobs(String path) {
+    if (path.endsWith("/**")) {
+      // When removing all contents of a directory, also remove the directory itself.
+      return Stream.of(path, path.substring(0, path.length() - "/**".length()));
+    } else {
+      return Stream.of(path);
+    }
+  }
+
+  private static Stream<String> toKeepGlobs(String path) {
+    // When keeping something, also keep all parents.
+    String[] segments = path.split("/");
+    return Stream.concat(Stream.of(path),
+        IntStream.range(0, segments.length)
+            .mapToObj(i -> Arrays.stream(segments).limit(i).collect(joining("/"))));
+  }
 }
diff --git a/deploy/BUILD.bazel b/deploy/BUILD.bazel
index 7cceeae..4b22a7a 100644
--- a/deploy/BUILD.bazel
+++ b/deploy/BUILD.bazel
@@ -1,13 +1,116 @@
+load("@bazel_skylib//rules:common_settings.bzl", "bool_flag")
 load("@rules_jvm_external//:defs.bzl", "java_export")
-load("//:maven.bzl", "JAZZER_API_COORDINATES")
+load("//:maven.bzl", "JAZZER_API_COORDINATES", "JAZZER_COORDINATES", "JAZZER_JUNIT_COORDINATES")
+load("//bazel:compat.bzl", "SKIP_ON_WINDOWS")
 
-# To publish a new release of the Jazzer API to Maven, run:
-# bazel run --config=maven --define "maven_user=..." --define "maven_password=..." --define gpg_sign=true //deploy:api.publish
-# Build //deploy:api-docs to generate javadocs for the API.
-java_export(
-    name = "api",
-    maven_coordinates = JAZZER_API_COORDINATES,
-    pom_template = "//:jazzer-api.pom",
-    visibility = ["//visibility:public"],
-    runtime_deps = ["//agent/src/main/java/com/code_intelligence/jazzer/api"],
+bool_flag(
+    name = "linked_javadoc",
+    build_setting_default = False,
 )
+
+config_setting(
+    name = "emit_linked_javadoc",
+    flag_values = {
+        ":linked_javadoc": "True",
+    },
+    visibility = ["//:__subpackages__"],
+)
+
+sh_binary(
+    name = "deploy",
+    srcs = ["deploy.sh"],
+    args = [JAZZER_COORDINATES],
+)
+
+java_export(
+    name = "jazzer-api",
+    javadocopts = select({
+        ":emit_linked_javadoc": [
+            "-link",
+            "https://docs.oracle.com/en/java/javase/17/docs/api/",
+        ],
+        "//conditions:default": [],
+    }),
+    maven_coordinates = JAZZER_API_COORDINATES,
+    pom_template = "//deploy:jazzer-api.pom",
+    visibility = ["//visibility:public"],
+    runtime_deps = ["//src/main/java/com/code_intelligence/jazzer/api"],
+)
+
+java_export(
+    name = "jazzer",
+    maven_coordinates = JAZZER_COORDINATES,
+    pom_template = "jazzer.pom",
+    # Do not generate an implicit javadocs target - the current target is based on the shaded deploy
+    # JAR, for which the docs JAR generated by default would be empty.
+    tags = ["no-javadocs"],
+    visibility = ["//visibility:public"],
+    runtime_deps = [
+        "//src/main/java/com/code_intelligence/jazzer:jazzer_import",
+    ],
+)
+
+alias(
+    name = "jazzer-docs",
+    actual = "//src/main/java/com/code_intelligence/jazzer:jazzer-docs",
+)
+
+alias(
+    name = "jazzer-sources",
+    actual = "//src/main/java/com/code_intelligence/jazzer:jazzer-sources",
+)
+
+java_export(
+    name = "jazzer-junit",
+    # Exclude the unshaded classes comprising com.code-intelligence:jazzer since the java_library
+    # target comprising jazzer-junit depend on the individual libraries, not the shaded jar.
+    deploy_env = ["//src/main/java/com/code_intelligence/jazzer:jazzer_lib"],
+    javadocopts = select({
+        ":emit_linked_javadoc": [
+            "-link",
+            "https://docs.oracle.com/en/java/javase/17/docs/api/",
+            "-link",
+            "https://codeintelligencetesting.github.io/jazzer-docs/jazzer-api/",
+            "-link",
+            "https://codeintelligencetesting.github.io/jazzer-docs/jazzer/",
+            "-link",
+            "https://junit.org/junit5/docs/current/api/",
+        ],
+        "//conditions:default": [],
+    }),
+    maven_coordinates = JAZZER_JUNIT_COORDINATES,
+    pom_template = "jazzer-junit.pom",
+    visibility = ["//visibility:public"],
+    runtime_deps = [
+        # These deps' only effect is to include a dependency on the 'jazzer' and 'jazzer-api' Maven artifacts in the
+        # POM.
+        "//deploy:jazzer",
+        "//deploy:jazzer-api",
+        "//src/main/java/com/code_intelligence/jazzer/junit",
+    ],
+)
+
+[
+    sh_test(
+        name = artifact + "_artifact_test",
+        srcs = [artifact + "_artifact_test.sh"],
+        args = [
+            "$(rootpath :%s)" % artifact,
+        ],
+        data = [
+            ":" + artifact,
+            "@local_jdk//:bin/jar",
+        ],
+        tags = [
+            # Coverage instrumentation necessarily adds files to the jar that we
+            # wouldn't want to release and thus causes this test to fail.
+            "no-coverage",
+        ],
+        target_compatible_with = SKIP_ON_WINDOWS,
+    )
+    for artifact in [
+        "jazzer-api",
+        "jazzer",
+        "jazzer-junit",
+    ]
+]
diff --git a/deploy/deploy.sh b/deploy/deploy.sh
new file mode 100755
index 0000000..edf8b40
--- /dev/null
+++ b/deploy/deploy.sh
@@ -0,0 +1,54 @@
+#!/usr/bin/env sh
+# Copyright 2022 Code Intelligence GmbH
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+set -eu
+
+fail() {
+  echo "$1"
+  exit 1
+}
+
+cd "$BUILD_WORKSPACE_DIRECTORY" || fail "BUILD_WORKSPACE_DIRECTORY not found"
+
+JAZZER_COORDINATES=$1
+
+[ -z "${MAVEN_USER+x}" ] && \
+  fail "Set MAVEN_USER to the Sonatype OSSRH user"
+[ -z "${MAVEN_PASSWORD+x}" ] && \
+  fail "Set MAVEN_PASSWORD to the Sonatype OSSRH password"
+[ -z "${JAZZER_JAR_PATH+x}" ] && \
+  fail "Set JAZZER_JAR_PATH to the absolute path of jazzer.jar obtained from the release GitHub Actions workflow"
+[ ! -f "${JAZZER_JAR_PATH}" ] && \
+  fail "JAZZER_JAR_PATH does not exist at '$JAZZER_JAR_PATH'"
+
+MAVEN_REPO=https://oss.sonatype.org/service/local/staging/deploy/maven2
+
+# The Jazzer jar itself bundles native libraries for multiple architectures and thus can't be built
+# on the local machine. It is obtained from CI and passed in via JAZZER_JAR_PATH.
+bazel build //deploy:jazzer-docs //deploy:jazzer-sources //deploy:jazzer-pom
+
+JAZZER_DOCS_PATH=$PWD/$(bazel cquery --output=files //deploy:jazzer-docs)
+JAZZER_SOURCES_PATH=$PWD/$(bazel cquery --output=files //deploy:jazzer-sources)
+JAZZER_POM_PATH=$PWD/$(bazel cquery --output=files //deploy:jazzer-pom)
+
+bazel run --define "maven_repo=${MAVEN_REPO}" --define "maven_user=${MAVEN_USER}" \
+  --define "maven_password=${MAVEN_PASSWORD}" --define gpg_sign=true \
+  //deploy:jazzer-api.publish
+bazel run @rules_jvm_external//private/tools/java/com/github/bazelbuild/rules_jvm_external/maven:MavenPublisher -- \
+  "$MAVEN_REPO" true "$MAVEN_USER" "$MAVEN_PASSWORD" "$JAZZER_COORDINATES" \
+  "$JAZZER_POM_PATH" "$JAZZER_JAR_PATH" "$JAZZER_SOURCES_PATH" "$JAZZER_DOCS_PATH"
+bazel run --define "maven_repo=${MAVEN_REPO}" --define "maven_user=${MAVEN_USER}" \
+  --define "maven_password=${MAVEN_PASSWORD}" --define gpg_sign=true \
+  //deploy:jazzer-junit.publish
diff --git a/deploy/jazzer-api.pom b/deploy/jazzer-api.pom
new file mode 100644
index 0000000..6ad1cc0
--- /dev/null
+++ b/deploy/jazzer-api.pom
@@ -0,0 +1,41 @@
+<project>
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>{groupId}</groupId>
+    <artifactId>{artifactId}</artifactId>
+    <version>{version}</version>
+    <packaging>{type}</packaging>
+
+    <dependencies>
+{dependencies}
+    </dependencies>
+
+    <name>Jazzer API</name>
+    <description>Helper functions and annotations for Jazzer fuzz targets</description>
+    <url>https://github.com/CodeIntelligenceTesting/jazzer</url>
+
+    <licenses>
+        <license>
+            <name>Apache License, Version 2.0</name>
+            <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
+            <distribution>repo</distribution>
+        </license>
+    </licenses>
+
+    <organization>
+        <name>Code Intelligence GmbH</name>
+        <url>https://code-intelligence.com</url>
+    </organization>
+
+    <developers>
+        <developer>
+            <id>fmeum</id>
+            <name>Fabian Meumertzheim</name>
+            <email>meumertzheim@code-intelligence.com</email>
+            <organization>Code Intelligence GmbH</organization>
+        </developer>
+    </developers>
+
+    <scm>
+        <url>https://github.com/CodeIntelligenceTesting/jazzer</url>
+    </scm>
+</project>
diff --git a/agent/verify_shading.sh b/deploy/jazzer-api_artifact_test.sh
similarity index 83%
copy from agent/verify_shading.sh
copy to deploy/jazzer-api_artifact_test.sh
index 5742476..b71c7d0 100755
--- a/agent/verify_shading.sh
+++ b/deploy/jazzer-api_artifact_test.sh
@@ -13,16 +13,16 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+[ -f "$1" ] || exit 1
 # List all files in the jar and exclude an allowed list of files.
 # Since grep fails if there is no match, ! ... | grep ... fails if there is a
 # match.
 ! external/local_jdk/bin/jar tf "$1" | \
   grep -v \
-    -e '^build-data.properties$' \
     -e '^com/$' \
     -e '^com/code_intelligence/$' \
-    -e '^com/code_intelligence/jazzer/' \
+    -e '^com/code_intelligence/jazzer/$' \
+    -e '^com/code_intelligence/jazzer/api/' \
     -e '^jaz/' \
-    -e '^win32-x86/' \
-    -e '^win32-x86-64/' \
-    -e '^META-INF/'
+    -e '^META-INF/$' \
+    -e '^META-INF/MANIFEST.MF$'
diff --git a/deploy/jazzer-junit.pom b/deploy/jazzer-junit.pom
new file mode 100644
index 0000000..1fb624e
--- /dev/null
+++ b/deploy/jazzer-junit.pom
@@ -0,0 +1,41 @@
+<project>
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>{groupId}</groupId>
+    <artifactId>{artifactId}</artifactId>
+    <version>{version}</version>
+    <packaging>{type}</packaging>
+
+    <dependencies>
+{dependencies}
+    </dependencies>
+
+    <name>Jazzer JUnit</name>
+    <description>JUnit 5 support for Jazzer fuzz tests</description>
+    <url>https://github.com/CodeIntelligenceTesting/jazzer</url>
+
+    <licenses>
+        <license>
+            <name>Apache License, Version 2.0</name>
+            <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
+            <distribution>repo</distribution>
+        </license>
+    </licenses>
+
+    <organization>
+        <name>Code Intelligence GmbH</name>
+        <url>https://code-intelligence.com</url>
+    </organization>
+
+    <developers>
+        <developer>
+            <id>fmeum</id>
+            <name>Fabian Meumertzheim</name>
+            <email>meumertzheim@code-intelligence.com</email>
+            <organization>Code Intelligence GmbH</organization>
+        </developer>
+    </developers>
+
+    <scm>
+        <url>https://github.com/CodeIntelligenceTesting/jazzer</url>
+    </scm>
+</project>
diff --git a/agent/verify_shading.sh b/deploy/jazzer-junit_artifact_test.sh
similarity index 68%
copy from agent/verify_shading.sh
copy to deploy/jazzer-junit_artifact_test.sh
index 5742476..fc4d041 100755
--- a/agent/verify_shading.sh
+++ b/deploy/jazzer-junit_artifact_test.sh
@@ -13,16 +13,19 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+[ -f "$1" ] || exit 1
 # List all files in the jar and exclude an allowed list of files.
 # Since grep fails if there is no match, ! ... | grep ... fails if there is a
 # match.
 ! external/local_jdk/bin/jar tf "$1" | \
   grep -v \
-    -e '^build-data.properties$' \
     -e '^com/$' \
     -e '^com/code_intelligence/$' \
-    -e '^com/code_intelligence/jazzer/' \
-    -e '^jaz/' \
-    -e '^win32-x86/' \
-    -e '^win32-x86-64/' \
-    -e '^META-INF/'
+    -e '^com/code_intelligence/jazzer/$' \
+    -e '^com/code_intelligence/jazzer/junit/' \
+    -e '^com/code_intelligence/jazzer/sanitizers/$' \
+    -e '^com/code_intelligence/jazzer/sanitizers/Constants.class$' \
+    -e '^META-INF/$' \
+    -e '^META-INF/MANIFEST.MF$' \
+    -e '^META-INF/services/$' \
+    -e '^META-INF/services/org.junit.platform.engine.TestEngine$'
diff --git a/deploy/jazzer.pom b/deploy/jazzer.pom
new file mode 100644
index 0000000..114fa08
--- /dev/null
+++ b/deploy/jazzer.pom
@@ -0,0 +1,41 @@
+<project>
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>{groupId}</groupId>
+    <artifactId>{artifactId}</artifactId>
+    <version>{version}</version>
+    <packaging>{type}</packaging>
+
+    <dependencies>
+{dependencies}
+    </dependencies>
+
+    <name>Jazzer</name>
+    <description>Coverage-guided, in-process fuzzing for the JVM</description>
+    <url>https://github.com/CodeIntelligenceTesting/jazzer</url>
+
+    <licenses>
+        <license>
+            <name>Apache License, Version 2.0</name>
+            <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
+            <distribution>repo</distribution>
+        </license>
+    </licenses>
+
+    <organization>
+        <name>Code Intelligence GmbH</name>
+        <url>https://code-intelligence.com</url>
+    </organization>
+
+    <developers>
+        <developer>
+            <id>fmeum</id>
+            <name>Fabian Meumertzheim</name>
+            <email>meumertzheim@code-intelligence.com</email>
+            <organization>Code Intelligence GmbH</organization>
+        </developer>
+    </developers>
+
+    <scm>
+        <url>https://github.com/CodeIntelligenceTesting/jazzer</url>
+    </scm>
+</project>
diff --git a/agent/verify_shading.sh b/deploy/jazzer_artifact_test.sh
similarity index 91%
rename from agent/verify_shading.sh
rename to deploy/jazzer_artifact_test.sh
index 5742476..d1f04b4 100755
--- a/agent/verify_shading.sh
+++ b/deploy/jazzer_artifact_test.sh
@@ -13,16 +13,16 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+[ -f "$1" ] || exit 1
 # List all files in the jar and exclude an allowed list of files.
 # Since grep fails if there is no match, ! ... | grep ... fails if there is a
 # match.
 ! external/local_jdk/bin/jar tf "$1" | \
   grep -v \
-    -e '^build-data.properties$' \
     -e '^com/$' \
     -e '^com/code_intelligence/$' \
     -e '^com/code_intelligence/jazzer/' \
-    -e '^jaz/' \
     -e '^win32-x86/' \
     -e '^win32-x86-64/' \
-    -e '^META-INF/'
+    -e '^META-INF/$' \
+    -e '^META-INF/MANIFEST.MF$'
diff --git a/deploy/maven.pub b/deploy/maven.pub
new file mode 100644
index 0000000..9ae043b
--- /dev/null
+++ b/deploy/maven.pub
@@ -0,0 +1,41 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQGNBGAtGcoBDADCvPAGK49KMe4KFs6ZWH5kBtjgWCzjYhVKtY96Z8osmTmfNyHN
+9XBpWgR2EsrCN+JAiEfKJGaHDAoeg9/gP5/PksZpULdPaRFRBsaCoXL7RnuG/tfV
+6XI9rdGLlJ6rRf5sSlHVXsSTxDfvB73xIxHnSoofNfsTr9ppBjot1NThkdBTE6Od
+r1mTRyBrd5rERcvNYbvnCiT8aDjdnZcK+8KPKflg5e8bJP5mMqNPoqWFG32FgJj4
+I//yytAgTg37CB2q6xH3XYE1RBvNjnKLCeQLyP0pwnYepXtkfpDleALJ6gd9kzAy
+16ZK67dbw7Be2K8ciE8jiYcNid1dVqv3ddmRdG0dOvsWDDl3HKCoLnPhCngVFkwH
+YY7O5FuSdkr1O3/RFP/TMafAxjcsVngfQele737PpSgd3qLshLFs/Ae2TUvrZ67N
+zMOmAFPe/EQ1mzAOI3vE6Rz2rzX+lSExUEUduIUa+/yQ9YLY43A1mICVg/6+pQ+5
+7EtLKhqO/yF1v00AEQEAAbQ7Q29kZSBJbnRlbGxpZ2VuY2UgR21iSCAtIE1hdmVu
+IDxpbmZvQGNvZGUtaW50ZWxsaWdlbmNlLmNvbT6JAc4EEwEKADgWIQT5Pd1S4Guk
+mJJiSPhasZzbKCTgmwUCYC0ZygIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAK
+CRBasZzbKCTgm4XUC/4wnOg5J8ajzacAeKQNBAECkEJi1qv0S+KR4gOChf8sQL9E
+dQTIJBn/GZSlXaxMlupVbAiHdCvsiDYtEpGdG+OQ6fKJ1V45WrqrzkmVJECLlEfS
+6KjldtOGBC+pRHc5KR3FcqJ9rfrW0K5r3MMCK03XVA6WW5nSwtAGOL50yX/00K12
+zQAXj3OZchY2F8VXD8Kmn6jiCSHt2uogj6LSP0Fy7OngeGxkyu7wcDTK3VO5Ij+D
+stgyw8HPy8Y91T/83BYxZLIosmOqoaQWeaqKG5DSsqIzOW0gR8ZIpeMob4KDLfZ6
+0cv93xFvFmz7GghCz5oQ854082PIvN0C5SHh6R/QqE4waTRydv1q5fKeSqlJfczC
+a0cyhdXGF9hiTGYM0zbiRI20XgNIzqiQBKAiKC2DMHpUY4Fjx8HEwRY9E5wtUthP
+TPvXbnbxHQDS1KUfy5JjoqjosHxyleOGFg+v9emlyQuh2jgtwEQHNIISeApWu7BV
+ilE1tbfda6zdy209/Hu5AY0EYC0ZygEMALqKcg+umxTmVZupGeJx26GqV+MnUPit
+W6LTf6JSfDdUNlyw2T7E+Z9WqPniUwjez3tzaMRdCRWUKoaHCJroZqG4d/w2/b3o
+T9zZhqEP+toEgzgfI7v/vcJTuUZZbqgYWOMxeikO69UEbZ0MVm2Z3IIZ74ReTzOx
+iG+Ctc9iCvIsKQTw7xULmJZTRGyaFVplwVAoAnhjuPzfLuKseutRH6A+v9eUt/35
+SozDNiSWQ+XkraDJxYCCCwASmQl6keofKMXg197g+q1olvxOptvSb6hUB0q8FJAO
+NwkRBlAlKM7P18j7gZkf1Lyo4X+P9f5k7iSACXbNfW78fpTCEZwCJKxsOrpDZDNz
+HD0dwpIQVajaYBVvvsSC5brE9Km4LFQgiEjLmpRuiZYoh8FbIF1VOxdRDx0HGimC
+YPlzgcC/xs7VnjSOvGNDRmBYabPB+d0YO61oUahiL8CcBmmaAfDpvqDFdTnK9DXs
+9t4kbuskoJh9Gvs7UTsHv8WtkZXPwwcuDQARAQABiQG2BBgBCgAgFiEE+T3dUuBr
+pJiSYkj4WrGc2ygk4JsFAmAtGcoCGwwACgkQWrGc2ygk4JvYwQv6AmLqdfj0FdX2
+vn2GILHCE445eiBM0qbyqmPo4jtbPUo6bMDs7An1BHF0P6bx/0uLezGtzRSIaRhJ
+iwt9PbH1se+ux3VFGk96RGVfTGiR2cwnnm3wEZOHP247i8Y7G0+r0B7u50d9EH7f
+2ZeY/D1Q5fENVNAKXV6kkqc86vsOvBv3q5qc2gwxyM7QI2Ybpjr19eFVII1zWjGY
+8u5hEhUT6SURIWxRNqaX5D6uxn33/oYLaz/Q6+Z0/b6t1WBy0boYdSBcthQVCzIF
+YlHMg6EQy8kBRqXXtqeRwf5zLXEYW08nMSzNFoCHe9AO1crBemHpNFGGQ2akRaED
+clzCkRfSGahPRGbUZ0fHEnsUjOm2TRM4/CENtDXLGAoDhWbURQgaiVzjwmUSBYxb
+jjEXsdATgU/UO4Oc36p5pgUuMrqmbELmUrvblZY/oBsSCjGIeT2WojoJ9jmnlzsH
+Da7ppTArEtMEeu0ggyaMtGXOv3fDgtOphGAUnl4zom13dAzZYuh8
+=R2yO
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/docker/jazzer-autofuzz/Dockerfile b/docker/jazzer-autofuzz/Dockerfile
index 5d57f2a..11721e1 100644
--- a/docker/jazzer-autofuzz/Dockerfile
+++ b/docker/jazzer-autofuzz/Dockerfile
@@ -17,14 +17,14 @@
 FROM ubuntu:20.04
 
 ENV DEBIAN_FRONTEND=noninteractive
-RUN apt-get update && apt-get install -y curl openjdk-11-jdk-headless
+RUN apt-get update && apt-get install -y curl openjdk-17-jdk-headless
 
 WORKDIR /app
 RUN curl -L 'https://github.com/coursier/coursier/releases/download/v2.0.16/coursier.jar' -o coursier.jar && \
     chmod +x coursier.jar
 
 COPY entrypoint.sh /app/
-COPY --from=jazzer /app/jazzer_agent_deploy.jar /app/jazzer_driver /app/
+COPY --from=jazzer /app/* /app/
 
 WORKDIR /fuzzing
 ENTRYPOINT ["/app/entrypoint.sh"]
diff --git a/docker/jazzer-autofuzz/entrypoint.sh b/docker/jazzer-autofuzz/entrypoint.sh
index 6c17f12..7df2ec3 100755
--- a/docker/jazzer-autofuzz/entrypoint.sh
+++ b/docker/jazzer-autofuzz/entrypoint.sh
@@ -16,7 +16,7 @@
 set -e
 
 CP="$(/app/coursier.jar fetch --classpath "$1")"
-/app/jazzer_driver \
+/app/jazzer \
   --cp="$CP" \
   --autofuzz="$2" \
   "${@:3}"
diff --git a/docker/jazzer/Dockerfile b/docker/jazzer/Dockerfile
index bddfcb5..6797b76 100644
--- a/docker/jazzer/Dockerfile
+++ b/docker/jazzer/Dockerfile
@@ -15,27 +15,28 @@
 FROM ubuntu:20.04 AS builder
 
 ENV DEBIAN_FRONTEND=noninteractive
-RUN apt-get update && apt-get install -y curl git python3 python-is-python3 openjdk-11-jdk-headless
+RUN apt-get update && apt-get install -y curl git python3 openjdk-17-jdk-headless
 
 WORKDIR /root
-RUN curl -L https://github.com/bazelbuild/bazelisk/releases/download/v1.11.0/bazelisk-linux-amd64 -o /usr/bin/bazelisk && \
+RUN curl -L https://github.com/bazelbuild/bazelisk/releases/download/v1.15.0/bazelisk-linux-amd64 -o /usr/bin/bazelisk && \
     chmod +x /usr/bin/bazelisk && \
     git clone --depth=1 https://github.com/CodeIntelligenceTesting/jazzer.git && \
     cd jazzer && \
     # The LLVM toolchain requires ld and ld.gold to exist, but does not use them.
     touch /usr/bin/ld && \
     touch /usr/bin/ld.gold && \
-    BAZEL_DO_NOT_DETECT_CPP_TOOLCHAIN=1 \
-    bazelisk build --config=toolchain --extra_toolchains=@llvm_toolchain//:cc-toolchain-x86_64-linux \
-      //agent:jazzer_agent_deploy //driver:jazzer_driver
+    bazelisk build --config=docker //launcher:jazzer && \
+    mkdir -p /app && \
+    cp $(bazelisk cquery --config=docker --output=files //src/main/java/com/code_intelligence/jazzer:jazzer_standalone_deploy.jar) /app/jazzer_standalone.jar && \
+    cp $(bazelisk cquery --config=docker --output=files //launcher:jazzer) /app/
 
 # :debug includes a busybox shell, which is needed for libFuzzer's use of system() for e.g. the
 # -fork and -minimize_crash commands.
-FROM gcr.io/distroless/java:debug
+FROM gcr.io/distroless/java17:debug
 
-COPY --from=builder /root/jazzer/bazel-bin/agent/jazzer_agent_deploy.jar /root/jazzer/bazel-bin/driver/jazzer_driver /app/
+COPY --from=builder /app/* /app/
 # system() expects the shell at /bin/sh, but the image has it at /busybox/sh. We create a symlink,
 # but have to use the long form as a simple RUN <command> also requires /bin/sh.
 RUN ["/busybox/sh", "-c", "ln -s /busybox/sh /bin/sh"]
 WORKDIR /fuzzing
-ENTRYPOINT [ "/app/jazzer_driver" ]
+ENTRYPOINT [ "/app/jazzer" ]
diff --git a/docs/advanced.md b/docs/advanced.md
new file mode 100644
index 0000000..55c3a83
--- /dev/null
+++ b/docs/advanced.md
@@ -0,0 +1,156 @@
+## Advanced options
+
+* [Passing JVM arguments](#passing-jvm-arguments)
+* [Coverage instrumentation](#coverage-instrumentation)
+* [Trace instrumentation](#trace-instrumentation)
+* [Value profile](#value-profile)
+* [Custom hooks](#custom-hooks)
+* [Suppressing stack traces](#suppressing-stack-traces)
+* [Export coverage information](#export-coverage-information)
+* [Native libraries](#native-libraries)
+* [Fuzzing mutators](#fuzzing-mutators)
+
+<!-- Created by https://github.com/ekalinin/github-markdown-toc -->
+
+Various command line options are available to control the instrumentation and fuzzer execution.
+Since Jazzer is a libFuzzer-compiled binary, all positional and single dash command-line options are parsed by libFuzzer.
+Therefore, all Jazzer options are passed via double dash command-line flags, i.e., as `--option=value` (note the `=` instead of a space).
+
+A full list of command-line flags can be printed with the `--help` flag.
+For the available libFuzzer options please refer to [its documentation](https://llvm.org/docs/LibFuzzer.html) for a detailed description.
+
+### Passing JVM arguments
+
+When Jazzer is started using the `jazzer` binary, it starts a JVM in which it executes the fuzz target.
+Arguments for this JVM can be provided via the `JAVA_OPTS` environment variable.
+
+Alternatively, arguments can also be supplied via the `--jvm_args` argument.
+Multiple arguments are delimited by the classpath separator, which is `;` on Windows and `:` else.
+For example, to enable preview features as well as set a maximum heap size, add the following to the Jazzer invocation:
+
+```bash
+# Windows
+--jvm_args=--enable-preview;-Xmx1000m
+# Linux & macOS
+--jvm_args=--enable-preview:-Xmx1000m
+```
+
+Arguments specified with `--jvm_args` take precedence over those in `JAVA_OPTS`.
+
+### Coverage instrumentation
+
+The Jazzer agent inserts coverage markers into the JVM bytecode during class loading.
+libFuzzer uses this information to guide its input mutations towards increased coverage.
+
+It is possible to restrict instrumentation to only a subset of classes with the `--instrumentation_includes` flag.
+This is especially useful if coverage inside specific packages is of higher interest, e.g., the user library under test rather than an external parsing library in which the fuzzer is likely to get lost.
+Similarly, there is `--instrumentation_excludes` to exclude specific classes from instrumentation.
+Both flags take a list of glob patterns for the java class name separated by colon:
+
+```bash
+--instrumentation_includes=com.my_com.**:com.other_com.** --instrumentation_excludes=com.my_com.crypto.**
+```
+
+By default, JVM-internal classes and Java as well as Kotlin standard library classes are not instrumented, so these do not need to be excluded manually.
+
+### Trace instrumentation
+
+The agent adds additional hooks for tracing compares, integer divisions, switch statements and array indices.
+These hooks correspond to [clang's data flow hooks](https://clang.llvm.org/docs/SanitizerCoverage.html#tracing-data-flow).
+The particular instrumentation types to apply can be specified using the `--trace` flag, which accepts the following values:
+
+* `cov`: AFL-style edge coverage
+* `cmp`: compares (int, long, String) and switch cases
+* `div`: divisors in integer divisions
+* `gep`: constant array indexes
+* `indir`: call through `Method#invoke`
+* `all`: shorthand to apply all available instrumentations (except `gep`)
+
+Multiple instrumentation types can be combined with a colon (Linux, macOS) or a semicolon (Windows).
+
+### Value profile
+
+The run-time flag `-use_value_profile=1` enables [libFuzzer's value profiling mode](https://llvm.org/docs/LibFuzzer.html#value-profile).
+When running with this flag, the feedback about compares and constants received from Jazzer's trace instrumentation is associated with the particular bytecode location and used to provide additional coverage instrumentation.
+See [ExampleValueProfileFuzzer.java](../examples/src/main/java/com/example/ExampleValueProfileFuzzer.java) for a fuzz target that would be very hard to fuzz without value profile.
+
+### Custom hooks
+
+In order to obtain information about data passed into functions such as `String.equals` or `String.startsWith`, Jazzer hooks invocations to these methods.
+This functionality is also available to fuzz targets, where it can be used to implement custom sanitizers or stub out methods that block the fuzzer from progressing (e.g. checksum verifications or random number generation).
+See [ExampleFuzzerHooks.java](../examples/src/main/java/com/example/ExampleFuzzerHooks.java) for an example of such a hook.
+An example for a sanitizer can be found in [ExamplePathTraversalFuzzerHooks.java](../examples/src/main/java/com/example/ExamplePathTraversalFuzzerHooks.java).
+
+Method hooks can be declared using the `@MethodHook` annotation defined in the `com.code_intelligence.jazzer.api` package, which is contained in `jazzer_standalone.jar` (binary release) or in the Maven artifact [`com.code-intelligence:jazzer-api`](https://search.maven.org/search?q=g:com.code-intelligence%20a:jazzer-api).
+See the [javadocs of the `@MethodHook` API](https://codeintelligencetesting.github.io/jazzer-docs/jazzer-api/com/code_intelligence/jazzer/api/MethodHook.html) for more details.
+
+To use the compiled method hooks, they have to be available on the classpath provided by `--cp` and can then be loaded by providing the flag `--custom_hooks`, which takes a colon-separated list of names of classes to load hooks from.
+Hooks have to be loaded from separate JAR files so that Jazzer can [add it to the bootstrap class loader search](https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/Instrumentation.html#appendToBootstrapClassLoaderSearch-java.util.jar.JarFile-).
+The list of custom hooks can alternatively be specified via the `Jazzer-Hook-Classes` attribute in the fuzz target JAR's manifest.
+
+### Suppressing stack traces
+
+With the flag `--keep_going=N` Jazzer continues fuzzing until `N` unique stack traces have been encountered.
+
+Particular stack traces can also be ignored based on their `DEDUP_TOKEN` by passing a comma-separated list of tokens via `--ignore=<token_1>,<token2>`.
+
+### Export coverage information
+
+The internally gathered JaCoCo coverage information can be exported in human-readable and JaCoCo execution data format (`.exec`).
+These can help identify code areas that have not been covered by the fuzzer and thus may require more comprehensive fuzz targets or a more extensive initial corpus to reach.
+
+The human-readable report contains coverage information, like branch and line coverage, on file level.
+It's useful to get a quick overview about the overall coverage. The flag `--coverage_report=<file>` can be used to generate it.
+
+Similar to the JaCoCo `dump` command, the flag `--coverage_dump=<file>` specifies a coverage dump file, often called `jacoco.exec`, that is generated after the fuzzing run. It contains a binary representation of the gathered coverage data in the JaCoCo format.
+
+The JaCoCo `report` command can be used to generate reports based on this coverage dump.
+The JaCoCo CLI tools are available on their [GitHub release page](https://github.com/jacoco/jacoco/releases) as `zip` file.
+The report tool is located in the `lib` folder and can be used as described in the JaCoCo [CLI documentation](https://www.eclemma.org/jacoco/trunk/doc/cli.html).
+For example the following command generates an HTML report in the folder `report` containing all classes available in `classes.jar` and their coverage as captured in the export `coverage.exec`.
+Source code to include in the report is searched for in `some/path/to/sources`.
+After execution the `index.html` file in the output folder can be opened in a browser.
+```shell
+java -jar path/to/jacococli.jar report coverage.exec \
+  --classfiles classes.jar \
+  --sourcefiles some/path/to/sources \
+  --html report \
+  --name FuzzCoverageReport
+```
+
+### Native libraries
+
+Jazzer supports fuzzing of native libraries loaded by the JVM, for example via `System.load()`.
+For the fuzzer to get coverage feedback, these libraries have to be compiled with `-fsanitize=fuzzer-no-link`.
+
+Additional sanitizers such as AddressSanitizer or UndefinedBehaviorSanitizer are often desirable to uncover bugs inside the native libraries.
+The required compilation flags for native libraries are as follows:
+- *AddressSanitizer*: `-fsanitize=fuzzer-no-link,address`
+- *UndefinedBehaviorSanitizer*: `-fsanitize=fuzzer-no-link,undefined` (add `-fno-sanitize-recover=all` to crash on UBSan reports)
+
+Then, start Jazzer with `--asan` and/or `--ubsan` to automatically preload the sanitizer runtimes.
+Jazzer defaults to using the runtimes associated with `clang` on the `PATH`.
+If you used a different compiler to compile the native libraries, specify it with `CC` to override this default.
+If no compiler is available in your runtime environment (e.g. in OSS-Fuzz) but you have a directory that contains the required sanitier libraries, specify its path in `JAZZER_NATIVE_SANITIZERS_DIR`.
+
+**Note:** On macOS, you may see Gatekeeper warnings when using `--asan` and/or `--ubsan` since these flags cause the native sanitizer libraries to be preloaded into the codesigned `java` executable via `DYLD_INSERT_LIBRARIES`.
+
+Sanitizers other than AddressSanitizer and UndefinedBehaviorSanitizer are not yet supported.
+Furthermore, due to the nature of the JVM's GC, LeakSanitizer reports too many false positives to be useful and is thus disabled.
+
+The fuzz targets `ExampleFuzzerWithASan` and `ExampleFuzzerWithUBSan` in the [`examples`](../examples/src/main/java/com/example) directory contain minimal working examples for fuzzing with native libraries.
+Also see `TurboJpegFuzzer` for a real-world example.
+
+### Fuzzing mutators
+
+LibFuzzer API offers two functions to customize the mutation strategy which is especially useful when fuzzing functions that require structured input.
+Jazzer does not define `LLVMFuzzerCustomMutator` nor `LLVMFuzzerCustomCrossOver` and leaves the mutation strategy entirely to libFuzzer.
+However, custom mutators can easily be integrated by compiling a mutator library which defines `LLVMFuzzerCustomMutator` (and optionally `LLVMFuzzerCustomCrossOver`) and pre-loading the mutator library:
+
+```bash
+# Using Bazel:
+LD_PRELOAD=libcustom_mutator.so bazel run //:jazzer -- <arguments>
+# Using the binary release:
+LD_PRELOAD=libcustom_mutator.so ./jazzer <arguments>
+```
+
diff --git a/docs/common.md b/docs/common.md
new file mode 100644
index 0000000..400b20f
--- /dev/null
+++ b/docs/common.md
@@ -0,0 +1,58 @@
+## Common options and workflows
+
+* [Reproducing a finding](#reproducing-a-finding)
+* [Minimizing a crashing input](#minimizing-a-crashing-input)
+* [Parallel execution](#parallel-execution)
+* [Autofuzz mode](#autofuzz-mode)
+
+<!-- Created by https://github.com/ekalinin/github-markdown-toc -->
+
+### Reproducing a finding
+
+When Jazzer manages to find an input that causes an uncaught exception or a failed assertion, it prints a Java stack trace and creates two files that aid in reproducing the crash without Jazzer:
+
+* `crash-<sha1_of_input>` contains the raw bytes passed to the fuzz target (just as with libFuzzer C/C++ fuzz targets).
+  The crash can be reproduced with Jazzer by passing the path to the crash file as the only positional argument.
+* `Crash-<sha1_of_input>.java` contains a class with a `main` function that invokes the fuzz target with the crashing input.
+  This is especially useful if using `FuzzedDataProvider` as the raw bytes of the input do not directly correspond to the values consumed by the fuzz target.
+  The `.java` file can be compiled with just the fuzz target and its dependencies in the classpath (plus `jazzer_standalone.jar` or `com.code-intelligence:jazzer-api:<version>` if using `FuzzedDataProvider`).
+
+### Minimizing a crashing input
+
+Every crash stack trace is accompanied by a `DEDUP_TOKEN` that uniquely identifies the relevant parts of the stack trace.
+This value is used by libFuzzer while minimizing a crashing input to ensure that the smaller inputs reproduce the "same" bug.
+To minimize a crashing input, execute Jazzer with the following arguments in addition to `--cp` and `--target_class`:
+
+```bash
+-minimize_crash=1 <path/to/crashing_input>
+```
+
+### Parallel execution
+
+libFuzzer offers the `-fork=N` and `-jobs=N` flags for parallel fuzzing, both of which are also supported by Jazzer.
+
+### Autofuzz mode
+
+The Autofuzz mode enables fuzzing arbitrary methods without having to manually create fuzz targets.
+Instead, Jazzer will attempt to generate suitable and varied inputs to a specified methods using only public API functions available on the classpath.
+
+To use Autofuzz, specify the `--autofuzz` flag and provide a fully [qualified method reference](https://docs.oracle.com/javase/specs/jls/se8/html/jls-15.html#jls-15.13), e.g.:
+```
+--autofuzz=org.apache.commons.imaging.Imaging::getBufferedImage
+```
+To autofuzz a constructor the `ClassType::new` format can be used.
+If there are multiple overloads, and you want Jazzer to only fuzz one, you can optionally specify the signature of the method to fuzz:
+```
+--autofuzz=org.apache.commons.imaging.Imaging::getBufferedImage(java.io.InputStream,java.util.Map)
+```
+The format of the signature agrees with that obtained from the part after the `#` of the link to the Javadocs for the particular method.
+
+Under the hood, Jazzer tries various ways of creating objects from the fuzzer input.
+For example, if a parameter is an interface or an abstract class, it will look for all concrete implementing classes on the classpath.
+Jazzer can also create objects from classes that follow the [builder design pattern](https://www.baeldung.com/creational-design-patterns#builder) or have a default constructor and use setters to set the fields.
+
+Creating objects from fuzzer input can lead to many reported exceptions.
+Jazzer addresses this issue by ignoring exceptions that the target method declares to throw.
+In addition to that, you can provide a list of exceptions to be ignored during fuzzing via the `--autofuzz_ignore` flag in the form of a comma-separated list.
+You can specify concrete exceptions (e.g., `java.lang.NullPointerException`), in which case also subclasses of these exception classes will be ignored, or glob patterns to ignore all exceptions in a specific package (e.g. `java.lang.*` or `com.company.**`).
+
diff --git a/docs/findings.md b/docs/findings.md
new file mode 100644
index 0000000..825fb36
--- /dev/null
+++ b/docs/findings.md
@@ -0,0 +1,53 @@
+## Findings
+
+Jazzer has found the following vulnerabilities and bugs.
+
+As Jazzer is used to fuzz JVM projects in OSS-Fuzz, further findings are listed [on the OSS-Fuzz issue tracker](https://bugs.chromium.org/p/oss-fuzz/issues/list).
+
+If you find bugs with Jazzer, we would like to hear from you!
+Feel free to [open an issue](https://github.com/CodeIntelligenceTesting/jazzer/issues/new) or submit a pull request.
+
+
+| Project                                                                                                                                   | Bug                                                                                           | Status                                                                                                                                                                   | CVE                                                                             | found by                                                                |
+|-------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------|-------------------------------------------------------------------------|
+| [hsqldb](https://hsqldb.org/)                                                                                                             | Remote code execution via prepared statement values                                           | [fixed](https://github.com/ryenus/hsqldb/commit/b6719c67b41eb9298c2451ad2829bf03b262a941) | [CVE-2022-41853](https://nvd.nist.gov/vuln/detail/CVE-2022-41853)               | [OSS-Fuzz](https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=50212) |
+| [protocolbuffers/protobuf](https://github.com/protocolbuffers/protobuf)                                                                   | Small protobuf messages can consume minutes of CPU time                                       | [fixed](https://github.com/protocolbuffers/protobuf/security/advisories/GHSA-h4h5-3hr4-j3g2)                                                                             | [CVE-2022-3171](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-3171)   | [OSS-Fuzz](https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=39330) |
+| [OpenJDK](https://github.com/openjdk/jdk)                                                                                                 | `OutOfMemoryError` via a small BMP image                                                      | [fixed](https://openjdk.java.net/groups/vulnerability/advisories/2022-01-18)                                                                                             | [CVE-2022-21360](https://nvd.nist.gov/vuln/detail/CVE-2022-21360)               | [Code Intelligence](https://code-intelligence.com)                      |
+| [OpenJDK](https://github.com/openjdk/jdk)                                                                                                 | `OutOfMemoryError` via a small TIFF image                                                     | [fixed](https://openjdk.java.net/groups/vulnerability/advisories/2022-01-18)                                                                                             | [CVE-2022-21366](https://nvd.nist.gov/vuln/detail/CVE-2022-21366)               | [Code Intelligence](https://code-intelligence.com)                      |
+| [protocolbuffers/protobuf](https://github.com/protocolbuffers/protobuf)                                                                   | Small protobuf messages can consume minutes of CPU time                                       | [fixed](https://github.com/protocolbuffers/protobuf/security/advisories/GHSA-wrvw-hg22-4m67)                                                                             | [CVE-2021-22569](https://nvd.nist.gov/vuln/detail/CVE-2021-22569)               | [OSS-Fuzz](https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=39330) |
+| [jhy/jsoup](https://github.com/jhy/jsoup)                                                                                                 | More than 19 Bugs found in HTML and XML parser                                                | [fixed](https://github.com/jhy/jsoup/security/advisories/GHSA-m72m-mhq2-9p6c)                                                                                            | [CVE-2021-37714](https://nvd.nist.gov/vuln/detail/CVE-2021-37714)               | [Code Intelligence](https://code-intelligence.com)                      |
+| [Apache/commons-compress](https://commons.apache.org/proper/commons-compress/)                                                            | Infinite loop when loading a crafted 7z                                                       | fixed                                                                                                                                                                    | [CVE-2021-35515](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-35515) | [Code Intelligence](https://code-intelligence.com)                      |
+| [Apache/commons-compress](https://commons.apache.org/proper/commons-compress/)                                                            | `OutOfMemoryError` when loading a crafted 7z                                                  | fixed                                                                                                                                                                    | [CVE-2021-35516](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-35516) | [Code Intelligence](https://code-intelligence.com)                      |
+| [Apache/commons-compress](https://commons.apache.org/proper/commons-compress/)                                                            | Infinite loop when loading a crafted TAR                                                      | fixed                                                                                                                                                                    | [CVE-2021-35517](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-35517) | [Code Intelligence](https://code-intelligence.com)                      |
+| [Apache/commons-compress](https://commons.apache.org/proper/commons-compress/)                                                            | `OutOfMemoryError` when loading a crafted ZIP                                                 | fixed                                                                                                                                                                    | [CVE-2021-36090](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-36090) | [Code Intelligence](https://code-intelligence.com)                      |
+| [Apache/PDFBox](https://pdfbox.apache.org/)                                                                                               | Infinite loop when loading a crafted PDF                                                      | fixed                                                                                                                                                                    | [CVE-2021-27807](https://cve.mitre.org/cgi-bin/cvename.cgi?name=2021-27807)     | [Code Intelligence](https://code-intelligence.com)                      |
+| [Apache/PDFBox](https://pdfbox.apache.org/)                                                                                               | OutOfMemoryError when loading a crafted PDF                                                   | fixed                                                                                                                                                                    | [CVE-2021-27906](https://cve.mitre.org/cgi-bin/cvename.cgi?name=2021-27906)     | [Code Intelligence](https://code-intelligence.com)                      |
+| [netplex/json-smart-v1](https://github.com/netplex/json-smart-v1) <br/> [netplex/json-smart-v2](https://github.com/netplex/json-smart-v2) | `JSONParser#parse` throws an undeclared exception                                             | [fixed](https://github.com/netplex/json-smart-v2/issues/60)                                                                                                              | [CVE-2021-27568](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-27568) | [@GanbaruTobi](https://github.com/GanbaruTobi)                          |
+| [OWASP/json-sanitizer](https://github.com/OWASP/json-sanitizer)                                                                           | Output can contain`</script>` and `]]>`, which allows XSS                                     | [fixed](https://groups.google.com/g/json-sanitizer-support/c/dAW1AeNMoA0)                                                                                                | [CVE-2021-23899](https://cve.mitre.org/cgi-bin/cvename.cgi?name=2021-23899)     | [Code Intelligence](https://code-intelligence.com)                      |
+| [OWASP/json-sanitizer](https://github.com/OWASP/json-sanitizer)                                                                           | Output can be invalid JSON and undeclared exceptions can be thrown                            | [fixed](https://groups.google.com/g/json-sanitizer-support/c/dAW1AeNMoA0)                                                                                                | [CVE-2021-23900](https://cve.mitre.org/cgi-bin/cvename.cgi?name=2021-23900)     | [Code Intelligence](https://code-intelligence.com)                      |
+| [alibaba/fastjson](https://github.com/alibaba/fastjson)                                                                                    | `JSON#parse` throws undeclared exceptions                                                     | [fixed](https://github.com/alibaba/fastjson/issues/3631)                                                                                                                 |                                                                                 | [Code Intelligence](https://code-intelligence.com)                      |
+| [Apache/commons-compress](https://commons.apache.org/proper/commons-compress/)                                                            | Infinite loop and `OutOfMemoryError` in `TarFile`                                             | [fixed](https://issues.apache.org/jira/browse/COMPRESS-569)                                                                                                              |                                                                                 | [Code Intelligence](https://code-intelligence.com)                      |
+| [Apache/commons-compress](https://commons.apache.org/proper/commons-compress/)                                                            | `NullPointerException` in `ZipFile`                                                           | [fixed](https://issues.apache.org/jira/browse/COMPRESS-568)                                                                                                              |                                                                                 | [Code Intelligence](https://code-intelligence.com)                      |
+| [Apache/commons-imaging](https://commons.apache.org/proper/commons-imaging/)                                                              | Parsers for multiple image formats throw undeclared exceptions                                | [reported](https://issues.apache.org/jira/browse/IMAGING-279?jql=project%20%3D%20%22Commons%20Imaging%22%20AND%20reporter%20%3D%20Meumertzheim%20)                       |                                                                                 | [Code Intelligence](https://code-intelligence.com)                      |
+| [Apache/PDFBox](https://pdfbox.apache.org/)                                                                                               | Various undeclared exceptions                                                                 | [fixed](https://issues.apache.org/jira/browse/PDFBOX-5108?jql=project%20%3D%20PDFBOX%20AND%20reporter%20in%20(Meumertzheim))                                             |                                                                                 | [Code Intelligence](https://code-intelligence.com)                      |
+| [cbeust/klaxon](https://github.com/cbeust/klaxon)                                                                                         | Default parser throws runtime exceptions                                                      | [fixed](https://github.com/cbeust/klaxon/pull/330)                                                                                                                       |                                                                                 | [Code Intelligence](https://code-intelligence.com)                      |
+| [FasterXML/jackson-dataformats-binary](https://github.com/FasterXML/jackson-dataformats-binary)                                           | `CBORParser` throws an undeclared exception due to missing bounds checks when parsing Unicode | [fixed](https://github.com/FasterXML/jackson-dataformats-binary/issues/236)                                                                                              |                                                                                 | [Code Intelligence](https://code-intelligence.com)                      |
+| [FasterXML/jackson-dataformats-binary](https://github.com/FasterXML/jackson-dataformats-binary)                                           | `CBORParser` throws an undeclared exception on dangling arrays                                | [fixed](https://github.com/FasterXML/jackson-dataformats-binary/issues/240)                                                                                              |                                                                                 | [Code Intelligence](https://code-intelligence.com)                      |
+| [ngageoint/tiff-java](https://github.com/ngageoint/tiff-java)                                                                             | `readTiff ` Index Out Of Bounds                                                               | [fixed](https://github.com/ngageoint/tiff-java/issues/38)                                                                                                                |                                                                                 | [@raminfp](https://github.com/raminfp)                                  |
+| [google/re2j](https://github.com/google/re2j)                                                                                             | `NullPointerException` in `Pattern.compile`                                                   | [reported](https://github.com/google/re2j/issues/148)                                                                                                                    |                                                                                 | [@schirrmacher](https://github.com/schirrmacher)                        |
+| [google/gson](https://github.com/google/gson)                                                                                             | `ArrayIndexOutOfBounds` in `ParseString`                                                      | [fixed](https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=40838)                                                                                                     |                                                                                 | [@DavidKorczynski](https://twitter.com/Davkorcz)                        |
+| [snakeyaml](https://bitbucket.org/snakeyaml/snakeyaml/src/master/)                                                                        | `StackOverflowError` in `Composer`                                                            | [fixed](https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=47024)                                                                                                     | [CVE-2022-38749](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-38749)                                                                               | [Code Intelligence](https://code-intelligence.com)                        |
+| [snakeyaml](https://bitbucket.org/snakeyaml/snakeyaml/src/master/)                                                                        | `StackOverflowError` in `BaseConstructor`                                                            | [fixed](https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=47027)                                                                                                     | [CVE-2022-38750](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-38750)                                                                               | [Code Intelligence](https://code-intelligence.com)                        |
+| [snakeyaml](https://bitbucket.org/snakeyaml/snakeyaml/src/master/)                                                                        | `StackOverflowError` caused by regex parse failure in `java.util.regex`                                                            | [fixed](https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=47039)                                                                                                     | [CVE-2022-38751](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-38751)                                                                               | [Code Intelligence](https://code-intelligence.com)                        |
+| [snakeyaml](https://bitbucket.org/snakeyaml/snakeyaml/src/master/)                                                                        | `StackOverflowError` caused by recursion in `java.util.ArrayList`                                                            | [fixed](https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=47081)                                                                                                     | [CVE-2022-38752](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-38752)                                                                               | [Code Intelligence](https://code-intelligence.com)                        |
+| [snakeyaml](https://bitbucket.org/snakeyaml/snakeyaml/src/master/)                                                                        | `StackOverflowError` caused by recursion in `java.util.ArrayList`                                                            | [fixed](https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=50355)                                                                                                     | [CVE-2022-41854](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-41854)                                                                               | [Code Intelligence](https://code-intelligence.com)                        |
+| [jettison-json/jettison](https://github.com/jettison-json/jettison/)                                                                       | `StackOverflowError` in `JSONTokener`                                                            | [fixed](https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=46538)                                                                                                     | [CVE-2022-40149](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-40149)                                                                               | [Code Intelligence](https://code-intelligence.com)                        |
+| [jettison-json/jettison](https://github.com/jettison-json/jettison/)                                                                       | `OutOfMemoryError` when parsing json objects                                                            | [fixed](https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=46549)                                                                                                     | [CVE-2022-40150](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-40150)                                                                               | [Code Intelligence](https://code-intelligence.com)                        |
+| [x-stream/xstream](https://github.com/x-stream/xstream/)                                                                                   | `StackOverflowError` in `xstream.core`                                                            | [fixed](https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=47367)                                                                                                     | [CVE-2022-40151](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-40151)                                                                               | [Code Intelligence](https://code-intelligence.com)                        |
+| [FasterXML/woodstox](https://github.com/FasterXML/woodstox/)                                                                               | `StackOverflowError` in `WordResolver`                                                            | [fixed](https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=47434)                                                                                                     | [CVE-2022-40152](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-40152)                                                                               | [Code Intelligence](https://code-intelligence.com)                        |
+| [alibaba/fastjson2](https://github.com/alibaba/fastjson2/)                                                                                 | `StackOverflowError` in `DefaultJSONParser`                                                            | [not fixed](https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=32410)                                                                                                 | [CVE-2022-40173](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-40173)                                                                               | [Code Intelligence](https://code-intelligence.com)                        |
+| [alibaba/fastjson2](https://github.com/alibaba/fastjson2/)                                                                                 | `StackOverflowError` in `JSONPath`                                                            | [not fixed](https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=35777)                                                                                                 | [CVE-2022-40174](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-40174)                                                                               | [Code Intelligence](https://code-intelligence.com)                        |
+| [alibaba/fastjson2](https://github.com/alibaba/fastjson2/)                                                                                 | `StackOverflowError` in `JSONPath`                                                            | [not fixed](https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=47686)                                                                                                 | [CVE-2022-40175](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-40175)                                                                               | [Code Intelligence](https://code-intelligence.com)                        |
+| [alibaba/fastjson2](https://github.com/alibaba/fastjson2/)                                                                                 | `StackOverflowError` in `DefaultJSONParser`                                                            | [not fixed](https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=37313)                                                                                                 | [CVE-2022-41855](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-41855)                                                                               | [Code Intelligence](https://code-intelligence.com)                        |
+| [alibaba/fastjson2](https://github.com/alibaba/fastjson2/)                                                                                 | `StackOverflowError` in `SerialContext`                                                            | [not fixed](https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=33768)                                                                                                 | [CVE-2022-41856](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-41856)                                                                               | [Code Intelligence](https://code-intelligence.com)                        |
+| [Apache/commons-jxpath](https://github.com/apache/commons-jxpath/)                                                                         | Remote code execution via crafted `XPath` expression                                                           | [not fixed](https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=47133)                                                                                                 |                                                                                                    | [Code Intelligence](https://code-intelligence.com)                        |
diff --git a/docs/images/fuzzing-flow.svg b/docs/images/fuzzing-flow.svg
new file mode 100644
index 0000000..fbdc13c
--- /dev/null
+++ b/docs/images/fuzzing-flow.svg
@@ -0,0 +1 @@
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1172" height="1042"><desc>title%20JUnit%20Integration%20Flow%20-%20Fuzzing%20Mode%0A%0Aparticipant%20JUnit%0A%0Aparticipantgroup%20Jazzer%0Aparticipant%20evaluateExecutionConditions%0Aparticipant%20provideArguments%0Aend%0A%0Aactivate%20JUnit%0A%0Aloop%20for%20all%20tests%0Acreate%20Tested%20Method%0A%0AJUnit-%3EevaluateExecutionConditions%3Acheck%20if%20test%20enabled%0Aactivate%20evaluateExecutionConditions%0Anote%20over%20evaluateExecutionConditions%3A%20first%20test%20checked%20will%20be%20cached%20and%20enabled%2C%20all%20other%20disabled%0AevaluateExecutionConditions--%3EJUnit%3A%20return%20if%20enabled%0Adeactivate%20evaluateExecutionConditions%0A%0Aalt%20if%20test%20enabled%0Anote%20over%20JUnit%3Athis%20will%20only%20be%20entered%20by%201%20test%20in%20any%20given%20run%0AJUnit-%3EprovideArguments%3Aget%20argument%20sets%0Aactivate%20provideArguments%0Anote%20over%20provideArguments%3A%20returns%201%20set%20of%20empty%20arguments%0AprovideArguments--%3EJUnit%3Areturn%20argument%20set%0Adeactivate%20provideArguments%0A%0AJUnit-%3EevaluateExecutionConditions%3A%20check%20if%20argument%20set%20enabled%0Aactivate%20evaluateExecutionConditions%0AevaluateExecutionConditions--%3EJUnit%3A%20always%20returns%20enabled%0Adeactivate%20evaluateExecutionConditions%0A%0AJUnit-%3ETested%20Method%3A%20fuzz%20test%0Aactivate%20Tested%20Method%0ATested%20Method--%3EJUnit%3A%20return%20results%0Adeactivate%20Tested%20Method%0A%0Aend%0A%0Aend%20</desc><defs/><g><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g><rect fill="white" stroke="none" x="0" y="0" width="1172" height="1042"/></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="16.5pt" font-style="normal" font-weight="normal" text-decoration="normal" x="401.07136897496525" y="26.3876361" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">JUnit Integration Flow - Fuzzing Mode</text></g><g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 479.99841997725264 54.270571579000006 L 946.1803889211316 54.270571579000006 L 946.1803889211316 1033.3398296760001 L 479.99841997725264 1033.3398296760001 L 479.99841997725264 54.270571579000006" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray=""/></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="691.4727376553689" y="78.899031939" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">Jazzer</text></g></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 237.28706385882813 143.72465795799997 L 237.28706385882813 1033.3398296760001" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray="13.532121076923076,5.863919133333333"/><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 602.5313569821316 143.72465795799997 L 602.5313569821316 1033.3398296760001" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray="13.532121076923076,5.863919133333333"/><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 858.7307877927175 143.72465795799997 L 858.7307877927175 1033.3398296760001" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray="13.532121076923076,5.863919133333333"/><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 1067.0255126417273 276.89426147599994 L 1067.0255126417273 1033.3398296760001" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray="13.532121076923076,5.863919133333333"/></g><g><path fill="none" stroke="none"/><g><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 201.48333990453517 96.226912978 L 273.0907878131211 96.226912978 L 273.0907878131211 143.724657958 L 201.48333990453517 143.724657958 L 201.48333990453517 96.226912978 Z" stroke-miterlimit="10" stroke-width="2.814681184" stroke-dasharray=""/></g><g><g/><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="220.57039668353517" y="126.13290055799999" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">JUnit</text></g><path fill="none" stroke="none"/><g><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 488.79429867725264 96.226912978 L 716.2684152870105 96.226912978 L 716.2684152870105 143.724657958 L 488.79429867725264 143.724657958 L 488.79429867725264 96.226912978 Z" stroke-miterlimit="10" stroke-width="2.814681184" stroke-dasharray=""/></g><g><g/><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="507.88135545625266" y="126.13290055799999" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">evaluateExecutionConditions</text></g><path fill="none" stroke="none"/><g><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 780.0770653643034 96.226912978 L 937.3845102211316 96.226912978 L 937.3845102211316 143.724657958 L 780.0770653643034 143.724657958 L 780.0770653643034 96.226912978 Z" stroke-miterlimit="10" stroke-width="2.814681184" stroke-dasharray=""/></g><g><g/><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="799.1641221433034" y="126.13290055799999" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">provideArguments</text></g></g><g><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 228.49118515882813 152.52053665799997 L 246.08294255882814 152.52053665799997 L 246.08294255882814 1015.7480722760001 L 228.49118515882813 1015.7480722760001 L 228.49118515882813 152.52053665799997" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 593.7354782821316 326.1511821959999 L 611.3272356821316 326.1511821959999 L 611.3272356821316 442.2567810359999 L 593.7354782821316 442.2567810359999 L 593.7354782821316 326.1511821959999" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 849.9349090927175 605.860124856 L 867.5266664927175 605.860124856 L 867.5266664927175 721.965723696 L 849.9349090927175 721.965723696 L 849.9349090927175 605.860124856" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 593.7354782821316 771.2226444160001 L 611.3272356821316 771.2226444160001 L 611.3272356821316 820.4795651360001 L 593.7354782821316 820.4795651360001 L 593.7354782821316 771.2226444160001" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 1058.2296339417273 869.7364858560002 L 1075.8213913417273 869.7364858560002 L 1075.8213913417273 918.9934065760002 L 1058.2296339417273 918.9934065760002 L 1058.2296339417273 869.7364858560002" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray=""/><path fill="none" stroke="none"/><g><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 999.8217909762527 229.39651649599995 L 1134.229234307202 229.39651649599995 L 1134.229234307202 276.89426147599994 L 999.8217909762527 276.89426147599994 L 999.8217909762527 229.39651649599995 Z" stroke-miterlimit="10" stroke-width="2.814681184" stroke-dasharray=""/></g><g><g/><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="1018.9088477552527" y="259.3025040759999" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">Tested Method</text></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="353.02587912165177" y="319.11447923599997" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">check if test enabled</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 246.08294255882814 326.1511821959999 L 579.2515980227982 326.1511821959999" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray=""/><g transform="translate(593.7354782821316,326.1511821959999) translate(-593.7354782821316,-326.1511821959999)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 579.0756804487983 318.8212832793333 L 593.7354782821316 326.1511821959999 L 579.0756804487983 333.4810811126666 Z"/></g></g><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 372.71956227154567 352.53881829599993 L 816.5105700327176 352.53881829599993 L 832.3431516927176 368.37139995599995 L 832.3431516927176 392.9998603159999 L 372.71956227154567 392.9998603159999 L 372.71956227154567 352.53881829599993 M 816.5105700327176 352.53881829599993 L 816.5105700327176 368.37139995599995 L 832.3431516927176 368.37139995599995" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray=""/><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="397.34802263154563" y="377.1672786559999" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">first test checked will be cached and enabled, all other disabled</text></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="366.85921118341935" y="435.22007807599994" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">return if enabled</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 593.7354782821316 442.2567810359999 L 260.5668228181615 442.2567810359999" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray="7.0367029599999995"/><g transform="translate(246.08294255882814,442.2567810359999) translate(-246.08294255882814,-442.2567810359999)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 260.74274039216147 434.92688211933324 L 246.08294255882814 442.2567810359999 L 260.74274039216147 449.58667995266654 Z"/></g></g><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 52.77527220000002 516.1421621159999 L 405.96627385765623 516.1421621159999 L 421.79885551765625 531.9747437759999 L 421.79885551765625 556.6032041359999 L 52.77527220000002 556.6032041359999 L 52.77527220000002 516.1421621159999 M 405.96627385765623 516.1421621159999 L 405.96627385765623 531.9747437759999 L 421.79885551765625 531.9747437759999" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray=""/><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="77.40373256000001" y="540.770622476" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">this will only be entered by 1 test in any given run</text></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="489.27559223812636" y="598.8234218959999" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">get argument sets</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 246.08294255882814 605.860124856 L 835.4510288333842 605.860124856" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray=""/><g transform="translate(849.9349090927175,605.860124856) translate(-849.9349090927175,-605.860124856)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 835.2751112593842 598.5302259393333 L 849.9349090927175 605.860124856 L 835.2751112593842 613.1900237726667 Z"/></g></g><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 726.4356633091824 632.247760956 L 975.1933306162528 632.247760956 L 991.0259122762527 648.0803426159999 L 991.0259122762527 672.708802976 L 726.4356633091824 672.708802976 L 726.4356633091824 632.247760956 M 975.1933306162528 632.247760956 L 975.1933306162528 648.0803426159999 L 991.0259122762527 648.0803426159999" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray=""/><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="751.0641236691823" y="656.876221316" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">returns 1 set of empty arguments</text></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="483.95892277401504" y="714.929020736" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">return argument set</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 849.9349090927175 721.965723696 L 260.5668228181615 721.965723696" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray="7.0367029599999995"/><g transform="translate(246.08294255882814,721.965723696) translate(-246.08294255882814,-721.965723696)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 260.74274039216147 714.6358247793333 L 246.08294255882814 721.965723696 L 260.74274039216147 729.2956226126668 Z"/></g></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="322.009208894601" y="764.185941456" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">check if argument set enabled</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 246.08294255882814 771.2226444160001 L 579.2515980227982 771.2226444160001" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray=""/><g transform="translate(593.7354782821316,771.2226444160001) translate(-593.7354782821316,-771.2226444160001)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 579.0756804487983 763.8927454993334 L 593.7354782821316 771.2226444160001 L 579.0756804487983 778.5525433326668 Z"/></g></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="344.4258806475307" y="813.4428621760001" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">always returns enabled</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 593.7354782821316 820.4795651360001 L 260.5668228181615 820.4795651360001" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray="7.0367029599999995"/><g transform="translate(246.08294255882814,820.4795651360001) translate(-246.08294255882814,-820.4795651360001)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 260.74274039216147 813.1496662193334 L 246.08294255882814 820.4795651360001 L 260.74274039216147 827.8094640526668 Z"/></g></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="624.8562890132172" y="862.6997828960001" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">fuzz test</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 246.08294255882814 869.7364858560002 L 1043.745753682394 869.7364858560002" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray=""/><g transform="translate(1058.2296339417273,869.7364858560002) translate(-1058.2296339417273,-869.7364858560002)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 1043.569836108394 862.4065869393335 L 1058.2296339417273 869.7364858560002 L 1043.569836108394 877.0663847726669 Z"/></g></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="609.3229561885101" y="911.9567036160001" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">return results</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 1058.2296339417273 918.9934065760002 L 260.5668228181615 918.9934065760002" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray="7.0367029599999995"/><g transform="translate(246.08294255882814,918.9934065760002) translate(-246.08294255882814,-918.9934065760002)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 260.74274039216147 911.6635076593335 L 246.08294255882814 918.9934065760002 L 260.74274039216147 926.3233054926669 Z"/></g></g><g><g/><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 17.59175740000001 178.90817275799998 L 1146.1884209417274 178.90817275799998 L 1146.1884209417274 971.7686787760002 L 17.59175740000001 971.7686787760002 L 17.59175740000001 178.90817275799998 Z" stroke-miterlimit="10" stroke-width="2.5131082" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 17.59175740000001 178.90817275799998 L 17.59175740000001 200.01828163799996 L 77.54193861235352 200.01828163799996 L 88.09699305235353 189.46322719799997 L 88.09699305235353 178.90817275799998 L 17.59175740000001 178.90817275799998" stroke-miterlimit="10" stroke-width="2.5131082" stroke-dasharray=""/><text fill="black" stroke="none" font-family="sans-serif" font-size="8.8pt" font-style="normal" font-weight="bold" text-decoration="normal" x="35.18351480000001" y="192.98157867799995" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">loop</text><g><rect fill="white" stroke="none" x="103.04998684235353" y="180.31551334999995" width="75.71086157058593" height="18.295427696"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="8.8pt" font-style="normal" font-weight="bold" text-decoration="normal" x="105.68875045235353" y="192.98157867799995" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">[for all tests]</text></g><g><g/><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 35.18351480000001 468.6444171359999 L 1128.5966635417274 468.6444171359999 L 1128.5966635417274 945.3810426760002 L 35.18351480000001 945.3810426760002 L 35.18351480000001 468.6444171359999 Z" stroke-miterlimit="10" stroke-width="2.5131082" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 35.18351480000001 468.6444171359999 L 35.18351480000001 489.7545260159999 L 84.06702940926515 489.7545260159999 L 94.62208384926515 479.1994715759999 L 94.62208384926515 468.6444171359999 L 35.18351480000001 468.6444171359999" stroke-miterlimit="10" stroke-width="2.5131082" stroke-dasharray=""/><text fill="black" stroke="none" font-family="sans-serif" font-size="8.8pt" font-style="normal" font-weight="bold" text-decoration="normal" x="52.77527220000001" y="482.7178230559999" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">alt</text><g><rect fill="white" stroke="none" x="109.57507763926516" y="470.05175772799987" width="92.01085699294921" height="18.295427696"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="8.8pt" font-style="normal" font-weight="bold" text-decoration="normal" x="112.21384124926516" y="482.7178230559999" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">[if test enabled]</text></g></g><g/><g/><g/></g></svg>
\ No newline at end of file
diff --git a/docs/images/regression-flow.svg b/docs/images/regression-flow.svg
new file mode 100644
index 0000000..30bf1eb
--- /dev/null
+++ b/docs/images/regression-flow.svg
@@ -0,0 +1 @@
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1022" height="908"><desc>title%20JUnit%20Integration%20Flow%20-%20Regression%20Mode%0A%0Aparticipant%20JUnit%0A%0Aparticipantgroup%20Jazzer%0Aparticipant%20evaluateExecutionConditions%0Aparticipant%20provideArguments%0Aend%0A%0Aactivate%20JUnit%0Aloop%20for%20all%20tests%0A%0Acreate%20Tested%20Method%0A%0AJUnit-%3EevaluateExecutionConditions%3Acheck%20if%20test%20enabled%0Aactivate%20evaluateExecutionConditions%0Anote%20over%20evaluateExecutionConditions%3A%20all%20tests%20are%20enabled%20in%20regression%0AevaluateExecutionConditions--%3EJUnit%3Atest%20enabled%0Adeactivate%20evaluateExecutionConditions%0A%0AJUnit-%3EprovideArguments%3Aget%20argument%20set%0Aactivate%20provideArguments%0AprovideArguments--%3EJUnit%3Areturn%20stream%20of%20argument%20sets%0Adeactivate%20provideArguments%0A%0Aloop%20for%20all%20arguments%0AJUnit-%3EevaluateExecutionConditions%3Acheck%20if%20this%20argument%20set%20should%20be%20run%0Aactivate%20evaluateExecutionConditions%0AevaluateExecutionConditions--%3EJUnit%3Aargument%20set%20enabled%0Adeactivate%20evaluateExecutionConditions%0A%0AJUnit-%3ETested%20Method%3A%20run%20test%0Aactivate%20Tested%20Method%0ATested%20Method--%3EJUnit%3A%20return%20results%0Adeactivate%20Tested%20Method%0Adestroysilent%20Tested%20Method%0A%0Aend%0A%0Aend%0A</desc><defs/><g><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g><rect fill="white" stroke="none" x="0" y="0" width="1022" height="908"/></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="16.5pt" font-style="normal" font-weight="normal" text-decoration="normal" x="308.73648944409024" y="26.3876361" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">JUnit Integration Flow - Regression Mode</text></g><g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 411.861891363151 54.270571579000006 L 831.826967629737 54.270571579000006 L 831.826967629737 899.642473436 L 411.861891363151 899.642473436 L 411.861891363151 54.270571579000006" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray=""/></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="600.2277627026208" y="78.899031939" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">Jazzer</text></g></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 105.5505444 143.72465795799997 L 105.5505444 899.642473436" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray="13.532121076923076,5.863919133333333"/><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 534.39482836803 143.72465795799997 L 534.39482836803 899.642473436" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray="13.532121076923076,5.863919133333333"/><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 744.377366501323 143.72465795799997 L 744.377366501323 899.642473436" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray="13.532121076923076,5.863919133333333"/><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 916.6224466952117 276.89426147599994 L 916.6224466952117 785.296050336" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray="13.532121076923076,5.863919133333333"/></g><g><path fill="none" stroke="none"/><g><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 69.74682044570704 96.226912978 L 141.35426835429297 96.226912978 L 141.35426835429297 143.724657958 L 69.74682044570704 143.724657958 L 69.74682044570704 96.226912978 Z" stroke-miterlimit="10" stroke-width="2.814681184" stroke-dasharray=""/></g><g><g/><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="88.83387722470704" y="126.13290055799999" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">JUnit</text></g><path fill="none" stroke="none"/><g><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 420.657770063151 96.226912978 L 648.1318866729089 96.226912978 L 648.1318866729089 143.724657958 L 420.657770063151 143.724657958 L 420.657770063151 96.226912978 Z" stroke-miterlimit="10" stroke-width="2.814681184" stroke-dasharray=""/></g><g><g/><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="439.74482684215104" y="126.13290055799999" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">evaluateExecutionConditions</text></g><path fill="none" stroke="none"/><g><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 665.7236440729089 96.226912978 L 823.031088929737 96.226912978 L 823.031088929737 143.724657958 L 665.7236440729089 143.724657958 L 665.7236440729089 96.226912978 Z" stroke-miterlimit="10" stroke-width="2.814681184" stroke-dasharray=""/></g><g><g/><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="684.8107008519089" y="126.13290055799999" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">provideArguments</text></g></g><g><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 96.7546657 152.52053665799997 L 114.34642310000001 152.52053665799997 L 114.34642310000001 882.050716036 L 96.7546657 882.050716036 L 96.7546657 152.52053665799997" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 525.59894966803 326.1511821959999 L 543.19070706803 326.1511821959999 L 543.19070706803 442.2567810359999 L 525.59894966803 442.2567810359999 L 525.59894966803 326.1511821959999" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 735.581487801323 491.5137017559999 L 753.173245201323 491.5137017559999 L 753.173245201323 540.7706224759999 L 735.581487801323 540.7706224759999 L 735.581487801323 491.5137017559999" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 525.59894966803 637.5252881759999 L 543.19070706803 637.5252881759999 L 543.19070706803 686.7822088959999 L 525.59894966803 686.7822088959999 L 525.59894966803 637.5252881759999" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 907.8265679952117 736.039129616 L 925.4183253952117 736.039129616 L 925.4183253952117 785.296050336 L 907.8265679952117 785.296050336 L 907.8265679952117 736.039129616" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray=""/><path fill="none" stroke="none"/><g><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 849.4187250297371 229.39651649599995 L 983.8261683606863 229.39651649599995 L 983.8261683606863 276.89426147599994 L 849.4187250297371 276.89426147599994 L 849.4187250297371 229.39651649599995 Z" stroke-miterlimit="10" stroke-width="2.814681184" stroke-dasharray=""/></g><g><g/><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="868.5057818087371" y="259.3025040759999" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">Tested Method</text></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="253.08935508518687" y="319.11447923599997" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">check if test enabled</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 114.34642310000001 326.1511821959999 L 511.1150694086966 326.1511821959999" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray=""/><g transform="translate(525.59894966803,326.1511821959999) translate(-525.59894966803,-326.1511821959999)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 510.9391518346966 318.8212832793333 L 525.59894966803 326.1511821959999 L 510.9391518346966 333.4810811126666 Z"/></g></g><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 400.01636800803 352.53881829599993 L 652.94070706803 352.53881829599993 L 668.7732887280299 368.37139995599995 L 668.7732887280299 392.9998603159999 L 400.01636800803 392.9998603159999 L 400.01636800803 352.53881829599993 M 652.94070706803 352.53881829599993 L 652.94070706803 368.37139995599995 L 668.7732887280299 368.37139995599995" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray=""/><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="424.64482836802995" y="377.1672786559999" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">all tests are enabled in regression</text></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="279.9893527963685" y="435.22007807599994" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">test enabled</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 525.59894966803 442.2567810359999 L 128.83030335933336 442.2567810359999" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray="7.0367029599999995"/><g transform="translate(114.34642310000001,442.2567810359999) translate(-114.34642310000001,-442.2567810359999)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 129.00622093333334 434.92688211933324 L 114.34642310000001 442.2567810359999 L 129.00622093333334 449.58667995266654 Z"/></g></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="369.89728980124744" y="484.4769987959999" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">get argument set</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 114.34642310000001 491.5137017559999 L 721.0976075419896 491.5137017559999" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray=""/><g transform="translate(735.581487801323,491.5137017559999) translate(-735.581487801323,-491.5137017559999)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 720.9216899679897 484.1838028393332 L 735.581487801323 491.5137017559999 L 720.9216899679897 498.8436006726665 Z"/></g></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="324.63061957419666" y="533.7339195159998" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">return stream of argument sets</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 735.581487801323 540.7706224759999 L 128.83030335933336 540.7706224759999" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray="7.0367029599999995"/><g transform="translate(114.34642310000001,540.7706224759999) translate(-114.34642310000001,-540.7706224759999)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 129.00622093333334 533.4407235593332 L 114.34642310000001 540.7706224759999 L 129.00622093333334 548.1005213926666 Z"/></g></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="190.2726894357728" y="630.4885852159998" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">check if this argument set should be run</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 114.34642310000001 637.5252881759999 L 511.1150694086966 637.5252881759999" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray=""/><g transform="translate(525.59894966803,637.5252881759999) translate(-525.59894966803,-637.5252881759999)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 510.9391518346966 630.1953892593332 L 525.59894966803 637.5252881759999 L 510.9391518346966 644.8551870926666 Z"/></g></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="248.972686384015" y="679.7455059359999" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">argument set enabled</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 525.59894966803 686.7822088959999 L 128.83030335933336 686.7822088959999" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray="7.0367029599999995"/><g transform="translate(114.34642310000001,686.7822088959999) translate(-114.34642310000001,-686.7822088959999)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 129.00622093333334 679.4523099793332 L 114.34642310000001 686.7822088959999 L 129.00622093333334 694.1121078126666 Z"/></g></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="486.6198283723129" y="729.0024266559999" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">run test</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 114.34642310000001 736.039129616 L 893.3426877358784 736.039129616" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray=""/><g transform="translate(907.8265679952117,736.039129616) translate(-907.8265679952117,-736.039129616)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 893.1667701618784 728.7092306993333 L 907.8265679952117 736.039129616 L 893.1667701618784 743.3690285326667 Z"/></g></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="468.2531634858383" y="778.2593473759999" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">return results</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 907.8265679952117 785.296050336 L 128.83030335933336 785.296050336" stroke-miterlimit="10" stroke-width="1.4659797833333332" stroke-dasharray="7.0367029599999995"/><g transform="translate(114.34642310000001,785.296050336) translate(-114.34642310000001,-785.296050336)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 129.00622093333334 777.9661514193333 L 114.34642310000001 785.296050336 L 129.00622093333334 792.6259492526667 Z"/></g></g><g><g/><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 17.59175740000001 178.90817275799998 L 995.7853549952117 178.90817275799998 L 995.7853549952117 838.071322536 L 17.59175740000001 838.071322536 L 17.59175740000001 178.90817275799998 Z" stroke-miterlimit="10" stroke-width="2.5131082" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 17.59175740000001 178.90817275799998 L 17.59175740000001 200.01828163799996 L 77.54193861235352 200.01828163799996 L 88.09699305235353 189.46322719799997 L 88.09699305235353 178.90817275799998 L 17.59175740000001 178.90817275799998" stroke-miterlimit="10" stroke-width="2.5131082" stroke-dasharray=""/><text fill="black" stroke="none" font-family="sans-serif" font-size="8.8pt" font-style="normal" font-weight="bold" text-decoration="normal" x="35.18351480000001" y="192.98157867799995" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">loop</text><g><rect fill="white" stroke="none" x="103.04998684235353" y="180.31551334999995" width="75.71086157058593" height="18.295427696"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="8.8pt" font-style="normal" font-weight="bold" text-decoration="normal" x="105.68875045235353" y="192.98157867799995" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">[for all tests]</text></g><g><g/><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 35.18351480000001 567.1582585759999 L 978.1935975952117 567.1582585759999 L 978.1935975952117 811.683686436 L 35.18351480000001 811.683686436 L 35.18351480000001 567.1582585759999 Z" stroke-miterlimit="10" stroke-width="2.5131082" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 35.18351480000001 567.1582585759999 L 35.18351480000001 588.2683674559999 L 95.13369601235352 588.2683674559999 L 105.68875045235353 577.7133130159999 L 105.68875045235353 567.1582585759999 L 35.18351480000001 567.1582585759999" stroke-miterlimit="10" stroke-width="2.5131082" stroke-dasharray=""/><text fill="black" stroke="none" font-family="sans-serif" font-size="8.8pt" font-style="normal" font-weight="bold" text-decoration="normal" x="52.77527220000001" y="581.2316644959999" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">loop</text><g><rect fill="white" stroke="none" x="120.64174424235354" y="568.565599168" width="108.31086004470703" height="18.295427696"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="8.8pt" font-style="normal" font-weight="bold" text-decoration="normal" x="123.28050785235354" y="581.2316644959999" text-anchor="start" dominant-baseline="alphabetic" xml:space="preserve">[for all arguments]</text></g></g><g/><g/><g/></g></svg>
\ No newline at end of file
diff --git a/docs/junit-integration.md b/docs/junit-integration.md
new file mode 100644
index 0000000..517f7ab
--- /dev/null
+++ b/docs/junit-integration.md
@@ -0,0 +1,111 @@
+# JUnit Integration Implementation
+
+Jazzer's JUnit integration starts from
+the [`FuzzTest`](../src/main/java/com/code_intelligence/jazzer/junit/FuzzTest.java) annotation. As mentioned in the
+annotation's javadoc, our integration runs in one of two modes: fuzzing and regression. Fuzzing mode will generate new
+inputs to feed into the tests to find new issues and regression mode will run the tests against previous findings, no
+fuzzing is done. The main entrypoints for the actual integration code are found in two of the annotations
+on `FuzzTest`: `@ArgumentsSource(FuzzTestArgumentsProvider.class)` and `@ExtendsWith(FuzzTestExtensions.class)`.
+
+Because these same files and functions are involved in two mostly separate sets of functionality, this will look at the
+flow of the different methods involved in integrating with JUnit in fuzzing mode (when `JAZZER_FUZZ` is set to any
+non-empty value) and in regression mode (when `JAZZER_FUZZ` is not set) separately.
+
+# Fuzzing Flow
+
+JUnit will call the following methods for each test marked with `FuzzTest`.
+
+## `evaluateExecutionCondition`
+
+The first call to this test will determine if the test should be run at all. In fuzzing mode, we only allow one test to
+be run due to global state in libfuzzer that would mean multiple tests would interfere with each other. Jazzer will
+accept the first fuzz test that is checked as the test to be run. It will cache which test it has seen first and
+return that test as enabled.
+
+If this returns that a test is disabled, JUnit will not run the rest of these methods for this test and instead skip
+to the next one.
+
+## `provideArguments`
+
+This will configure the fuzzing agent to set up code instrumentation, instantiate a `FuzzTestExecutor` and put it into
+JUnit's `extensionContext`, then create a stream of a single empty argument set. As the comment mentions, this is so
+that JUnit will actually execute the test but the argument will not be used.
+
+## `evaluateExecutionCondition`
+
+This will be called for each argument set for the current test. In fuzzing mode, there will only be the single
+empty argument set which will be enabled.
+
+## `interceptTestTemplateMethod`
+
+This will call `invocation.skip()` which prevents invoking the test function with the default set of
+arguments `provideArguments` created. It will instead extract the `FuzzTestExecutor` instance from
+the `extensionContext` and then calls `FuzzTestExecutor#execute` which creates a `FuzzTargetRunner` to run the actual
+fuzzing.
+
+Crashes are saved in `resources/<package>/<test file name>Inputs/<test method name>` and results that are interesting to
+libfuzzer are saved in `.cifuzz-corpus`.
+
+# Regression Flow
+
+Similar to fuzzing mode, JUnit will call these methods for each test marked with `FuzzTest`.
+
+## `evaluateExecutionCondition`
+
+This checks if the given test should be run at all. In regression mode, all tests are run so this will always return
+enabled.
+
+## `provideArguments`
+
+This will configure the fuzzing agent as in fuzzing mode, then gather test cases to run from the following sources:
+
+1. A default argument set of just an empty input
+2. A set of arguments from the associated resources directory
+3. If a `.cifuzz-corpus` directory exists, relevant entries from that are added as well
+
+Prior to returning, the stream of test cases is put through `adaptInputsForFuzzTest` to turn the raw bytes from the
+files into the actual types to be given to the tested function.
+
+### Resources Tests
+
+The tests from the resources directory are gathered by `walkInputs`. This will look for inputs in two places:
+- `resources/<package>/<test class name>Inputs` - files found directly within this directory will be used as inputs for 
+  any tests within this class. This allows for easy sharing of corpus entries. Jazzer does not automatically put entries
+  here, instead a human will need to decide a finding should be shared and manually move it.
+- `resources/<package>/<test class name>Inputs/<test method name>` - files found in this directory and any directory
+  under it are used as inputs for only the test of the same name.
+
+JUnit will use the file's name as the name of the test case for its reporting. It also accepts .jar files where it will
+search with the given directory in the jar.
+
+### CIFuzz Corpus
+
+The corpus kept in `.cifuzz-corpus/<test class name>/<test method name>` holds any inputs that libfuzzer found worth
+saving and not necessarily just inputs that caused a crash. Jazzer is able to set the directory but the contents of
+these directories are managed entirely by libfuzzer. Unlike with the resources test inputs above, this will not look
+in `.cifuzz-corpus/<test class name>` for shared test cases. This is a limitation of libfuzzer.
+
+## `evaluateExecutionCondition`
+
+This will run once per argument set returned by `provideArguments` for this test. All argument sets will return as
+enabled.
+
+## `interceptTestTemplateMethod`
+
+This will run for each individual test case for each fuzz test and will mostly just allow the test function to proceed
+with the provided arguments. Prior to the call to the test, it will enable the agent's hooks and then disable them
+afterward. It will also check for and report any findings from Jazzer to JUnit.
+
+# Diagrams
+
+Below are two sequence diagrams for how JUnit calls `evaluateExecutionConditions` and `provideArguments` in fuzzing and
+regression mode. These diagrams ignore `interceptTestTemplateMethod` for brevity as its behavior and place in the
+sequence is more clear.
+
+## Fuzzing
+
+![created on sequencediagram.org, load the svg in the editor to edit](./images/fuzzing-flow.svg)
+
+## Regression
+
+![created on sequencediagram.org, load the svg in the editor to edit](./images/regression-flow.svg)
diff --git a/driver/BUILD.bazel b/driver/BUILD.bazel
deleted file mode 100644
index 2d503cc..0000000
--- a/driver/BUILD.bazel
+++ /dev/null
@@ -1,234 +0,0 @@
-load("@fmeum_rules_jni//jni:defs.bzl", "cc_jni_library")
-load("//bazel:compat.bzl", "SKIP_ON_WINDOWS")
-
-cc_library(
-    name = "jazzer_main",
-    srcs = ["jazzer_main.cpp"],
-    deps = [
-        ":jvm_tooling_lib",
-        "@com_google_absl//absl/strings",
-        "@fmeum_rules_jni//jni:libjvm",
-        "@jazzer_com_github_gflags_gflags//:gflags",
-    ],
-)
-
-cc_library(
-    name = "jvm_tooling_lib",
-    srcs = ["jvm_tooling.cpp"],
-    hdrs = ["jvm_tooling.h"],
-    tags = [
-        # Should be built through the cc_17_library driver_lib.
-        "manual",
-    ],
-    deps = [
-        "@bazel_tools//tools/cpp/runfiles",
-        "@com_google_absl//absl/strings",
-        "@com_google_absl//absl/strings:str_format",
-        "@fmeum_rules_jni//jni",
-        "@jazzer_com_github_gflags_gflags//:gflags",
-    ],
-)
-
-DYNAMIC_SYMBOLS_TO_EXPORT = [
-    "__sanitizer_cov_8bit_counters_init",
-    "__sanitizer_cov_pcs_init",
-    "__sanitizer_cov_trace_cmp1",
-    "__sanitizer_cov_trace_cmp4",
-    "__sanitizer_cov_trace_cmp4",
-    "__sanitizer_cov_trace_cmp8",
-    "__sanitizer_cov_trace_const_cmp1",
-    "__sanitizer_cov_trace_const_cmp4",
-    "__sanitizer_cov_trace_const_cmp4",
-    "__sanitizer_cov_trace_const_cmp8",
-    "__sanitizer_cov_trace_div4",
-    "__sanitizer_cov_trace_div8",
-    "__sanitizer_cov_trace_gep",
-    "__sanitizer_cov_trace_pc_indir",
-    "__sanitizer_cov_trace_switch",
-    "__sanitizer_weak_hook_memcmp",
-    "__sanitizer_weak_hook_memmem",
-    "__sanitizer_weak_hook_strcasecmp",
-    "__sanitizer_weak_hook_strcasestr",
-    "__sanitizer_weak_hook_strcmp",
-    "__sanitizer_weak_hook_strncasecmp",
-    "__sanitizer_weak_hook_strncmp",
-    "__sanitizer_weak_hook_strstr",
-    "bcmp",
-    "jazzer_initialize_native_hooks",
-    "memcmp",
-    "memmem",
-    "strcasecmp",
-    "strcasestr",
-    "strcmp",
-    "strncasecmp",
-    "strncmp",
-    "strstr",
-]
-
-cc_library(
-    name = "native_fuzzer_hooks",
-    srcs = ["native_fuzzer_hooks.c"],
-    linkopts = select({
-        "@platforms//os:linux": [
-            "-Wl,--export-dynamic-symbol=" + symbol
-            for symbol in DYNAMIC_SYMBOLS_TO_EXPORT
-        ] + [
-            "-ldl",
-        ],
-        "@platforms//os:macos": [
-            "-rdynamic",
-            "-ldl",
-        ],
-        "//conditions:default": [],
-    }),
-    target_compatible_with = SKIP_ON_WINDOWS,
-    deps = ["//driver/src/main/native/com/code_intelligence/jazzer/driver:sanitizer_hooks_with_pc"],
-    alwayslink = True,
-)
-
-cc_binary(
-    name = "jazzer_driver",
-    data = [
-        "//agent:jazzer_agent_deploy",
-    ],
-    linkopts = select({
-        "//:clang_on_linux": ["-fuse-ld=lld"],
-        "//conditions:default": [],
-    }),
-    linkstatic = True,
-    visibility = ["//visibility:public"],
-    deps = [":jazzer_main"],
-)
-
-alias(
-    name = "using_toolchain_on_osx",
-    actual = select({
-        "//third_party:uses_toolchain": "@platforms//os:osx",
-        # In order to achieve AND semantics, reference a setting that is known
-        # not to apply.
-        "//conditions:default": "//third_party:uses_toolchain",
-    }),
-)
-
-cc_binary(
-    name = "jazzer_driver_asan",
-    data = [
-        "//agent:jazzer_agent_deploy",
-    ],
-    linkopts = select({
-        "@platforms//os:windows": [
-            # Sanitizer runtimes have to be linked manually on Windows:
-            # https://devblogs.microsoft.com/cppblog/addresssanitizer-asan-for-windows-with-msvc/
-            "/wholearchive:clang_rt.asan-x86_64.lib",
-            "/wholearchive:clang_rt.asan_cxx-x86_64.lib",
-        ],
-        "//conditions:default": [
-            "-fsanitize=address",
-            "-static-libsan",
-        ],
-    }) + select({
-        "//:clang_on_linux": ["-fuse-ld=lld"],
-        "//conditions:default": [],
-    }),
-    linkstatic = True,
-    visibility = ["//visibility:public"],
-    deps = [":jazzer_main"] + select({
-        # There is no static ASan runtime on macOS, so link to the dynamic
-        # runtime library if on macOS and using the toolchain.
-        ":using_toolchain_on_osx": ["@llvm_toolchain_llvm//:macos_asan_dynamic"],
-        "//conditions:default": [],
-    }) + select({
-        "@platforms//os:windows": [],
-        "//conditions:default": [":native_fuzzer_hooks"],
-    }),
-)
-
-cc_binary(
-    name = "jazzer_driver_ubsan",
-    data = [
-        "//agent:jazzer_agent_deploy",
-    ],
-    linkopts = select({
-        "@platforms//os:windows": [
-            # Sanitizer runtimes have to be linked manually on Windows:
-            # https://devblogs.microsoft.com/cppblog/addresssanitizer-asan-for-windows-with-msvc/
-            "/wholearchive:clang_rt.ubsan_standalone-x86_64.lib",
-            "/wholearchive:clang_rt.ubsan_standalone_cxx-x86_64.lib",
-        ],
-        "//conditions:default": [
-            "-fsanitize=undefined",
-            # Link UBSan statically, even on macOS.
-            "-static-libsan",
-            "-fsanitize-link-c++-runtime",
-        ],
-    }) + select({
-        "//:clang_on_linux": ["-fuse-ld=lld"],
-        "//conditions:default": [],
-    }),
-    linkstatic = True,
-    visibility = ["//visibility:public"],
-    deps = [
-        ":jazzer_main",
-    ] + select({
-        "@platforms//os:windows": [],
-        "//conditions:default": [":native_fuzzer_hooks"],
-    }),
-)
-
-cc_test(
-    name = "jvm_tooling_test",
-    size = "small",
-    srcs = ["jvm_tooling_test.cpp"],
-    args = [
-        "--cp=jazzer/$(rootpath //driver/testdata:fuzz_target_mocks_deploy.jar)",
-    ],
-    data = [
-        "//agent:jazzer_agent_deploy",
-        "//driver/testdata:fuzz_target_mocks_deploy.jar",
-    ],
-    includes = ["."],
-    deps = [
-        ":jvm_tooling_lib",
-        ":test_main",
-        "@bazel_tools//tools/cpp/runfiles",
-        "@googletest//:gtest",
-        "@jazzer_com_github_gflags_gflags//:gflags",
-    ],
-)
-
-cc_test(
-    name = "fuzzed_data_provider_test",
-    size = "medium",
-    srcs = ["fuzzed_data_provider_test.cpp"],
-    args = [
-        "--cp=jazzer/$(rootpath //driver/testdata:fuzz_target_mocks_deploy.jar)",
-    ],
-    copts = select({
-        "@platforms//os:windows": ["/std:c++17"],
-        "//conditions:default": ["-std=c++17"],
-    }),
-    data = [
-        "//agent:jazzer_agent_deploy",
-        "//driver/testdata:fuzz_target_mocks_deploy.jar",
-    ],
-    includes = ["."],
-    deps = [
-        ":jvm_tooling_lib",
-        ":test_main",
-        "//driver/src/main/native/com/code_intelligence/jazzer/driver:fuzzed_data_provider",
-        "@bazel_tools//tools/cpp/runfiles",
-        "@googletest//:gtest",
-        "@jazzer_com_github_gflags_gflags//:gflags",
-    ],
-)
-
-cc_library(
-    name = "test_main",
-    srcs = ["test_main.cpp"],
-    linkstatic = True,
-    deps = [
-        "@fmeum_rules_jni//jni:libjvm",
-        "@googletest//:gtest",
-        "@jazzer_com_github_gflags_gflags//:gflags",
-    ],
-)
diff --git a/driver/fuzzed_data_provider_test.cpp b/driver/fuzzed_data_provider_test.cpp
deleted file mode 100644
index e6225b7..0000000
--- a/driver/fuzzed_data_provider_test.cpp
+++ /dev/null
@@ -1,173 +0,0 @@
-// Copyright 2021 Code Intelligence GmbH
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-#include <cstddef>
-#include <cstdint>
-#include <random>
-#include <string>
-#include <vector>
-
-#include "gflags/gflags.h"
-#include "gtest/gtest.h"
-#include "jvm_tooling.h"
-#include "tools/cpp/runfiles/runfiles.h"
-
-DECLARE_string(cp);
-DECLARE_bool(hooks);
-
-namespace jazzer {
-
-std::pair<std::string, jint> FixUpModifiedUtf8(const uint8_t* pos,
-                                               jint max_bytes, jint max_length,
-                                               bool ascii_only,
-                                               bool stop_on_backslash);
-
-std::pair<std::string, jint> FixUpRemainingModifiedUtf8(
-    const std::string& str, bool ascii_only, bool stop_on_backslash) {
-  return FixUpModifiedUtf8(reinterpret_cast<const uint8_t*>(str.c_str()),
-                           str.length(), std::numeric_limits<jint>::max(),
-                           ascii_only, stop_on_backslash);
-}
-
-std::pair<std::string, jint> expect(const std::string& s, jint i) {
-  return std::make_pair(s, i);
-}
-
-using namespace std::literals::string_literals;
-TEST(FixUpModifiedUtf8Test, FullUtf8_ContinueOnBackslash) {
-  EXPECT_EQ(expect("jazzer"s, 6),
-            FixUpRemainingModifiedUtf8("jazzer"s, false, false));
-  EXPECT_EQ(expect("ja\xC0\x80zzer"s, 7),
-            FixUpRemainingModifiedUtf8("ja\0zzer"s, false, false));
-  EXPECT_EQ(expect("ja\xC0\x80\xC0\x80zzer"s, 8),
-            FixUpRemainingModifiedUtf8("ja\0\0zzer"s, false, false));
-  EXPECT_EQ(expect("ja\\zzer"s, 7),
-            FixUpRemainingModifiedUtf8("ja\\zzer"s, false, false));
-  EXPECT_EQ(expect("ja\\\\zzer"s, 8),
-            FixUpRemainingModifiedUtf8("ja\\\\zzer"s, false, false));
-  EXPECT_EQ(expect("ۧ"s, 5),
-            FixUpRemainingModifiedUtf8(u8"ۧ"s, false, false));
-}
-
-TEST(FixUpModifiedUtf8Test, AsciiOnly_ContinueOnBackslash) {
-  EXPECT_EQ(expect("jazzer"s, 6),
-            FixUpRemainingModifiedUtf8("jazzer"s, true, false));
-  EXPECT_EQ(expect("ja\xC0\x80zzer"s, 7),
-            FixUpRemainingModifiedUtf8("ja\0zzer"s, true, false));
-  EXPECT_EQ(expect("ja\xC0\x80\xC0\x80zzer"s, 8),
-            FixUpRemainingModifiedUtf8("ja\0\0zzer"s, true, false));
-  EXPECT_EQ(expect("ja\\zzer"s, 7),
-            FixUpRemainingModifiedUtf8("ja\\zzer"s, true, false));
-  EXPECT_EQ(expect("ja\\\\zzer"s, 8),
-            FixUpRemainingModifiedUtf8("ja\\\\zzer"s, true, false));
-  EXPECT_EQ(expect("\x62\x02\x2C\x43\x1F"s, 5),
-            FixUpRemainingModifiedUtf8(u8"ۧ"s, true, false));
-}
-
-TEST(FixUpModifiedUtf8Test, FullUtf8_StopOnBackslash) {
-  EXPECT_EQ(expect("jazzer"s, 6),
-            FixUpRemainingModifiedUtf8("jazzer"s, false, true));
-  EXPECT_EQ(expect("ja\xC0\x80zzer"s, 7),
-            FixUpRemainingModifiedUtf8("ja\0zzer"s, false, true));
-  EXPECT_EQ(expect("ja\xC0\x80\xC0\x80zzer"s, 8),
-            FixUpRemainingModifiedUtf8("ja\0\0zzer"s, false, true));
-  EXPECT_EQ(expect("ja"s, 4),
-            FixUpRemainingModifiedUtf8("ja\\zzer"s, false, true));
-  EXPECT_EQ(expect("ja\\zzer"s, 8),
-            FixUpRemainingModifiedUtf8("ja\\\\zzer"s, false, true));
-}
-
-TEST(FixUpModifiedUtf8Test, AsciiOnly_StopOnBackslash) {
-  EXPECT_EQ(expect("jazzer"s, 6),
-            FixUpRemainingModifiedUtf8("jazzer"s, true, true));
-  EXPECT_EQ(expect("ja\xC0\x80zzer"s, 7),
-            FixUpRemainingModifiedUtf8("ja\0zzer"s, true, true));
-  EXPECT_EQ(expect("ja\xC0\x80\xC0\x80zzer"s, 8),
-            FixUpRemainingModifiedUtf8("ja\0\0zzer"s, true, true));
-  EXPECT_EQ(expect("ja"s, 4),
-            FixUpRemainingModifiedUtf8("ja\\zzer"s, true, true));
-  EXPECT_EQ(expect("ja\\zzer"s, 8),
-            FixUpRemainingModifiedUtf8("ja\\\\zzer"s, true, true));
-}
-
-class FuzzedDataProviderTest : public ::testing::Test {
- protected:
-  // After DestroyJavaVM() no new JVM instance can be created in the same
-  // process, so we set up a single JVM instance for this test binary which gets
-  // destroyed after all tests in this test suite have finished.
-  static void SetUpTestCase() {
-    FLAGS_hooks = false;
-    using ::bazel::tools::cpp::runfiles::Runfiles;
-    Runfiles* runfiles = Runfiles::CreateForTest();
-    FLAGS_cp = runfiles->Rlocation(FLAGS_cp);
-
-    jvm_ = std::make_unique<JVM>("test_executable");
-  }
-
-  static void TearDownTestCase() { jvm_.reset(nullptr); }
-
-  static std::unique_ptr<JVM> jvm_;
-};
-
-std::unique_ptr<JVM> FuzzedDataProviderTest::jvm_ = nullptr;
-
-constexpr std::size_t kValidModifiedUtf8NumRuns = 10000;
-constexpr std::size_t kValidModifiedUtf8NumBytes = 100000;
-constexpr uint32_t kValidModifiedUtf8Seed = 0x12345678;
-
-TEST_F(FuzzedDataProviderTest, InvalidModifiedUtf8AfterFixup) {
-  auto& env = jvm_->GetEnv();
-  auto modified_utf8_validator = env.FindClass("test/ModifiedUtf8Encoder");
-  ASSERT_NE(nullptr, modified_utf8_validator);
-  auto string_to_modified_utf_bytes = env.GetStaticMethodID(
-      modified_utf8_validator, "encode", "(Ljava/lang/String;)[B");
-  ASSERT_NE(nullptr, string_to_modified_utf_bytes);
-  auto random_bytes = std::vector<uint8_t>(kValidModifiedUtf8NumBytes);
-  auto random = std::mt19937(kValidModifiedUtf8Seed);
-  for (bool ascii_only : {false, true}) {
-    for (bool stop_on_backslash : {false, true}) {
-      for (std::size_t i = 0; i < kValidModifiedUtf8NumRuns; ++i) {
-        std::generate(random_bytes.begin(), random_bytes.end(), random);
-        std::string fixed_string;
-        std::tie(fixed_string, std::ignore) = FixUpModifiedUtf8(
-            random_bytes.data(), random_bytes.size(),
-            std::numeric_limits<jint>::max(), ascii_only, stop_on_backslash);
-
-        jstring jni_fixed_string = env.NewStringUTF(fixed_string.c_str());
-        auto jni_roundtripped_bytes = (jbyteArray)env.CallStaticObjectMethod(
-            modified_utf8_validator, string_to_modified_utf_bytes,
-            jni_fixed_string);
-        ASSERT_FALSE(env.ExceptionCheck());
-        env.DeleteLocalRef(jni_fixed_string);
-        jint roundtripped_bytes_length =
-            env.GetArrayLength(jni_roundtripped_bytes);
-        jbyte* roundtripped_bytes =
-            env.GetByteArrayElements(jni_roundtripped_bytes, nullptr);
-        auto roundtripped_string =
-            std::string(reinterpret_cast<char*>(roundtripped_bytes),
-                        roundtripped_bytes_length);
-        env.ReleaseByteArrayElements(jni_roundtripped_bytes, roundtripped_bytes,
-                                     JNI_ABORT);
-        env.DeleteLocalRef(jni_roundtripped_bytes);
-
-        // Verify that the bytes obtained from running our modified UTF-8 fix-up
-        // function remain unchanged when turned into a Java string and
-        // reencoded into modified UTF-8. This will only happen if the our
-        // fix-up function indeed returned valid modified UTF-8.
-        ASSERT_EQ(fixed_string, roundtripped_string);
-      }
-    }
-  }
-}
-}  // namespace jazzer
diff --git a/driver/jazzer_main.cpp b/driver/jazzer_main.cpp
deleted file mode 100644
index c72e111..0000000
--- a/driver/jazzer_main.cpp
+++ /dev/null
@@ -1,151 +0,0 @@
-// Copyright 2021 Code Intelligence GmbH
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-/*
- * Jazzer's native main function, which:
- * 1. defines default settings for ASan and UBSan;
- * 2. preprocesses the command-line arguments passed to libFuzzer;
- * 3. starts a JVM;
- * 4. passes control to the Java-part of the driver.
- */
-
-#include <rules_jni.h>
-
-#include <algorithm>
-#include <iostream>
-#include <memory>
-#include <vector>
-
-#include "absl/strings/match.h"
-#include "gflags/gflags.h"
-#include "jvm_tooling.h"
-
-// Defined by glog
-DECLARE_bool(log_prefix);
-
-namespace {
-bool is_asan_active = false;
-}
-
-extern "C" {
-[[maybe_unused]] const char *__asan_default_options() {
-  is_asan_active = true;
-  // LeakSanitizer is not yet supported as it reports too many false positives
-  // due to how the JVM GC works.
-  // We use a distinguished exit code to recognize ASan crashes in tests.
-  // Also specify abort_on_error=0 explicitly since ASan aborts rather than
-  // exits on macOS by default, which would cause our exit code to be ignored.
-  return "abort_on_error=0,detect_leaks=0,exitcode=76";
-}
-
-[[maybe_unused]] const char *__ubsan_default_options() {
-  // We use a distinguished exit code to recognize UBSan crashes in tests.
-  // Also specify abort_on_error=0 explicitly since UBSan aborts rather than
-  // exits on macOS by default, which would cause our exit code to be ignored.
-  return "abort_on_error=0,exitcode=76";
-}
-}
-
-namespace {
-const std::string kUsageMessage =
-    R"(Test java fuzz targets using libFuzzer. Usage:
-  jazzer --cp=<java_class_path> --target_class=<fuzz_target_class> <libfuzzer_arguments...>)";
-const std::string kDriverClassName =
-    "com/code_intelligence/jazzer/driver/Driver";
-
-int StartLibFuzzer(std::unique_ptr<jazzer::JVM> jvm,
-                   std::vector<std::string> argv) {
-  JNIEnv &env = jvm->GetEnv();
-  jclass runner = env.FindClass(kDriverClassName.c_str());
-  if (runner == nullptr) {
-    env.ExceptionDescribe();
-    return 1;
-  }
-  jmethodID startDriver = env.GetStaticMethodID(runner, "start", "([[B)I");
-  if (startDriver == nullptr) {
-    env.ExceptionDescribe();
-    return 1;
-  }
-  jclass byteArrayClass = env.FindClass("[B");
-  if (byteArrayClass == nullptr) {
-    env.ExceptionDescribe();
-    return 1;
-  }
-  jobjectArray args = env.NewObjectArray(argv.size(), byteArrayClass, nullptr);
-  if (args == nullptr) {
-    env.ExceptionDescribe();
-    return 1;
-  }
-  for (jsize i = 0; i < argv.size(); ++i) {
-    jint len = argv[i].size();
-    jbyteArray arg = env.NewByteArray(len);
-    if (arg == nullptr) {
-      env.ExceptionDescribe();
-      return 1;
-    }
-    // startDriver expects UTF-8 encoded strings that are not null-terminated.
-    env.SetByteArrayRegion(arg, 0, len,
-                           reinterpret_cast<const jbyte *>(argv[i].data()));
-    if (env.ExceptionCheck()) {
-      env.ExceptionDescribe();
-      return 1;
-    }
-    env.SetObjectArrayElement(args, i, arg);
-    if (env.ExceptionCheck()) {
-      env.ExceptionDescribe();
-      return 1;
-    }
-    env.DeleteLocalRef(arg);
-  }
-  int res = env.CallStaticIntMethod(runner, startDriver, args);
-  if (env.ExceptionCheck()) {
-    env.ExceptionDescribe();
-    return 1;
-  }
-  env.DeleteLocalRef(args);
-  return res;
-}
-}  // namespace
-
-int main(int argc, char **argv) {
-  gflags::SetUsageMessage(kUsageMessage);
-  rules_jni_init(argv[0]);
-
-  const auto argv_end = argv + argc;
-
-  {
-    // All libFuzzer flags start with a single dash, our arguments all start
-    // with a double dash. We can thus filter out the arguments meant for gflags
-    // by taking only those with a leading double dash.
-    std::vector<char *> our_args = {*argv};
-    std::copy_if(argv, argv_end, std::back_inserter(our_args),
-                 [](const std::string &arg) {
-                   return absl::StartsWith(std::string(arg), "--");
-                 });
-    int our_argc = our_args.size();
-    char **our_argv = our_args.data();
-    // Let gflags consume its flags, but keep them in the argument list in case
-    // libFuzzer forwards the command line (e.g. with -jobs or -minimize_crash).
-    gflags::ParseCommandLineFlags(&our_argc, &our_argv, false);
-  }
-
-  if (is_asan_active) {
-    std::cerr << "WARN: Jazzer is not compatible with LeakSanitizer yet. Leaks "
-                 "are not reported."
-              << std::endl;
-  }
-
-  return StartLibFuzzer(std::unique_ptr<jazzer::JVM>(new jazzer::JVM(argv[0])),
-                        std::vector<std::string>(argv, argv_end));
-}
diff --git a/driver/jvm_tooling.cpp b/driver/jvm_tooling.cpp
deleted file mode 100644
index 71a5f58..0000000
--- a/driver/jvm_tooling.cpp
+++ /dev/null
@@ -1,332 +0,0 @@
-// Copyright 2021 Code Intelligence GmbH
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-#include "jvm_tooling.h"
-
-#include <cstdlib>
-#include <fstream>
-#include <iostream>
-#include <memory>
-#include <utility>
-#include <vector>
-
-#include "absl/strings/str_format.h"
-#include "absl/strings/str_join.h"
-#include "absl/strings/str_replace.h"
-#include "absl/strings/str_split.h"
-#include "gflags/gflags.h"
-#include "tools/cpp/runfiles/runfiles.h"
-
-DEFINE_string(cp, ".",
-              "the classpath to use for fuzzing. Behaves analogously to java's "
-              "-cp (separator is ':' on Linux/macOS and ';' on Windows, escape "
-              "it with '\\').");
-DEFINE_string(jvm_args, "",
-              "arguments passed to the JVM (separator is ':' on Linux/macOS "
-              "and ';' on Windows, escape it with '\\')");
-DEFINE_string(additional_jvm_args, "",
-              "additional arguments passed to the JVM (separator is ':' on "
-              "Linux/macOS and ';' on Windows). Use this option to set further "
-              "JVM args that should not "
-              "interfere with those provided via --jvm_args.");
-DEFINE_string(agent_path, "", "location of the fuzzing instrumentation agent");
-
-// Arguments that are passed to the instrumentation agent.
-// The instrumentation agent takes arguments in the form
-// <option_1>=<option_1_val>,<option_2>=<option_2_val>,... To not expose this
-// format to the user the available options are defined here as flags and
-// combined during the initialization of the JVM.
-DEFINE_string(instrumentation_includes, "",
-              "list of glob patterns for classes that will be instrumented for "
-              "fuzzing (separator is ':' on Linux/macOS and ';' on Windows)");
-DEFINE_string(
-    instrumentation_excludes, "",
-    "list of glob patterns for classes that will not be instrumented "
-    "for fuzzing (separator is ':' on Linux/macOS and ';' on Windows)");
-
-DEFINE_string(custom_hook_includes, "",
-              "list of glob patterns for classes that will only be "
-              "instrumented using custom hooks (separator is ':' on "
-              "Linux/macOS and ';' on Windows)");
-DEFINE_string(
-    custom_hook_excludes, "",
-    "list of glob patterns for classes that will not be instrumented "
-    "using custom hooks (separator is ':' on Linux/macOS and ';' on Windows)");
-DEFINE_string(custom_hooks, "",
-              "list of classes containing custom instrumentation hooks "
-              "(separator is ':' on Linux/macOS and ';' on Windows)");
-DEFINE_string(disabled_hooks, "",
-              "list of hook classes (custom or built-in) that should not be "
-              "loaded (separator is ':' on Linux/macOS and ';' on Windows)");
-DEFINE_string(
-    trace, "",
-    "list of instrumentation to perform separated by colon ':' on Linux/macOS "
-    "and ';' on Windows. "
-    "Available options are cov, cmp, div, gep, all. These options "
-    "correspond to the \"-fsanitize-coverage=trace-*\" flags in clang.");
-DEFINE_string(
-    id_sync_file, "",
-    "path to a file that should be used to synchronize coverage IDs "
-    "between parallel fuzzing processes. Defaults to a temporary file "
-    "created for this purpose if running in parallel.");
-DEFINE_string(
-    dump_classes_dir, "",
-    "path to a directory in which Jazzer should dump the instrumented classes");
-
-DEFINE_bool(hooks, true,
-            "Use JVM hooks to provide coverage information to the fuzzer. The "
-            "fuzzer uses the coverage information to perform smarter input "
-            "selection and mutation. If set to false no "
-            "coverage information will be processed. This can be useful for "
-            "running a regression test on non-instrumented bytecode.");
-
-DEFINE_string(
-    target_class, "",
-    "The Java class that contains the static fuzzerTestOneInput function");
-DEFINE_string(target_args, "",
-              "Arguments passed to fuzzerInitialize as a String array. "
-              "Separated by space.");
-
-DEFINE_uint32(keep_going, 0,
-              "Continue fuzzing until N distinct exception stack traces have"
-              "been encountered. Defaults to exit after the first finding "
-              "unless --autofuzz is specified.");
-DEFINE_bool(dedup, true,
-            "Emit a dedup token for every finding. Defaults to true and is "
-            "required for --keep_going and --ignore.");
-DEFINE_string(
-    ignore, "",
-    "Comma-separated list of crash dedup tokens to ignore. This is useful to "
-    "continue fuzzing before a crash is fixed.");
-
-DEFINE_string(reproducer_path, ".",
-              "Path at which fuzzing reproducers are stored. Defaults to the "
-              "current directory.");
-DEFINE_string(coverage_report, "",
-              "Path at which a coverage report is stored when the fuzzer "
-              "exits. If left empty, no report is generated (default)");
-DEFINE_string(coverage_dump, "",
-              "Path at which a coverage dump is stored when the fuzzer "
-              "exits. If left empty, no dump is generated (default)");
-
-DEFINE_string(autofuzz, "",
-              "Fully qualified reference to a method on the classpath that "
-              "should be fuzzed automatically (example: System.out::println). "
-              "Fuzzing will continue even after a finding; specify "
-              "--keep_going=N to stop after N findings.");
-DEFINE_string(autofuzz_ignore, "",
-              "Fully qualified class names of exceptions to ignore during "
-              "autofuzz. Separated by comma.");
-DEFINE_bool(fake_pcs, false,
-            "No-op flag that remains for backwards compatibility only.");
-
-#if defined(_WIN32) || defined(_WIN64)
-#define ARG_SEPARATOR ";"
-constexpr auto kPathSeparator = '\\';
-#else
-#define ARG_SEPARATOR ":"
-constexpr auto kPathSeparator = '/';
-#endif
-
-namespace {
-constexpr auto kAgentBazelRunfilesPath = "jazzer/agent/jazzer_agent_deploy.jar";
-constexpr auto kAgentFileName = "jazzer_agent_deploy.jar";
-
-std::string dirFromFullPath(const std::string &path) {
-  const auto pos = path.rfind(kPathSeparator);
-  if (pos != std::string::npos) {
-    return path.substr(0, pos);
-  }
-  return "";
-}
-
-// getInstrumentorAgentPath searches for the fuzzing instrumentation agent and
-// returns the location if it is found. Otherwise it calls exit(0).
-std::string getInstrumentorAgentPath(const std::string &executable_path) {
-  // User provided agent location takes precedence.
-  if (!FLAGS_agent_path.empty()) {
-    if (std::ifstream(FLAGS_agent_path).good()) return FLAGS_agent_path;
-    std::cerr << "ERROR: Could not find " << kAgentFileName << " at \""
-              << FLAGS_agent_path << "\"" << std::endl;
-    exit(1);
-  }
-  // First check if we are running inside the Bazel tree and use the agent
-  // runfile.
-  {
-    using bazel::tools::cpp::runfiles::Runfiles;
-    std::string error;
-    std::unique_ptr<Runfiles> runfiles(
-        Runfiles::Create(std::string(executable_path), &error));
-    if (runfiles != nullptr) {
-      auto bazel_path = runfiles->Rlocation(kAgentBazelRunfilesPath);
-      if (!bazel_path.empty() && std::ifstream(bazel_path).good())
-        return bazel_path;
-    }
-  }
-
-  // If the agent is not in the bazel path we look next to the jazzer_driver
-  // binary.
-  const auto dir = dirFromFullPath(executable_path);
-  auto agent_path =
-      absl::StrFormat("%s%c%s", dir, kPathSeparator, kAgentFileName);
-  if (std::ifstream(agent_path).good()) return agent_path;
-  std::cerr << "ERROR: Could not find " << kAgentFileName
-            << ". Please provide the pathname via the --agent_path flag."
-            << std::endl;
-  exit(1);
-}
-
-std::vector<std::string> optsAsDefines() {
-  std::vector<std::string> defines{
-      absl::StrFormat("-Djazzer.target_class=%s", FLAGS_target_class),
-      absl::StrFormat("-Djazzer.target_args=%s", FLAGS_target_args),
-      absl::StrFormat("-Djazzer.dedup=%s", FLAGS_dedup ? "true" : "false"),
-      absl::StrFormat("-Djazzer.ignore=%s", FLAGS_ignore),
-      absl::StrFormat("-Djazzer.reproducer_path=%s", FLAGS_reproducer_path),
-      absl::StrFormat("-Djazzer.coverage_report=%s", FLAGS_coverage_report),
-      absl::StrFormat("-Djazzer.coverage_dump=%s", FLAGS_coverage_dump),
-      absl::StrFormat("-Djazzer.autofuzz=%s", FLAGS_autofuzz),
-      absl::StrFormat("-Djazzer.autofuzz_ignore=%s", FLAGS_autofuzz_ignore),
-      absl::StrFormat("-Djazzer.hooks=%s", FLAGS_hooks ? "true" : "false"),
-      absl::StrFormat("-Djazzer.id_sync_file=%s", FLAGS_id_sync_file),
-      absl::StrFormat("-Djazzer.instrumentation_includes=%s",
-                      FLAGS_instrumentation_includes),
-      absl::StrFormat("-Djazzer.instrumentation_excludes=%s",
-                      FLAGS_instrumentation_excludes),
-      absl::StrFormat("-Djazzer.custom_hooks=%s", FLAGS_custom_hooks),
-      absl::StrFormat("-Djazzer.disabled_hooks=%s", FLAGS_disabled_hooks),
-      absl::StrFormat("-Djazzer.custom_hook_includes=%s",
-                      FLAGS_custom_hook_includes),
-      absl::StrFormat("-Djazzer.custom_hook_excludes=%s",
-                      FLAGS_custom_hook_excludes),
-      absl::StrFormat("-Djazzer.trace=%s", FLAGS_trace),
-      absl::StrFormat("-Djazzer.dump_classes_dir=%s", FLAGS_dump_classes_dir),
-  };
-  if (!gflags::GetCommandLineFlagInfoOrDie("keep_going").is_default) {
-    defines.emplace_back(
-        absl::StrFormat("-Djazzer.keep_going=%d", FLAGS_keep_going));
-  }
-  return defines;
-}
-
-// Splits a string at the ARG_SEPARATOR unless it is escaped with a backslash.
-// Backslash itself can be escaped with another backslash.
-std::vector<std::string> splitEscaped(const std::string &str) {
-  // Protect \\ and \<separator> against splitting.
-  const std::string BACKSLASH_BACKSLASH_REPLACEMENT =
-      "%%JAZZER_BACKSLASH_BACKSLASH_REPLACEMENT%%";
-  const std::string BACKSLASH_SEPARATOR_REPLACEMENT =
-      "%%JAZZER_BACKSLASH_SEPARATOR_REPLACEMENT%%";
-  std::string protected_str =
-      absl::StrReplaceAll(str, {{"\\\\", BACKSLASH_BACKSLASH_REPLACEMENT}});
-  protected_str = absl::StrReplaceAll(
-      protected_str, {{"\\" ARG_SEPARATOR, BACKSLASH_SEPARATOR_REPLACEMENT}});
-
-  std::vector<std::string> parts = absl::StrSplit(protected_str, ARG_SEPARATOR);
-  std::transform(parts.begin(), parts.end(), parts.begin(),
-                 [&BACKSLASH_SEPARATOR_REPLACEMENT,
-                  &BACKSLASH_BACKSLASH_REPLACEMENT](const std::string &part) {
-                   return absl::StrReplaceAll(
-                       part,
-                       {
-                           {BACKSLASH_SEPARATOR_REPLACEMENT, ARG_SEPARATOR},
-                           {BACKSLASH_BACKSLASH_REPLACEMENT, "\\"},
-                       });
-                 });
-
-  return parts;
-}
-}  // namespace
-
-namespace jazzer {
-
-JVM::JVM(const std::string &executable_path) {
-  // combine class path from command line flags and JAVA_FUZZER_CLASSPATH env
-  // variable
-  std::string class_path = absl::StrFormat("-Djava.class.path=%s", FLAGS_cp);
-  const auto class_path_from_env = std::getenv("JAVA_FUZZER_CLASSPATH");
-  if (class_path_from_env) {
-    class_path += absl::StrCat(ARG_SEPARATOR, class_path_from_env);
-  }
-  class_path +=
-      absl::StrCat(ARG_SEPARATOR, getInstrumentorAgentPath(executable_path));
-
-  std::vector<JavaVMOption> options;
-  options.push_back(
-      JavaVMOption{.optionString = const_cast<char *>(class_path.c_str())});
-  // Set the maximum heap size to a value that is slightly smaller than
-  // libFuzzer's default rss_limit_mb. This prevents erroneous oom reports.
-  options.push_back(JavaVMOption{.optionString = (char *)"-Xmx1800m"});
-  // Preserve and emit stack trace information even on hot paths.
-  // This may hurt performance, but also helps find flaky bugs.
-  options.push_back(
-      JavaVMOption{.optionString = (char *)"-XX:-OmitStackTraceInFastThrow"});
-  // Optimize GC for high throughput rather than low latency.
-  options.push_back(JavaVMOption{.optionString = (char *)"-XX:+UseParallelGC"});
-  options.push_back(
-      JavaVMOption{.optionString = (char *)"-XX:+CriticalJNINatives"});
-
-  std::vector<std::string> opt_defines = optsAsDefines();
-  for (const auto &define : opt_defines) {
-    options.push_back(
-        JavaVMOption{.optionString = const_cast<char *>(define.c_str())});
-  }
-
-  // Add additional JVM options set through JAVA_OPTS.
-  std::vector<std::string> java_opts_args;
-  const char *java_opts = std::getenv("JAVA_OPTS");
-  if (java_opts != nullptr) {
-    // Mimic the behavior of the JVM when it sees JAVA_TOOL_OPTIONS.
-    std::cerr << "Picked up JAVA_OPTS: " << java_opts << std::endl;
-    java_opts_args = absl::StrSplit(java_opts, ' ');
-    for (const std::string &java_opt : java_opts_args) {
-      options.push_back(
-          JavaVMOption{.optionString = const_cast<char *>(java_opt.c_str())});
-    }
-  }
-
-  // add additional jvm options set through command line flags
-  std::vector<std::string> jvm_args;
-  if (!FLAGS_jvm_args.empty()) {
-    jvm_args = splitEscaped(FLAGS_jvm_args);
-  }
-  for (const auto &arg : jvm_args) {
-    options.push_back(
-        JavaVMOption{.optionString = const_cast<char *>(arg.c_str())});
-  }
-  std::vector<std::string> additional_jvm_args;
-  if (!FLAGS_additional_jvm_args.empty()) {
-    additional_jvm_args = splitEscaped(FLAGS_additional_jvm_args);
-  }
-  for (const auto &arg : additional_jvm_args) {
-    options.push_back(
-        JavaVMOption{.optionString = const_cast<char *>(arg.c_str())});
-  }
-
-  JavaVMInitArgs jvm_init_args = {.version = JNI_VERSION_1_8,
-                                  .nOptions = (int)options.size(),
-                                  .options = options.data(),
-                                  .ignoreUnrecognized = JNI_FALSE};
-
-  auto ret = JNI_CreateJavaVM(&jvm_, (void **)&env_, &jvm_init_args);
-  if (ret != JNI_OK) {
-    throw std::runtime_error(
-        absl::StrFormat("JNI_CreateJavaVM returned code %d", ret));
-  }
-}
-
-JNIEnv &JVM::GetEnv() const { return *env_; }
-
-JVM::~JVM() { jvm_->DestroyJavaVM(); }
-}  // namespace jazzer
diff --git a/driver/native_fuzzer_hooks.c b/driver/native_fuzzer_hooks.c
deleted file mode 100644
index 4b58188..0000000
--- a/driver/native_fuzzer_hooks.c
+++ /dev/null
@@ -1,527 +0,0 @@
-// Copyright 2022 Code Intelligence GmbH
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-/*
- * Dynamically exported definitions of fuzzer hooks and libc functions that
- * forward to the symbols provided by the Jazzer driver JNI library once it has
- * been loaded.
- *
- * Native libraries instrumented for fuzzing include references to fuzzer hooks
- * that are resolved by the dynamic linker. Sanitizers such as ASan provide weak
- * definitions of these symbols, but the dynamic linker doesn't distinguish
- * between weak and strong symbols and thus wouldn't ever resolve them against
- * the strong definitions provided by the Jazzer driver JNI library.
- * Furthermore, libc functions can only be overridden in the native driver
- * executable, which is the only binary that comes before the actual libc in the
- * dynamic linker search order.
- */
-
-#define _GNU_SOURCE  // for RTLD_NEXT
-#include <dlfcn.h>
-#include <stdatomic.h>
-#include <stddef.h>
-#include <string.h>
-
-#define GET_CALLER_PC() __builtin_return_address(0)
-#define LIKELY(x) __builtin_expect(!!(x), 1)
-#define UNLIKELY(x) __builtin_expect(!!(x), 0)
-
-typedef int (*bcmp_t)(const void *, const void *, size_t);
-static _Atomic bcmp_t bcmp_real;
-typedef void (*bcmp_hook_t)(void *, const void *, const void *, size_t, int);
-static _Atomic bcmp_hook_t bcmp_hook;
-
-typedef int (*memcmp_t)(const void *, const void *, size_t);
-static _Atomic memcmp_t memcmp_real;
-typedef void (*memcmp_hook_t)(void *, const void *, const void *, size_t, int);
-static _Atomic memcmp_hook_t memcmp_hook;
-
-typedef int (*strncmp_t)(const char *, const char *, size_t);
-static _Atomic strncmp_t strncmp_real;
-typedef void (*strncmp_hook_t)(void *, const char *, const char *, size_t, int);
-static _Atomic strncmp_hook_t strncmp_hook;
-
-typedef int (*strcmp_t)(const char *, const char *);
-static _Atomic strcmp_t strcmp_real;
-typedef void (*strcmp_hook_t)(void *, const char *, const char *, int);
-static _Atomic strcmp_hook_t strcmp_hook;
-
-typedef int (*strncasecmp_t)(const char *, const char *, size_t);
-static _Atomic strncasecmp_t strncasecmp_real;
-typedef void (*strncasecmp_hook_t)(void *, const char *, const char *, size_t,
-                                   int);
-static _Atomic strncasecmp_hook_t strncasecmp_hook;
-
-typedef int (*strcasecmp_t)(const char *, const char *);
-static _Atomic strcasecmp_t strcasecmp_real;
-typedef void (*strcasecmp_hook_t)(void *, const char *, const char *, int);
-static _Atomic strcasecmp_hook_t strcasecmp_hook;
-
-typedef char *(*strstr_t)(const char *, const char *);
-static _Atomic strstr_t strstr_real;
-typedef void (*strstr_hook_t)(void *, const char *, const char *, char *);
-static _Atomic strstr_hook_t strstr_hook;
-
-typedef char *(*strcasestr_t)(const char *, const char *);
-static _Atomic strcasestr_t strcasestr_real;
-typedef void (*strcasestr_hook_t)(void *, const char *, const char *, char *);
-static _Atomic strcasestr_hook_t strcasestr_hook;
-
-typedef void *(*memmem_t)(const void *, size_t, const void *, size_t);
-static _Atomic memmem_t memmem_real;
-typedef void (*memmem_hook_t)(void *, const void *, size_t, const void *,
-                              size_t, void *);
-static _Atomic memmem_hook_t memmem_hook;
-
-typedef void (*cov_8bit_counters_init_t)(uint8_t *, uint8_t *);
-static _Atomic cov_8bit_counters_init_t cov_8bit_counters_init;
-typedef void (*cov_pcs_init_t)(const uintptr_t *, const uintptr_t *);
-static _Atomic cov_pcs_init_t cov_pcs_init;
-
-typedef void (*trace_cmp1_t)(void *, uint8_t, uint8_t);
-static _Atomic trace_cmp1_t trace_cmp1_with_pc;
-typedef void (*trace_cmp2_t)(void *, uint16_t, uint16_t);
-static _Atomic trace_cmp2_t trace_cmp2_with_pc;
-typedef void (*trace_cmp4_t)(void *, uint32_t, uint32_t);
-static _Atomic trace_cmp4_t trace_cmp4_with_pc;
-typedef void (*trace_cmp8_t)(void *, uint64_t, uint64_t);
-static _Atomic trace_cmp8_t trace_cmp8_with_pc;
-
-typedef void (*trace_const_cmp1_t)(void *, uint8_t, uint8_t);
-static _Atomic trace_const_cmp1_t trace_const_cmp1_with_pc;
-typedef void (*trace_const_cmp2_t)(void *, uint16_t, uint16_t);
-static _Atomic trace_const_cmp2_t trace_const_cmp2_with_pc;
-typedef void (*trace_const_cmp4_t)(void *, uint32_t, uint32_t);
-static _Atomic trace_const_cmp4_t trace_const_cmp4_with_pc;
-typedef void (*trace_const_cmp8_t)(void *, uint64_t, uint64_t);
-static _Atomic trace_const_cmp8_t trace_const_cmp8_with_pc;
-
-typedef void (*trace_switch_t)(void *, uint64_t, uint64_t *);
-static _Atomic trace_switch_t trace_switch_with_pc;
-
-typedef void (*trace_div4_t)(void *, uint32_t);
-static _Atomic trace_div4_t trace_div4_with_pc;
-typedef void (*trace_div8_t)(void *, uint64_t);
-static _Atomic trace_div8_t trace_div8_with_pc;
-
-typedef void (*trace_gep_t)(void *, uintptr_t);
-static _Atomic trace_gep_t trace_gep_with_pc;
-
-typedef void (*trace_pc_indir_t)(void *, uintptr_t);
-static _Atomic trace_pc_indir_t trace_pc_indir_with_pc;
-
-__attribute__((visibility("default"))) void jazzer_initialize_native_hooks(
-    void *handle) {
-  atomic_store(&bcmp_hook, dlsym(handle, "__sanitizer_weak_hook_bcmp"));
-  atomic_store(&memcmp_hook, dlsym(handle, "__sanitizer_weak_hook_memcmp"));
-  atomic_store(&strncmp_hook, dlsym(handle, "__sanitizer_weak_hook_strncmp"));
-  atomic_store(&strcmp_hook, dlsym(handle, "__sanitizer_weak_hook_strcmp"));
-  atomic_store(&strncasecmp_hook,
-               dlsym(handle, "__sanitizer_weak_hook_strncasecmp"));
-  atomic_store(&strcasecmp_hook,
-               dlsym(handle, "__sanitizer_weak_hook_strcasecmp"));
-  atomic_store(&strstr_hook, dlsym(handle, "__sanitizer_weak_hook_strstr"));
-  atomic_store(&strcasestr_hook,
-               dlsym(handle, "__sanitizer_weak_hook_strcasestr"));
-  atomic_store(&memmem_hook, dlsym(handle, "__sanitizer_weak_hook_memmem"));
-
-  atomic_store(&cov_8bit_counters_init,
-               dlsym(handle, "__sanitizer_cov_8bit_counters_init"));
-  atomic_store(&cov_pcs_init, dlsym(handle, "__sanitizer_cov_pcs_init"));
-
-  atomic_store(&trace_cmp1_with_pc,
-               dlsym(handle, "__sanitizer_cov_trace_cmp1_with_pc"));
-  atomic_store(&trace_cmp2_with_pc,
-               dlsym(handle, "__sanitizer_cov_trace_cmp2_with_pc"));
-  atomic_store(&trace_cmp4_with_pc,
-               dlsym(handle, "__sanitizer_cov_trace_cmp4_with_pc"));
-  atomic_store(&trace_cmp8_with_pc,
-               dlsym(handle, "__sanitizer_cov_trace_cmp8_with_pc"));
-
-  atomic_store(&trace_const_cmp1_with_pc,
-               dlsym(handle, "__sanitizer_cov_trace_const_cmp1_with_pc"));
-  atomic_store(&trace_const_cmp2_with_pc,
-               dlsym(handle, "__sanitizer_cov_trace_const_cmp2_with_pc"));
-  atomic_store(&trace_const_cmp4_with_pc,
-               dlsym(handle, "__sanitizer_cov_trace_const_cmp4_with_pc"));
-  atomic_store(&trace_const_cmp8_with_pc,
-               dlsym(handle, "__sanitizer_cov_trace_const_cmp8_with_pc"));
-
-  atomic_store(&trace_switch_with_pc,
-               dlsym(handle, "__sanitizer_cov_trace_switch_with_pc"));
-
-  atomic_store(&trace_div4_with_pc,
-               dlsym(handle, "__sanitizer_cov_trace_div4_with_pc"));
-  atomic_store(&trace_div8_with_pc,
-               dlsym(handle, "__sanitizer_cov_trace_div8_with_pc"));
-
-  atomic_store(&trace_gep_with_pc,
-               dlsym(handle, "__sanitizer_cov_trace_gep_with_pc"));
-
-  atomic_store(&trace_pc_indir_with_pc,
-               dlsym(handle, "__sanitizer_cov_trace_pc_indir_with_pc"));
-}
-
-// Alternate definitions for libc functions mimicking those that libFuzzer would
-// provide if it were part of the native driver executable. All these functions
-// invoke the real libc function loaded from the next library in search order
-// (usually libc itself).
-// Function pointers have to be loaded and stored atomically even if libc
-// functions are invoked from different threads, but we do not need any
-// synchronization guarantees - in the worst case, we will non-deterministically
-// lose a few hook invocations.
-
-__attribute__((visibility("default"))) int bcmp(const void *s1, const void *s2,
-                                                size_t n) {
-  bcmp_t bcmp_real_local =
-      atomic_load_explicit(&bcmp_real, memory_order_relaxed);
-  if (UNLIKELY(bcmp_real_local == NULL)) {
-    bcmp_real_local = dlsym(RTLD_NEXT, "bcmp");
-    atomic_store_explicit(&bcmp_real, bcmp_real_local, memory_order_relaxed);
-  }
-
-  int result = bcmp_real_local(s1, s2, n);
-  bcmp_hook_t hook = atomic_load_explicit(&bcmp_hook, memory_order_relaxed);
-  if (LIKELY(hook != NULL)) {
-    hook(GET_CALLER_PC(), s1, s2, n, result);
-  }
-  return result;
-}
-
-__attribute__((visibility("default"))) int memcmp(const void *s1,
-                                                  const void *s2, size_t n) {
-  memcmp_t memcmp_real_local =
-      atomic_load_explicit(&memcmp_real, memory_order_relaxed);
-  if (UNLIKELY(memcmp_real_local == NULL)) {
-    memcmp_real_local = dlsym(RTLD_NEXT, "memcmp");
-    atomic_store_explicit(&memcmp_real, memcmp_real_local,
-                          memory_order_relaxed);
-  }
-
-  int result = memcmp_real_local(s1, s2, n);
-  memcmp_hook_t hook = atomic_load_explicit(&memcmp_hook, memory_order_relaxed);
-  if (LIKELY(hook != NULL)) {
-    hook(GET_CALLER_PC(), s1, s2, n, result);
-  }
-  return result;
-}
-
-__attribute__((visibility("default"))) int strncmp(const char *s1,
-                                                   const char *s2, size_t n) {
-  strncmp_t strncmp_real_local =
-      atomic_load_explicit(&strncmp_real, memory_order_relaxed);
-  if (UNLIKELY(strncmp_real_local == NULL)) {
-    strncmp_real_local = dlsym(RTLD_NEXT, "strncmp");
-    atomic_store_explicit(&strncmp_real, strncmp_real_local,
-                          memory_order_relaxed);
-  }
-
-  int result = strncmp_real_local(s1, s2, n);
-  strncmp_hook_t hook =
-      atomic_load_explicit(&strncmp_hook, memory_order_relaxed);
-  if (LIKELY(hook != NULL)) {
-    hook(GET_CALLER_PC(), s1, s2, n, result);
-  }
-  return result;
-}
-
-__attribute__((visibility("default"))) int strncasecmp(const char *s1,
-                                                       const char *s2,
-                                                       size_t n) {
-  strncasecmp_t strncasecmp_real_local =
-      atomic_load_explicit(&strncasecmp_real, memory_order_relaxed);
-  if (UNLIKELY(strncasecmp_real_local == NULL)) {
-    strncasecmp_real_local = dlsym(RTLD_NEXT, "strncasecmp");
-    atomic_store_explicit(&strncasecmp_real, strncasecmp_real_local,
-                          memory_order_relaxed);
-  }
-
-  int result = strncasecmp_real_local(s1, s2, n);
-  strncasecmp_hook_t hook =
-      atomic_load_explicit(&strncasecmp_hook, memory_order_relaxed);
-  if (LIKELY(hook != NULL)) {
-    hook(GET_CALLER_PC(), s1, s2, n, result);
-  }
-  return result;
-}
-
-__attribute__((visibility("default"))) int strcmp(const char *s1,
-                                                  const char *s2) {
-  strcmp_t strcmp_real_local =
-      atomic_load_explicit(&strcmp_real, memory_order_relaxed);
-  if (UNLIKELY(strcmp_real_local == NULL)) {
-    strcmp_real_local = dlsym(RTLD_NEXT, "strcmp");
-    atomic_store_explicit(&strcmp_real, strcmp_real_local,
-                          memory_order_relaxed);
-  }
-
-  int result = strcmp_real_local(s1, s2);
-  strcmp_hook_t hook = atomic_load_explicit(&strcmp_hook, memory_order_relaxed);
-  if (LIKELY(hook != NULL)) {
-    hook(GET_CALLER_PC(), s1, s2, result);
-  }
-  return result;
-}
-
-__attribute__((visibility("default"))) int strcasecmp(const char *s1,
-                                                      const char *s2) {
-  strcasecmp_t strcasecmp_real_local =
-      atomic_load_explicit(&strcasecmp_real, memory_order_relaxed);
-  if (UNLIKELY(strcasecmp_real_local == NULL)) {
-    strcasecmp_real_local = dlsym(RTLD_NEXT, "strcasecmp");
-    atomic_store_explicit(&strcasecmp_real, strcasecmp_real_local,
-                          memory_order_relaxed);
-  }
-
-  int result = strcasecmp_real_local(s1, s2);
-  strcasecmp_hook_t hook =
-      atomic_load_explicit(&strcasecmp_hook, memory_order_relaxed);
-  if (LIKELY(hook != NULL)) {
-    hook(GET_CALLER_PC(), s1, s2, result);
-  }
-  return result;
-}
-
-__attribute__((visibility("default"))) char *strstr(const char *s1,
-                                                    const char *s2) {
-  strstr_t strstr_real_local =
-      atomic_load_explicit(&strstr_real, memory_order_relaxed);
-  if (UNLIKELY(strstr_real_local == NULL)) {
-    strstr_real_local = dlsym(RTLD_NEXT, "strstr");
-    atomic_store_explicit(&strstr_real, strstr_real_local,
-                          memory_order_relaxed);
-  }
-
-  char *result = strstr_real_local(s1, s2);
-  strstr_hook_t hook = atomic_load_explicit(&strstr_hook, memory_order_relaxed);
-  if (LIKELY(hook != NULL)) {
-    hook(GET_CALLER_PC(), s1, s2, result);
-  }
-  return result;
-}
-
-__attribute__((visibility("default"))) char *strcasestr(const char *s1,
-                                                        const char *s2) {
-  strcasestr_t strcasestr_real_local =
-      atomic_load_explicit(&strcasestr_real, memory_order_relaxed);
-  if (UNLIKELY(strcasestr_real_local == NULL)) {
-    strcasestr_real_local = dlsym(RTLD_NEXT, "strcasestr");
-    atomic_store_explicit(&strcasestr_real, strcasestr_real_local,
-                          memory_order_relaxed);
-  }
-
-  char *result = strcasestr_real_local(s1, s2);
-  strcasestr_hook_t hook =
-      atomic_load_explicit(&strcasestr_hook, memory_order_relaxed);
-  if (LIKELY(hook != NULL)) {
-    hook(GET_CALLER_PC(), s1, s2, result);
-  }
-  return result;
-}
-
-__attribute__((visibility("default"))) void *memmem(const void *s1, size_t n1,
-                                                    const void *s2, size_t n2) {
-  memmem_t memmem_real_local =
-      atomic_load_explicit(&memmem_real, memory_order_relaxed);
-  if (UNLIKELY(memmem_real_local == NULL)) {
-    memmem_real_local = dlsym(RTLD_NEXT, "memmem");
-    atomic_store_explicit(&memmem_real, memmem_real_local,
-                          memory_order_relaxed);
-  }
-
-  void *result = memmem_real_local(s1, n1, s2, n2);
-  memmem_hook_t hook = atomic_load_explicit(&memmem_hook, memory_order_relaxed);
-  if (LIKELY(hook != NULL)) {
-    hook(GET_CALLER_PC(), s1, n1, s2, n2, result);
-  }
-  return result;
-}
-
-// The __sanitizer_cov_trace_* family of functions is only invoked from code
-// compiled with -fsanitize=fuzzer. We can assume that the Jazzer JNI library
-// has been loaded before any such code, which necessarily belongs to the fuzz
-// target, is executed and thus don't need NULL checks.
-
-__attribute__((visibility("default"))) void __sanitizer_cov_trace_cmp1(
-    uint8_t arg1, uint8_t arg2) {
-  trace_cmp1_t hook =
-      atomic_load_explicit(&trace_cmp1_with_pc, memory_order_relaxed);
-  hook(GET_CALLER_PC(), arg1, arg2);
-}
-
-__attribute__((visibility("default"))) void __sanitizer_cov_trace_cmp2(
-    uint16_t arg1, uint16_t arg2) {
-  trace_cmp2_t hook =
-      atomic_load_explicit(&trace_cmp2_with_pc, memory_order_relaxed);
-  hook(GET_CALLER_PC(), arg1, arg2);
-}
-
-__attribute__((visibility("default"))) void __sanitizer_cov_trace_cmp4(
-    uint32_t arg1, uint32_t arg2) {
-  trace_cmp4_t hook =
-      atomic_load_explicit(&trace_cmp4_with_pc, memory_order_relaxed);
-  hook(GET_CALLER_PC(), arg1, arg2);
-}
-
-__attribute__((visibility("default"))) void __sanitizer_cov_trace_cmp8(
-    uint64_t arg1, uint64_t arg2) {
-  trace_cmp8_t hook =
-      atomic_load_explicit(&trace_cmp8_with_pc, memory_order_relaxed);
-  hook(GET_CALLER_PC(), arg1, arg2);
-}
-
-__attribute__((visibility("default"))) void __sanitizer_cov_trace_const_cmp1(
-    uint8_t arg1, uint8_t arg2) {
-  trace_const_cmp1_t hook =
-      atomic_load_explicit(&trace_const_cmp1_with_pc, memory_order_relaxed);
-  hook(GET_CALLER_PC(), arg1, arg2);
-}
-
-__attribute__((visibility("default"))) void __sanitizer_cov_trace_const_cmp2(
-    uint16_t arg1, uint16_t arg2) {
-  trace_const_cmp2_t hook =
-      atomic_load_explicit(&trace_const_cmp2_with_pc, memory_order_relaxed);
-  hook(GET_CALLER_PC(), arg1, arg2);
-}
-
-__attribute__((visibility("default"))) void __sanitizer_cov_trace_const_cmp4(
-    uint32_t arg1, uint32_t arg2) {
-  trace_const_cmp4_t hook =
-      atomic_load_explicit(&trace_const_cmp4_with_pc, memory_order_relaxed);
-  hook(GET_CALLER_PC(), arg1, arg2);
-}
-
-__attribute__((visibility("default"))) void __sanitizer_cov_trace_const_cmp8(
-    uint64_t arg1, uint64_t arg2) {
-  trace_const_cmp8_t hook =
-      atomic_load_explicit(&trace_const_cmp8_with_pc, memory_order_relaxed);
-  hook(GET_CALLER_PC(), arg1, arg2);
-}
-
-__attribute__((visibility("default"))) void __sanitizer_cov_trace_switch(
-    uint64_t val, uint64_t *cases) {
-  trace_switch_t hook =
-      atomic_load_explicit(&trace_switch_with_pc, memory_order_relaxed);
-  hook(GET_CALLER_PC(), val, cases);
-}
-
-__attribute__((visibility("default"))) void __sanitizer_cov_trace_div4(
-    uint32_t val) {
-  trace_div4_t hook =
-      atomic_load_explicit(&trace_div4_with_pc, memory_order_relaxed);
-  hook(GET_CALLER_PC(), val);
-}
-
-__attribute__((visibility("default"))) void __sanitizer_cov_trace_div8(
-    uint64_t val) {
-  trace_div8_t hook =
-      atomic_load_explicit(&trace_div8_with_pc, memory_order_relaxed);
-  hook(GET_CALLER_PC(), val);
-}
-
-__attribute__((visibility("default"))) void __sanitizer_cov_trace_gep(
-    uintptr_t idx) {
-  trace_gep_t hook =
-      atomic_load_explicit(&trace_gep_with_pc, memory_order_relaxed);
-  hook(GET_CALLER_PC(), idx);
-}
-
-__attribute__((visibility("default"))) void __sanitizer_cov_trace_pc_indir(
-    uintptr_t callee) {
-  trace_pc_indir_t hook =
-      atomic_load_explicit(&trace_pc_indir_with_pc, memory_order_relaxed);
-  hook(GET_CALLER_PC(), callee);
-}
-
-__attribute__((visibility("default"))) void __sanitizer_cov_8bit_counters_init(
-    uint8_t *start, uint8_t *end) {
-  cov_8bit_counters_init_t init =
-      atomic_load_explicit(&cov_8bit_counters_init, memory_order_relaxed);
-  init(start, end);
-}
-
-__attribute__((visibility("default"))) void __sanitizer_cov_pcs_init(
-    const uintptr_t *pcs_beg, const uintptr_t *pcs_end) {
-  cov_pcs_init_t init =
-      atomic_load_explicit(&cov_pcs_init, memory_order_relaxed);
-  init(pcs_beg, pcs_end);
-}
-
-// The __sanitizer_weak_hook_* family of functions can be invoked early on macOS
-// and thus requires NULL checks.
-
-__attribute__((visibility("default"))) void __sanitizer_weak_hook_memcmp(
-    void *called_pc, const void *s1, const void *s2, size_t n, int result) {
-  memcmp_hook_t hook = atomic_load_explicit(&memcmp_hook, memory_order_relaxed);
-  if (LIKELY(hook != NULL)) {
-    hook(called_pc, s1, s2, n, result);
-  }
-}
-
-__attribute__((visibility("default"))) void __sanitizer_weak_hook_strncmp(
-    void *called_pc, const void *s1, const void *s2, size_t n, int result) {
-  strncmp_hook_t hook =
-      atomic_load_explicit(&strncmp_hook, memory_order_relaxed);
-  if (LIKELY(hook != NULL)) {
-    hook(called_pc, s1, s2, n, result);
-  }
-}
-
-__attribute__((visibility("default"))) void __sanitizer_weak_hook_strcmp(
-    void *called_pc, const void *s1, const void *s2, int result) {
-  strcmp_hook_t hook = atomic_load_explicit(&strcmp_hook, memory_order_relaxed);
-  if (LIKELY(hook != NULL)) {
-    hook(called_pc, s1, s2, result);
-  }
-}
-
-__attribute__((visibility("default"))) void __sanitizer_weak_hook_strncasecmp(
-    void *called_pc, const void *s1, const void *s2, size_t n, int result) {
-  strncasecmp_hook_t hook =
-      atomic_load_explicit(&strncasecmp_hook, memory_order_relaxed);
-  if (LIKELY(hook != NULL)) {
-    hook(called_pc, s1, s2, n, result);
-  }
-}
-
-__attribute__((visibility("default"))) void __sanitizer_weak_hook_strcasecmp(
-    void *called_pc, const void *s1, const void *s2, int result) {
-  strcasecmp_hook_t hook =
-      atomic_load_explicit(&strcasecmp_hook, memory_order_relaxed);
-  if (LIKELY(hook != NULL)) {
-    hook(called_pc, s1, s2, result);
-  }
-}
-
-__attribute__((visibility("default"))) void __sanitizer_weak_hook_strstr(
-    void *called_pc, const void *s1, const void *s2, char *result) {
-  strstr_hook_t hook = atomic_load_explicit(&strstr_hook, memory_order_relaxed);
-  if (LIKELY(hook != NULL)) {
-    hook(called_pc, s1, s2, result);
-  }
-}
-
-__attribute__((visibility("default"))) void __sanitizer_weak_hook_strcasestr(
-    void *called_pc, const void *s1, const void *s2, char *result) {
-  strcasestr_hook_t hook =
-      atomic_load_explicit(&strstr_hook, memory_order_relaxed);
-  hook(called_pc, s1, s2, result);
-}
-
-__attribute__((visibility("default"))) void __sanitizer_weak_hook_memmem(
-    void *called_pc, const void *s1, size_t len1, const void *s2, size_t len2,
-    void *result) {
-  memmem_hook_t hook = atomic_load_explicit(&memmem_hook, memory_order_relaxed);
-  hook(called_pc, s1, len1, s2, len2, result);
-}
diff --git a/driver/src/main/java/com/code_intelligence/jazzer/driver/BUILD.bazel b/driver/src/main/java/com/code_intelligence/jazzer/driver/BUILD.bazel
deleted file mode 100644
index c8e6ba1..0000000
--- a/driver/src/main/java/com/code_intelligence/jazzer/driver/BUILD.bazel
+++ /dev/null
@@ -1,64 +0,0 @@
-load("@fmeum_rules_jni//jni:defs.bzl", "java_jni_library")
-
-java_library(
-    name = "driver",
-    srcs = [":Driver.java"],
-    visibility = [
-        "//agent:__pkg__",
-    ],
-    deps = [
-        ":fuzz_target_runner",
-        ":opt",
-        ":utils",
-        "//agent/src/main/java/com/code_intelligence/jazzer/agent:agent_lib",
-        "@net_bytebuddy_byte_buddy_agent//jar",
-    ],
-)
-
-java_jni_library(
-    name = "fuzz_target_runner",
-    srcs = ["FuzzTargetRunner.java"],
-    native_libs = [
-        "//driver/src/main/native/com/code_intelligence/jazzer/driver:jazzer_driver",
-    ],
-    visibility = [
-        "//agent:__pkg__",
-        "//driver/src/main/native/com/code_intelligence/jazzer/driver:__pkg__",
-        "//driver/src/test:__subpackages__",
-    ],
-    deps = [
-        ":opt",
-        ":reproducer_template",
-        ":utils",
-        "//agent/src/main/java/com/code_intelligence/jazzer/api",
-        "//agent/src/main/java/com/code_intelligence/jazzer/autofuzz",
-        "//agent/src/main/java/com/code_intelligence/jazzer/instrumentor",
-        "//agent/src/main/java/com/code_intelligence/jazzer/runtime",
-        "//agent/src/main/java/com/code_intelligence/jazzer/runtime:coverage_map",
-        "//agent/src/main/java/com/code_intelligence/jazzer/runtime:fuzzed_data_provider",
-        "//agent/src/main/java/com/code_intelligence/jazzer/runtime:signal_handler",
-        "//agent/src/main/java/com/code_intelligence/jazzer/runtime:unsafe_provider",
-        "//agent/src/main/java/com/code_intelligence/jazzer/utils",
-    ],
-)
-
-java_library(
-    name = "reproducer_template",
-    srcs = ["ReproducerTemplate.java"],
-    resources = ["Reproducer.java.tmpl"],
-    deps = [":opt"],
-)
-
-java_library(
-    name = "opt",
-    srcs = ["Opt.java"],
-    visibility = [
-        "//agent/src/main/java/com/code_intelligence/jazzer:__subpackages__",
-        "//driver/src/test/java/com/code_intelligence/jazzer/driver:__pkg__",
-    ],
-)
-
-java_library(
-    name = "utils",
-    srcs = ["Utils.java"],
-)
diff --git a/driver/src/main/java/com/code_intelligence/jazzer/driver/Driver.java b/driver/src/main/java/com/code_intelligence/jazzer/driver/Driver.java
deleted file mode 100644
index 5b107ad..0000000
--- a/driver/src/main/java/com/code_intelligence/jazzer/driver/Driver.java
+++ /dev/null
@@ -1,111 +0,0 @@
-/*
- * Copyright 2022 Code Intelligence GmbH
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.code_intelligence.jazzer.driver;
-
-import static java.lang.System.err;
-
-import com.code_intelligence.jazzer.agent.Agent;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.security.SecureRandom;
-import java.util.List;
-import net.bytebuddy.agent.ByteBuddyAgent;
-
-public class Driver {
-  // Accessed from jazzer_main.cpp.
-  @SuppressWarnings("unused")
-  private static int start(byte[][] nativeArgs) throws IOException {
-    List<String> args = Utils.fromNativeArgs(nativeArgs);
-
-    final boolean spawnsSubprocesses = args.stream().anyMatch(
-        arg -> arg.startsWith("-fork=") || arg.startsWith("-jobs=") || arg.startsWith("-merge="));
-    if (spawnsSubprocesses) {
-      if (!System.getProperty("jazzer.coverage_report", "").isEmpty()) {
-        err.println(
-            "WARN: --coverage_report does not support parallel fuzzing and has been disabled");
-        System.clearProperty("jazzer.coverage_report");
-      }
-      if (!System.getProperty("jazzer.coverage_dump", "").isEmpty()) {
-        err.println(
-            "WARN: --coverage_dump does not support parallel fuzzing and has been disabled");
-        System.clearProperty("jazzer.coverage_dump");
-      }
-
-      String idSyncFileArg = System.getProperty("jazzer.id_sync_file", "");
-      Path idSyncFile;
-      if (idSyncFileArg.isEmpty()) {
-        // Create an empty temporary file used for coverage ID synchronization and
-        // pass its path to the agent in every child process. This requires adding
-        // the argument to argv for it to be picked up by libFuzzer, which then
-        // forwards it to child processes.
-        idSyncFile = Files.createTempFile("jazzer-", "");
-        args.add("--id_sync_file=" + idSyncFile.toAbsolutePath());
-      } else {
-        // Creates the file, truncating it if it exists.
-        idSyncFile = Files.write(Paths.get(idSyncFileArg), new byte[] {});
-      }
-      // This wouldn't run in case we exit the process with _Exit, but the parent process of a -fork
-      // run is expected to exit with a regular exit(0), which does cause JVM shutdown hooks to run:
-      // https://github.com/llvm/llvm-project/blob/940e178c0018b32af2f1478d331fc41a92a7dac7/compiler-rt/lib/fuzzer/FuzzerFork.cpp#L491
-      idSyncFile.toFile().deleteOnExit();
-    }
-
-    // Jazzer's hooks use deterministic randomness and thus require a seed. Search for the last
-    // occurrence of a "-seed" argument as that is the one that is used by libFuzzer. If none is
-    // set, generate one and pass it to libFuzzer so that a fuzzing run can be reproduced simply by
-    // setting the seed printed by libFuzzer.
-    String seed = args.stream().reduce(
-        null, (prev, cur) -> cur.startsWith("-seed=") ? cur.substring("-seed=".length()) : prev);
-    if (seed == null) {
-      seed = Integer.toUnsignedString(new SecureRandom().nextInt());
-      // Only add the -seed argument to the command line if not running in a mode
-      // that spawns subprocesses. These would inherit the same seed, which might
-      // make them less effective.
-      if (!spawnsSubprocesses) {
-        args.add("-seed=" + seed);
-      }
-    }
-    System.setProperty("jazzer.seed", seed);
-
-    if (args.stream().noneMatch(arg -> arg.startsWith("-rss_limit_mb="))) {
-      args.add(getDefaultRssLimitMbArg());
-    }
-
-    // Do *not* modify system properties beyond this point - initializing Opt parses them as a side
-    // effect.
-
-    if (Opt.hooks) {
-      Agent.premain(null, ByteBuddyAgent.install());
-    }
-
-    return FuzzTargetRunner.startLibFuzzer(args);
-  }
-
-  private static String getDefaultRssLimitMbArg() {
-    // Java OutOfMemoryErrors are strictly more informative than libFuzzer's out of memory crashes.
-    // We thus want to scale the default libFuzzer memory limit, which includes all memory used by
-    // the process including Jazzer's native and non-native memory footprint, such that:
-    // 1. we never reach it purely by allocating memory on the Java heap;
-    // 2. it is still reached if the fuzz target allocates excessively on the native heap.
-    // As a heuristic, we set the overall memory limit to 2 * the maximum size of the Java heap and
-    // add a fixed 1 GiB on top for the fuzzer's own memory usage.
-    long maxHeapInBytes = Runtime.getRuntime().maxMemory();
-    return "-rss_limit_mb=" + ((2 * maxHeapInBytes / (1024 * 1024)) + 1024);
-  }
-}
diff --git a/driver/src/main/java/com/code_intelligence/jazzer/driver/FuzzTargetRunner.java b/driver/src/main/java/com/code_intelligence/jazzer/driver/FuzzTargetRunner.java
deleted file mode 100644
index 5646e91..0000000
--- a/driver/src/main/java/com/code_intelligence/jazzer/driver/FuzzTargetRunner.java
+++ /dev/null
@@ -1,450 +0,0 @@
-/*
- * Copyright 2022 Code Intelligence GmbH
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.code_intelligence.jazzer.driver;
-
-import static java.lang.System.err;
-import static java.lang.System.exit;
-import static java.lang.System.out;
-
-import com.code_intelligence.jazzer.api.FuzzedDataProvider;
-import com.code_intelligence.jazzer.autofuzz.FuzzTarget;
-import com.code_intelligence.jazzer.instrumentor.CoverageRecorder;
-import com.code_intelligence.jazzer.runtime.CoverageMap;
-import com.code_intelligence.jazzer.runtime.FuzzedDataProviderImpl;
-import com.code_intelligence.jazzer.runtime.JazzerInternal;
-import com.code_intelligence.jazzer.runtime.RecordingFuzzedDataProvider;
-import com.code_intelligence.jazzer.runtime.SignalHandler;
-import com.code_intelligence.jazzer.runtime.UnsafeProvider;
-import com.code_intelligence.jazzer.utils.ExceptionUtils;
-import com.code_intelligence.jazzer.utils.ManifestUtils;
-import com.github.fmeum.rules_jni.RulesJni;
-import java.io.IOException;
-import java.lang.invoke.MethodHandle;
-import java.lang.invoke.MethodHandles;
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
-import java.lang.reflect.Modifier;
-import java.math.BigInteger;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-import java.util.Arrays;
-import java.util.Base64;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Locale;
-import java.util.Set;
-import sun.misc.Unsafe;
-
-/**
- * Executes a fuzz target and reports findings.
- *
- * <p>This class maintains global state (both native and non-native) and thus cannot be used
- * concurrently.
- */
-public final class FuzzTargetRunner {
-  static {
-    RulesJni.loadLibrary("jazzer_driver", FuzzTargetRunner.class);
-  }
-
-  private static final Unsafe UNSAFE = UnsafeProvider.getUnsafe();
-  private static final long BYTE_ARRAY_OFFSET = UNSAFE.arrayBaseOffset(byte[].class);
-
-  // Default value of the libFuzzer -error_exitcode flag.
-  private static final int LIBFUZZER_ERROR_EXIT_CODE = 77;
-  private static final String AUTOFUZZ_FUZZ_TARGET =
-      "com.code_intelligence.jazzer.autofuzz.FuzzTarget";
-  private static final String FUZZER_TEST_ONE_INPUT = "fuzzerTestOneInput";
-  private static final String FUZZER_INITIALIZE = "fuzzerInitialize";
-  private static final String FUZZER_TEARDOWN = "fuzzerTearDown";
-
-  private static final Set<Long> ignoredTokens = new HashSet<>(Opt.ignore);
-  private static final FuzzedDataProviderImpl fuzzedDataProvider =
-      FuzzedDataProviderImpl.withNativeData();
-  private static final Class<?> fuzzTargetClass;
-  private static final MethodHandle fuzzTarget;
-  public static final boolean useFuzzedDataProvider;
-  private static final ReproducerTemplate reproducerTemplate;
-
-  static {
-    String targetClassName = determineFuzzTargetClassName();
-
-    // FuzzTargetRunner is loaded by the bootstrap class loader since Driver installs the agent
-    // before invoking FuzzTargetRunner.startLibFuzzer. We can't load the fuzz target with that
-    // class loader - we have to use the class loader that loaded Driver. This would be
-    // straightforward to do in Java 9+, but requires the use of reflection to maintain
-    // compatibility with Java 8, which doesn't have StackWalker.
-    //
-    // Note that we can't just move the agent initialization so that FuzzTargetRunner is loaded by
-    // Driver's class loader: The agent and FuzzTargetRunner have to share the native library that
-    // contains libFuzzer and that library needs to be available in the bootstrap class loader
-    // since instrumentation applied to Java standard library classes still needs to be able to call
-    // libFuzzer hooks. A fundamental JNI restriction is that a native library can't be shared
-    // between two different class loaders, so FuzzTargetRunner is thus forced to be loaded in the
-    // bootstrap class loader, which makes this ugly code block necessary.
-    // We also can't use the system class loader since Driver may be loaded by a custom class loader
-    // if not invoked from the native driver.
-    Class<?> driverClass;
-    try {
-      Class<?> reflectionClass = Class.forName("sun.reflect.Reflection");
-      try {
-        driverClass =
-            (Class<?>) reflectionClass.getMethod("getCallerClass", int.class).invoke(null, 2);
-      } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
-        throw new IllegalStateException(e);
-      }
-    } catch (ClassNotFoundException e) {
-      // sun.reflect.Reflection is no longer available after Java 8, use StackWalker.
-      try {
-        Class<?> stackWalker = Class.forName("java.lang.StackWalker");
-        Class<? extends Enum<?>> stackWalkerOption =
-            (Class<? extends Enum<?>>) Class.forName("java.lang.StackWalker$Option");
-        Enum<?> retainClassReferences =
-            Arrays.stream(stackWalkerOption.getEnumConstants())
-                .filter(v -> v.name().equals("RETAIN_CLASS_REFERENCE"))
-                .findFirst()
-                .orElseThrow(()
-                                 -> new IllegalStateException(
-                                     "No RETAIN_CLASS_REFERENCE in java.lang.StackWalker$Option"));
-        Object stackWalkerInstance = stackWalker.getMethod("getInstance", stackWalkerOption)
-                                         .invoke(null, retainClassReferences);
-        Method stackWalkerGetCallerClass = stackWalker.getMethod("getCallerClass");
-        driverClass = (Class<?>) stackWalkerGetCallerClass.invoke(stackWalkerInstance);
-      } catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException
-          | InvocationTargetException ex) {
-        throw new IllegalStateException(ex);
-      }
-    }
-
-    try {
-      ClassLoader driverClassLoader = driverClass.getClassLoader();
-      driverClassLoader.setDefaultAssertionStatus(true);
-      fuzzTargetClass = Class.forName(targetClassName, false, driverClassLoader);
-    } catch (ClassNotFoundException e) {
-      err.print("ERROR: ");
-      e.printStackTrace(err);
-      exit(1);
-      throw new IllegalStateException("Not reached");
-    }
-    // Inform the agent about the fuzz target class. Important note: This has to be done *before*
-    // the class is initialized so that hooks can enable themselves in time for the fuzz target's
-    // static initializer.
-    JazzerInternal.onFuzzTargetReady(targetClassName);
-
-    Method bytesFuzzTarget = targetPublicStaticMethodOrNull(FUZZER_TEST_ONE_INPUT, byte[].class);
-    Method dataFuzzTarget =
-        targetPublicStaticMethodOrNull(FUZZER_TEST_ONE_INPUT, FuzzedDataProvider.class);
-    if ((bytesFuzzTarget != null) == (dataFuzzTarget != null)) {
-      err.printf(
-          "ERROR: %s must define exactly one of the following two functions:%n", targetClassName);
-      err.println("public static void fuzzerTestOneInput(byte[] ...)");
-      err.println("public static void fuzzerTestOneInput(FuzzedDataProvider ...)");
-      err.println(
-          "Note: Fuzz targets returning boolean are no longer supported; exceptions should be thrown instead of returning true.");
-      exit(1);
-    }
-    try {
-      if (bytesFuzzTarget != null) {
-        useFuzzedDataProvider = false;
-        fuzzTarget = MethodHandles.publicLookup().unreflect(bytesFuzzTarget);
-      } else {
-        useFuzzedDataProvider = true;
-        fuzzTarget = MethodHandles.publicLookup().unreflect(dataFuzzTarget);
-      }
-    } catch (IllegalAccessException e) {
-      throw new RuntimeException(e);
-    }
-    reproducerTemplate = new ReproducerTemplate(fuzzTargetClass.getName(), useFuzzedDataProvider);
-
-    Method initializeNoArgs = targetPublicStaticMethodOrNull(FUZZER_INITIALIZE);
-    Method initializeWithArgs = targetPublicStaticMethodOrNull(FUZZER_INITIALIZE, String[].class);
-    try {
-      if (initializeWithArgs != null) {
-        initializeWithArgs.invoke(null, (Object) Opt.targetArgs.toArray(new String[] {}));
-      } else if (initializeNoArgs != null) {
-        initializeNoArgs.invoke(null);
-      }
-    } catch (IllegalAccessException | InvocationTargetException e) {
-      err.print("== Java Exception in fuzzerInitialize: ");
-      e.printStackTrace(err);
-      exit(1);
-    }
-
-    if (Opt.hooks) {
-      // libFuzzer will clear the coverage map after this method returns and keeps no record of the
-      // coverage accumulated so far (e.g. by static initializers). We record it here to keep it
-      // around for JaCoCo coverage reports.
-      CoverageRecorder.updateCoveredIdsWithCoverageMap();
-    }
-
-    Runtime.getRuntime().addShutdownHook(new Thread(FuzzTargetRunner::shutdown));
-  }
-
-  /**
-   * A test-only convenience wrapper around {@link #runOne(long, int)}.
-   */
-  static int runOne(byte[] data) {
-    long dataPtr = UNSAFE.allocateMemory(data.length);
-    UNSAFE.copyMemory(data, BYTE_ARRAY_OFFSET, null, dataPtr, data.length);
-    try {
-      return runOne(dataPtr, data.length);
-    } finally {
-      UNSAFE.freeMemory(dataPtr);
-    }
-  }
-
-  /**
-   * Executes the user-provided fuzz target once.
-   *
-   * @param dataPtr a native pointer to beginning of the input provided by the fuzzer for this
-   *     execution
-   * @param dataLength length of the fuzzer input
-   * @return the value that the native LLVMFuzzerTestOneInput function should return. Currently,
-   *         this is always 0. The function may exit the process instead of returning.
-   */
-  private static int runOne(long dataPtr, int dataLength) {
-    Throwable finding = null;
-    byte[] data = null;
-    try {
-      if (useFuzzedDataProvider) {
-        fuzzedDataProvider.setNativeData(dataPtr, dataLength);
-        fuzzTarget.invokeExact((FuzzedDataProvider) fuzzedDataProvider);
-      } else {
-        data = copyToArray(dataPtr, dataLength);
-        fuzzTarget.invokeExact(data);
-      }
-    } catch (Throwable uncaughtFinding) {
-      finding = uncaughtFinding;
-    }
-    // Explicitly reported findings take precedence over uncaught exceptions.
-    if (JazzerInternal.lastFinding != null) {
-      finding = JazzerInternal.lastFinding;
-      JazzerInternal.lastFinding = null;
-    }
-    if (finding == null) {
-      return 0;
-    }
-    if (Opt.hooks) {
-      finding = ExceptionUtils.preprocessThrowable(finding);
-    }
-
-    long dedupToken = Opt.dedup ? ExceptionUtils.computeDedupToken(finding) : 0;
-    // Opt.keepGoing implies Opt.dedup.
-    if (Opt.keepGoing > 1 && !ignoredTokens.add(dedupToken)) {
-      return 0;
-    }
-
-    err.println();
-    err.print("== Java Exception: ");
-    finding.printStackTrace(err);
-    if (Opt.dedup) {
-      // Has to be printed to stdout as it is parsed by libFuzzer when minimizing a crash. It does
-      // not necessarily have to appear at the beginning of a line.
-      // https://github.com/llvm/llvm-project/blob/4c106c93eb68f8f9f201202677cd31e326c16823/compiler-rt/lib/fuzzer/FuzzerDriver.cpp#L342
-      out.printf(Locale.ROOT, "DEDUP_TOKEN: %016x%n", dedupToken);
-    }
-    err.println("== libFuzzer crashing input ==");
-    printCrashingInput();
-    // dumpReproducer needs to be called after libFuzzer printed its final stats as otherwise it
-    // would report incorrect coverage - the reproducer generation involved rerunning the fuzz
-    // target.
-    dumpReproducer(data);
-
-    if (Opt.keepGoing == 1 || Long.compareUnsigned(ignoredTokens.size(), Opt.keepGoing) >= 0) {
-      // Reached the maximum amount of findings to keep going for, crash after shutdown. We use
-      // _Exit rather than System.exit to not trigger libFuzzer's exit handlers.
-      shutdown();
-      _Exit(LIBFUZZER_ERROR_EXIT_CODE);
-      throw new IllegalStateException("Not reached");
-    }
-    return 0;
-  }
-
-  /*
-   * Starts libFuzzer via LLVMFuzzerRunDriver.
-   *
-   * Note: Must be public rather than package-private as it is loaded in a different class loader
-   * than Driver.
-   */
-  public static int startLibFuzzer(List<String> args) {
-    SignalHandler.initialize();
-    return startLibFuzzer(Utils.toNativeArgs(args));
-  }
-
-  private static void shutdown() {
-    if (!Opt.coverageDump.isEmpty() || !Opt.coverageReport.isEmpty()) {
-      int[] everCoveredIds = CoverageMap.getEverCoveredIds();
-      if (!Opt.coverageDump.isEmpty()) {
-        CoverageRecorder.dumpJacocoCoverage(everCoveredIds, Opt.coverageDump);
-      }
-      if (!Opt.coverageReport.isEmpty()) {
-        CoverageRecorder.dumpCoverageReport(everCoveredIds, Opt.coverageReport);
-      }
-    }
-
-    Method teardown = targetPublicStaticMethodOrNull(FUZZER_TEARDOWN);
-    if (teardown == null) {
-      return;
-    }
-    err.println("calling fuzzerTearDown function");
-    try {
-      teardown.invoke(null);
-    } catch (InvocationTargetException e) {
-      // An exception in fuzzerTearDown is a regular finding.
-      err.print("== Java Exception in fuzzerTearDown: ");
-      e.getCause().printStackTrace(err);
-      _Exit(LIBFUZZER_ERROR_EXIT_CODE);
-    } catch (Throwable t) {
-      // Any other exception is an error.
-      t.printStackTrace(err);
-      _Exit(1);
-    }
-  }
-
-  private static String determineFuzzTargetClassName() {
-    if (!Opt.autofuzz.isEmpty()) {
-      return AUTOFUZZ_FUZZ_TARGET;
-    }
-    if (!Opt.targetClass.isEmpty()) {
-      return Opt.targetClass;
-    }
-    String manifestTargetClass = ManifestUtils.detectFuzzTargetClass();
-    if (manifestTargetClass != null) {
-      return manifestTargetClass;
-    }
-    err.println("Missing argument --target_class=<fuzz_target_class>");
-    exit(1);
-    throw new IllegalStateException("Not reached");
-  }
-
-  private static void dumpReproducer(byte[] data) {
-    if (data == null) {
-      assert useFuzzedDataProvider;
-      fuzzedDataProvider.reset();
-      data = fuzzedDataProvider.consumeRemainingAsBytes();
-    }
-    MessageDigest digest;
-    try {
-      digest = MessageDigest.getInstance("SHA-1");
-    } catch (NoSuchAlgorithmException e) {
-      throw new IllegalStateException("SHA-1 not available", e);
-    }
-    String dataSha1 = toHexString(digest.digest(data));
-
-    if (!Opt.autofuzz.isEmpty()) {
-      fuzzedDataProvider.reset();
-      FuzzTarget.dumpReproducer(fuzzedDataProvider, Opt.reproducerPath, dataSha1);
-      return;
-    }
-
-    String base64Data;
-    if (useFuzzedDataProvider) {
-      fuzzedDataProvider.reset();
-      FuzzedDataProvider recordingFuzzedDataProvider =
-          RecordingFuzzedDataProvider.makeFuzzedDataProviderProxy(fuzzedDataProvider);
-      try {
-        fuzzTarget.invokeExact(recordingFuzzedDataProvider);
-        if (JazzerInternal.lastFinding == null) {
-          err.println("Failed to reproduce crash when rerunning with recorder");
-        }
-      } catch (Throwable ignored) {
-        // Expected.
-      }
-      try {
-        base64Data = RecordingFuzzedDataProvider.serializeFuzzedDataProviderProxy(
-            recordingFuzzedDataProvider);
-      } catch (IOException e) {
-        err.print("ERROR: Failed to create reproducer: ");
-        e.printStackTrace(err);
-        // Don't let libFuzzer print a native stack trace.
-        _Exit(1);
-        throw new IllegalStateException("Not reached");
-      }
-    } else {
-      base64Data = Base64.getEncoder().encodeToString(data);
-    }
-
-    reproducerTemplate.dumpReproducer(base64Data, dataSha1);
-  }
-
-  private static Method targetPublicStaticMethodOrNull(String name, Class<?>... parameterTypes) {
-    try {
-      Method method = fuzzTargetClass.getMethod(name, parameterTypes);
-      if (!Modifier.isStatic(method.getModifiers()) || !Modifier.isPublic(method.getModifiers())) {
-        return null;
-      }
-      return method;
-    } catch (NoSuchMethodException e) {
-      return null;
-    }
-  }
-
-  /**
-   * Convert a byte array to a lower-case hex string.
-   *
-   * <p>The returned hex string always has {@code 2 * bytes.length} characters.
-   *
-   * @param bytes the bytes to convert
-   * @return a lower-case hex string representing the bytes
-   */
-  private static String toHexString(byte[] bytes) {
-    String unpadded = new BigInteger(1, bytes).toString(16);
-    int numLeadingZeroes = 2 * bytes.length - unpadded.length();
-    return String.join("", Collections.nCopies(numLeadingZeroes, "0")) + unpadded;
-  }
-
-  // Accessed by fuzz_target_runner.cpp.
-  @SuppressWarnings("unused")
-  private static void dumpAllStackTraces() {
-    ExceptionUtils.dumpAllStackTraces();
-  }
-
-  private static byte[] copyToArray(long ptr, int length) {
-    // TODO: Use Unsafe.allocateUninitializedArray instead once Java 9 is the base.
-    byte[] array = new byte[length];
-    UNSAFE.copyMemory(null, ptr, array, BYTE_ARRAY_OFFSET, length);
-    return array;
-  }
-
-  /**
-   * Starts libFuzzer via LLVMFuzzerRunDriver.
-   *
-   * @param args command-line arguments encoded in UTF-8 (not null-terminated)
-   * @return the return value of LLVMFuzzerRunDriver
-   */
-  private static native int startLibFuzzer(byte[][] args);
-
-  /**
-   * Causes libFuzzer to write the current input to disk as a crashing input and emit some
-   * information about it to stderr.
-   */
-  private static native void printCrashingInput();
-
-  /**
-   * Immediately terminates the process without performing any cleanup.
-   *
-   * <p>Neither JVM shutdown hooks nor native exit handlers are called. This method does not return.
-   *
-   * <p>This method provides a way to exit Jazzer without triggering libFuzzer's exit hook that
-   * prints the "fuzz target exited" error message. It should thus be preferred over
-   * {@link System#exit} in any situation where Jazzer encounters an error after the fuzz target has
-   * started running.
-   *
-   * @param exitCode the exit code
-   */
-  private static native void _Exit(int exitCode);
-}
diff --git a/driver/src/main/java/com/code_intelligence/jazzer/driver/Opt.java b/driver/src/main/java/com/code_intelligence/jazzer/driver/Opt.java
deleted file mode 100644
index 477c7d3..0000000
--- a/driver/src/main/java/com/code_intelligence/jazzer/driver/Opt.java
+++ /dev/null
@@ -1,173 +0,0 @@
-/*
- * Copyright 2022 Code Intelligence GmbH
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.code_intelligence.jazzer.driver;
-
-import static java.lang.System.err;
-import static java.lang.System.exit;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Set;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-
-/**
- * Static options that determine the runtime behavior of the fuzzer, set via Java properties.
- *
- * <p>Each option corresponds to a command-line argument of the driver of the same name.
- *
- * <p>Every public field should be deeply immutable.
- *
- * <p>This class is loaded twice: As it is used in {@link FuzzTargetRunner}, it is loaded in the
- * class loader that loads {@link Driver}. It is also used in
- * {@link com.code_intelligence.jazzer.agent.Agent} after the agent JAR has been added to the
- * bootstrap classpath and thus is loaded again in the bootstrap loader. This is not a problem since
- * it only provides immutable fields and has no non-fatal side effects.
- */
-public final class Opt {
-  private static final char SYSTEM_DELIMITER =
-      System.getProperty("os.name").startsWith("Windows") ? ';' : ':';
-
-  public static final String autofuzz = stringSetting("autofuzz", "");
-  public static final List<String> autofuzzIgnore = stringListSetting("autofuzz_ignore", ',');
-  public static final String coverageDump = stringSetting("coverage_dump", "");
-  public static final String coverageReport = stringSetting("coverage_report", "");
-  public static final List<String> customHookIncludes = stringListSetting("custom_hook_includes");
-  public static final List<String> customHookExcludes = stringListSetting("custom_hook_excludes");
-  public static final List<String> customHooks = stringListSetting("custom_hooks");
-  public static final List<String> disabledHooks = stringListSetting("disabled_hooks");
-  public static final String dumpClassesDir = stringSetting("dump_classes_dir", "");
-  public static final boolean hooks = boolSetting("hooks", true);
-  public static final String idSyncFile = stringSetting("id_sync_file", null);
-  public static final List<String> instrumentationIncludes =
-      stringListSetting("instrumentation_includes");
-  public static final List<String> instrumentationExcludes =
-      stringListSetting("instrumentation_excludes");
-  public static final Set<Long> ignore =
-      Collections.unmodifiableSet(stringListSetting("ignore", ',')
-                                      .stream()
-                                      .map(Long::parseUnsignedLong)
-                                      .collect(Collectors.toSet()));
-  public static final String reproducerPath = stringSetting("reproducer_path", ".");
-  public static final String targetClass = stringSetting("target_class", "");
-  public static final List<String> trace = stringListSetting("trace");
-
-  // The values of these settings depend on autofuzz.
-  public static final List<String> targetArgs = autofuzz.isEmpty()
-      ? stringListSetting("target_args", ' ')
-      : Collections.unmodifiableList(
-          Stream.concat(Stream.of(autofuzz), autofuzzIgnore.stream()).collect(Collectors.toList()));
-  public static final long keepGoing =
-      uint64Setting("keep_going", autofuzz.isEmpty() ? 1 : Long.MAX_VALUE);
-
-  // Default to false if hooks is false to mimic the original behavior of the native fuzz target
-  // runner, but still support hooks = false && dedup = true.
-  public static final boolean dedup = boolSetting("dedup", hooks);
-
-  static {
-    if (!targetClass.isEmpty() && !autofuzz.isEmpty()) {
-      err.println("--target_class and --autofuzz cannot be specified together");
-      exit(1);
-    }
-    if (!stringListSetting("target_args", ' ').isEmpty() && !autofuzz.isEmpty()) {
-      err.println("--target_args and --autofuzz cannot be specified together");
-      exit(1);
-    }
-    if (autofuzz.isEmpty() && !autofuzzIgnore.isEmpty()) {
-      err.println("--autofuzz_ignore requires --autofuzz");
-      exit(1);
-    }
-    if ((!ignore.isEmpty() || keepGoing > 1) && !dedup) {
-      // --autofuzz implicitly sets keepGoing to Integer.MAX_VALUE.
-      err.println("--nodedup is not supported with --ignore, --keep_going, or --autofuzz");
-      exit(1);
-    }
-  }
-
-  private static final String optionsPrefix = "jazzer.";
-
-  private static String stringSetting(String name, String defaultValue) {
-    return System.getProperty(optionsPrefix + name, defaultValue);
-  }
-
-  private static List<String> stringListSetting(String name) {
-    return stringListSetting(name, SYSTEM_DELIMITER);
-  }
-
-  private static List<String> stringListSetting(String name, char separator) {
-    String value = System.getProperty(optionsPrefix + name);
-    if (value == null || value.isEmpty()) {
-      return Collections.emptyList();
-    }
-    return splitOnUnescapedSeparator(value, separator);
-  }
-
-  private static boolean boolSetting(String name, boolean defaultValue) {
-    String value = System.getProperty(optionsPrefix + name);
-    if (value == null) {
-      return defaultValue;
-    }
-    return Boolean.parseBoolean(value);
-  }
-
-  private static long uint64Setting(String name, long defaultValue) {
-    String value = System.getProperty(optionsPrefix + name);
-    if (value == null) {
-      return defaultValue;
-    }
-    return Long.parseUnsignedLong(value, 10);
-  }
-
-  /**
-   * Split value into non-empty takens separated by separator. Backslashes can be used to escape
-   * separators (or backslashes).
-   *
-   * @param value the string to split
-   * @param separator a single character to split on (backslash is not allowed)
-   * @return an immutable list of tokens obtained by splitting value on separator
-   */
-  static List<String> splitOnUnescapedSeparator(String value, char separator) {
-    if (separator == '\\') {
-      throw new IllegalArgumentException("separator '\\' is not supported");
-    }
-    ArrayList<String> tokens = new ArrayList<>();
-    StringBuilder currentToken = new StringBuilder();
-    boolean inEscapeState = false;
-    for (int pos = 0; pos < value.length(); pos++) {
-      char c = value.charAt(pos);
-      if (inEscapeState) {
-        currentToken.append(c);
-        inEscapeState = false;
-      } else if (c == '\\') {
-        inEscapeState = true;
-      } else if (c == separator) {
-        // Do not emit empty tokens between consecutive separators.
-        if (currentToken.length() > 0) {
-          tokens.add(currentToken.toString());
-        }
-        currentToken.setLength(0);
-      } else {
-        currentToken.append(c);
-      }
-    }
-    if (currentToken.length() > 0) {
-      tokens.add(currentToken.toString());
-    }
-    return Collections.unmodifiableList(tokens);
-  }
-}
diff --git a/driver/src/main/java/com/code_intelligence/jazzer/driver/Utils.java b/driver/src/main/java/com/code_intelligence/jazzer/driver/Utils.java
deleted file mode 100644
index 37eb1d0..0000000
--- a/driver/src/main/java/com/code_intelligence/jazzer/driver/Utils.java
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * Copyright 2022 Code Intelligence GmbH
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.code_intelligence.jazzer.driver;
-
-import java.nio.charset.StandardCharsets;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.List;
-import java.util.stream.Collectors;
-
-public class Utils {
-  /**
-   * Convert the arguments to UTF8 before passing them on to JNI as there are no JNI functions to
-   * get (unmodified) UTF-8 out of a jstring.
-   */
-  static byte[][] toNativeArgs(Collection<String> args) {
-    return args.stream().map(str -> str.getBytes(StandardCharsets.UTF_8)).toArray(byte[][] ::new);
-  }
-
-  static List<String> fromNativeArgs(byte[][] args) {
-    return Arrays.stream(args)
-        .map(bytes -> new String(bytes, StandardCharsets.UTF_8))
-        .collect(Collectors.toList());
-  }
-}
diff --git a/driver/src/main/native/com/code_intelligence/jazzer/driver/BUILD.bazel b/driver/src/main/native/com/code_intelligence/jazzer/driver/BUILD.bazel
deleted file mode 100644
index 863a187..0000000
--- a/driver/src/main/native/com/code_intelligence/jazzer/driver/BUILD.bazel
+++ /dev/null
@@ -1,124 +0,0 @@
-load("@fmeum_rules_jni//jni:defs.bzl", "cc_jni_library")
-load("//bazel:compat.bzl", "SKIP_ON_WINDOWS")
-
-cc_jni_library(
-    name = "jazzer_driver",
-    visibility = [
-        "//agent/src/jmh:__subpackages__",
-        "//agent/src/test:__subpackages__",
-        "//driver/src/main/java/com/code_intelligence/jazzer/driver:__pkg__",
-        "//driver/src/test:__subpackages__",
-    ],
-    deps = [
-        ":jazzer_driver_lib",
-        "@jazzer_libfuzzer//:libfuzzer_no_main",
-    ] + select({
-        # Windows doesn't have a concept analogous to RTLD_GLOBAL.
-        "@platforms//os:windows": [],
-        "//conditions:default": [":trigger_driver_hooks_load"],
-    }),
-)
-
-cc_library(
-    name = "jazzer_driver_lib",
-    visibility = ["//driver/src/test/native/com/code_intelligence/jazzer/driver/mocks:__pkg__"],
-    deps = [
-        ":coverage_tracker",
-        ":fuzz_target_runner",
-        ":fuzzed_data_provider",
-        ":jazzer_fuzzer_callbacks",
-        ":libfuzzer_callbacks",
-    ],
-)
-
-cc_library(
-    name = "coverage_tracker",
-    srcs = ["coverage_tracker.cpp"],
-    hdrs = ["coverage_tracker.h"],
-    deps = ["//agent/src/main/java/com/code_intelligence/jazzer/runtime:coverage_map.hdrs"],
-    # Symbols are only referenced dynamically via JNI.
-    alwayslink = True,
-)
-
-cc_library(
-    name = "fuzz_target_runner",
-    srcs = ["fuzz_target_runner.cpp"],
-    hdrs = ["fuzz_target_runner.h"],
-    linkopts = select({
-        "@platforms//os:windows": [],
-        "//conditions:default": ["-ldl"],
-    }),
-    deps = [
-        ":sanitizer_symbols",
-        "//driver/src/main/java/com/code_intelligence/jazzer/driver:fuzz_target_runner.hdrs",
-    ],
-    # With sanitizers, symbols are only referenced dynamically via JNI.
-    alwayslink = True,
-)
-
-cc_library(
-    name = "fuzzed_data_provider",
-    srcs = ["fuzzed_data_provider.cpp"],
-    visibility = [
-        "//driver:__pkg__",
-    ],
-    deps = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/runtime:fuzzed_data_provider.hdrs",
-    ],
-    # Symbols may only be referenced dynamically via JNI.
-    alwayslink = True,
-)
-
-cc_jni_library(
-    name = "fuzzed_data_provider_standalone",
-    visibility = ["//agent/src/main/java/com/code_intelligence/jazzer/replay:__pkg__"],
-    deps = [":fuzzed_data_provider"],
-)
-
-cc_library(
-    name = "jazzer_fuzzer_callbacks",
-    srcs = ["jazzer_fuzzer_callbacks.cpp"],
-    deps = [
-        ":sanitizer_hooks_with_pc",
-        "//agent/src/main/java/com/code_intelligence/jazzer/runtime:trace_data_flow_native_callbacks.hdrs",
-    ],
-    alwayslink = True,
-)
-
-cc_library(
-    name = "libfuzzer_callbacks",
-    srcs = ["libfuzzer_callbacks.cpp"],
-    deps = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/runtime:trace_data_flow_native_callbacks.hdrs",
-        "@com_google_absl//absl/strings",
-    ],
-    # Symbols are only referenced dynamically via JNI.
-    alwayslink = True,
-)
-
-cc_library(
-    name = "trigger_driver_hooks_load",
-    srcs = ["trigger_driver_hooks_load.cpp"],
-    linkopts = ["-ldl"],
-    target_compatible_with = SKIP_ON_WINDOWS,
-    deps = ["@fmeum_rules_jni//jni"],
-    # Symbols are only referenced dynamically via JNI.
-    alwayslink = True,
-)
-
-cc_library(
-    name = "sanitizer_hooks_with_pc",
-    hdrs = ["sanitizer_hooks_with_pc.h"],
-    visibility = [
-        "//agent/src/jmh/native:__subpackages__",
-        "//driver:__pkg__",
-        "//driver/src/test/native/com/code_intelligence/jazzer/driver:__pkg__",
-    ],
-)
-
-cc_library(
-    name = "sanitizer_symbols",
-    srcs = ["sanitizer_symbols.cpp"],
-    # Symbols are referenced dynamically by libFuzzer.
-    alwayslink = True,
-)
diff --git a/driver/src/test/java/com/code_intelligence/jazzer/driver/BUILD.bazel b/driver/src/test/java/com/code_intelligence/jazzer/driver/BUILD.bazel
deleted file mode 100644
index 0411970..0000000
--- a/driver/src/test/java/com/code_intelligence/jazzer/driver/BUILD.bazel
+++ /dev/null
@@ -1,21 +0,0 @@
-java_test(
-    name = "FuzzTargetRunnerTest",
-    srcs = ["FuzzTargetRunnerTest.java"],
-    jvm_flags = ["-ea"],
-    use_testrunner = False,
-    deps = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/api",
-        "//agent/src/main/java/com/code_intelligence/jazzer/runtime:coverage_map",
-        "//agent/src/main/java/com/code_intelligence/jazzer/runtime:unsafe_provider",
-        "//driver/src/main/java/com/code_intelligence/jazzer/driver:fuzz_target_runner",
-    ],
-)
-
-java_test(
-    name = "OptTest",
-    srcs = ["OptTest.java"],
-    deps = [
-        "//driver/src/main/java/com/code_intelligence/jazzer/driver:opt",
-        "@maven//:junit_junit",
-    ],
-)
diff --git a/examples/BUILD.bazel b/examples/BUILD.bazel
index 599b826..1a7da53 100644
--- a/examples/BUILD.bazel
+++ b/examples/BUILD.bazel
@@ -2,14 +2,13 @@
 load("@fmeum_rules_jni//jni:defs.bzl", "java_jni_library")
 load("//bazel:compat.bzl", "SKIP_ON_MACOS", "SKIP_ON_WINDOWS")
 load("//bazel:fuzz_target.bzl", "java_fuzz_target_test")
+load("//bazel:kotlin.bzl", "ktlint")
 
 java_fuzz_target_test(
     name = "Autofuzz",
-    expected_findings = ["java.lang.ArrayIndexOutOfBoundsException"],
+    allowed_findings = ["java.lang.ArrayIndexOutOfBoundsException"],
     fuzzer_args = [
         "--autofuzz=com.google.json.JsonSanitizer::sanitize",
-        # Exit after the first finding for testing purposes.
-        "--keep_going=1",
     ],
     runtime_deps = [
         "@maven//:com_mikesamuel_json_sanitizer",
@@ -18,13 +17,22 @@
 
 java_fuzz_target_test(
     name = "ExampleFuzzer",
-    srcs = [
-        "src/main/java/com/example/ExampleFuzzer.java",
-        "src/main/java/com/example/ExampleFuzzerHooks.java",
-    ],
-    # Comment out the next line to keep the fuzzer running indefinitely.
-    hook_classes = ["com.example.ExampleFuzzerHooks"],
+    srcs = ["src/main/java/com/example/ExampleFuzzer.java"],
+    allowed_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium"],
+    hook_jar = "ExampleFuzzerHooks_deploy.jar",
     target_class = "com.example.ExampleFuzzer",
+    # Does not crash due to not using the hook.
+    verify_crash_reproducer = False,
+)
+
+java_binary(
+    name = "ExampleFuzzerHooks",
+    srcs = ["src/main/java/com/example/ExampleFuzzerHooks.java"],
+    create_executable = False,
+    # Comment out the next line to keep the ExampleFuzzer running indefinitely - without the hook, it will never be able
+    # to pass the comparison with the random number.
+    deploy_manifest_lines = ["Jazzer-Hook-Classes: com.example.ExampleFuzzerHooks"],
+    deps = ["//src/main/java/com/code_intelligence/jazzer/api:hooks"],
 )
 
 java_jni_library(
@@ -38,14 +46,21 @@
     ],
     visibility = ["//examples/src/main/native/com/example:__pkg__"],
     deps = [
-        "//agent:jazzer_api_compile_only",
+        "//deploy:jazzer-api",
     ],
 )
 
 java_fuzz_target_test(
     name = "ExampleFuzzerWithASan",
-    fuzzer_args = ["--jvm_args=-Djazzer.native_lib=native_asan"],
-    sanitizer = "address",
+    allowed_findings = ["native"],
+    env = {"EXAMPLE_NATIVE_LIB": "native_asan"},
+    env_inherit = ["CC"],
+    fuzzer_args = [
+        "--asan",
+    ],
+    # The shell launcher generated by Jazzer is killed in CI, even with codesigning disabled on the
+    # Java binary. This is not reproducible locally or with JDK 17.
+    tags = ["no-macos-x86_64-jdk8"],
     target_class = "com.example.ExampleFuzzerWithNative",
     target_compatible_with = SKIP_ON_WINDOWS,
     verify_crash_reproducer = False,
@@ -56,8 +71,15 @@
 
 java_fuzz_target_test(
     name = "ExampleFuzzerWithUBSan",
-    fuzzer_args = ["--jvm_args=-Djazzer.native_lib=native_ubsan"],
-    sanitizer = "undefined",
+    allowed_findings = ["native"],
+    env = {"EXAMPLE_NATIVE_LIB": "native_ubsan"},
+    env_inherit = ["CC"],
+    fuzzer_args = [
+        "--ubsan",
+    ],
+    # The shell launcher generated by Jazzer is killed in CI, even with codesigning disabled on the
+    # Java binary. This is not reproducible locally or with JDK 17.
+    tags = ["no-macos-x86_64-jdk8"],
     target_class = "com.example.ExampleFuzzerWithNative",
     # Crashes at runtime without an error message.
     target_compatible_with = SKIP_ON_WINDOWS,
@@ -67,14 +89,23 @@
     ],
 )
 
+java_binary(
+    name = "ExamplePathTraversalFuzzerHooks",
+    srcs = ["src/main/java/com/example/ExamplePathTraversalFuzzerHooks.java"],
+    create_executable = False,
+    deploy_manifest_lines = ["Jazzer-Hook-Classes: com.example.ExamplePathTraversalFuzzerHooks"],
+    deps = ["//src/main/java/com/code_intelligence/jazzer/api:hooks"],
+)
+
 java_fuzz_target_test(
     name = "ExamplePathTraversalFuzzer",
     srcs = [
         "src/main/java/com/example/ExamplePathTraversalFuzzer.java",
-        "src/main/java/com/example/ExamplePathTraversalFuzzerHooks.java",
     ],
-    hook_classes = ["com.example.ExamplePathTraversalFuzzerHooks"],
+    allowed_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueHigh"],
+    hook_jar = "ExamplePathTraversalFuzzerHooks_deploy.jar",
     target_class = "com.example.ExamplePathTraversalFuzzer",
+    verify_crash_reproducer = False,
 )
 
 java_fuzz_target_test(
@@ -82,7 +113,7 @@
     srcs = [
         "src/main/java/com/example/ExampleValueProfileFuzzer.java",
     ],
-    expected_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow"],
+    allowed_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow"],
     # Comment out the next line to keep the fuzzer running indefinitely.
     fuzzer_args = ["-use_value_profile=1"],
     target_class = "com.example.ExampleValueProfileFuzzer",
@@ -93,17 +124,21 @@
     srcs = [
         "src/main/java/com/example/MazeFuzzer.java",
     ],
-    expected_findings = ["com.example.MazeFuzzer$$TreasureFoundException"],
+    allowed_findings = ["com.example.MazeFuzzer$$TreasureFoundException"],
     fuzzer_args = ["-use_value_profile=1"],
     target_class = "com.example.MazeFuzzer",
 )
 
 java_fuzz_target_test(
     name = "ExampleOutOfMemoryFuzzer",
+    timeout = "short",
     srcs = [
         "src/main/java/com/example/ExampleOutOfMemoryFuzzer.java",
     ],
-    expected_findings = ["java.lang.OutOfMemoryError"],
+    allowed_findings = [
+        "com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow",
+        "java.lang.OutOfMemoryError",
+    ],
     fuzzer_args = ["--jvm_args=-Xmx512m"],
     target_class = "com.example.ExampleOutOfMemoryFuzzer",
 )
@@ -113,7 +148,10 @@
     srcs = [
         "src/main/java/com/example/ExampleStackOverflowFuzzer.java",
     ],
-    expected_findings = ["java.lang.StackOverflowError"],
+    allowed_findings = [
+        "com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow",
+        "java.lang.StackOverflowError",
+    ],
     target_class = "com.example.ExampleStackOverflowFuzzer",
     # Crashes with a segfault before any stack trace printing is reached.
     target_compatible_with = SKIP_ON_MACOS,
@@ -141,16 +179,37 @@
     ],
 )
 
+# WARNING: This fuzz target uses a vulnerable version of Apache Commons Text, which could result in the execution
+# of arbitrary code during fuzzing if executed with an older JDK. Use at your own risk.
+java_fuzz_target_test(
+    name = "CommonsTextFuzzer",
+    size = "enormous",
+    srcs = [
+        "src/main/java/com/example/CommonsTextFuzzer.java",
+    ],
+    fuzzer_args = [
+        "-fork=8",
+        "-use_value_profile=1",
+    ],
+    tags = ["manual"],
+    target_class = "com.example.CommonsTextFuzzer",
+    verify_crash_reproducer = False,
+    deps = [
+        "@maven//:org_apache_commons_commons_text",
+    ],
+)
+
 java_fuzz_target_test(
     name = "JpegImageParserFuzzer",
     size = "enormous",
     srcs = [
         "src/main/java/com/example/JpegImageParserFuzzer.java",
     ],
-    expected_findings = ["java.lang.NegativeArraySizeException"],
+    allowed_findings = ["java.lang.NegativeArraySizeException"],
     fuzzer_args = [
         "-fork=2",
     ],
+    tags = ["exclusive-if-local"],
     target_class = "com.example.JpegImageParserFuzzer",
     # The exit codes of the forked libFuzzer processes are not picked up correctly.
     target_compatible_with = SKIP_ON_MACOS,
@@ -164,7 +223,7 @@
     srcs = [
         "src/main/java/com/example/GifImageParserFuzzer.java",
     ],
-    expected_findings = [
+    allowed_findings = [
         "java.lang.ArrayIndexOutOfBoundsException",
         "java.lang.IllegalArgumentException",
         "java.lang.OutOfMemoryError",
@@ -192,7 +251,7 @@
     srcs = [
         "src/main/java/com/example/JsonSanitizerCrashFuzzer.java",
     ],
-    expected_findings = ["java.lang.IndexOutOfBoundsException"],
+    allowed_findings = ["java.lang.IndexOutOfBoundsException"],
     target_class = "com.example.JsonSanitizerCrashFuzzer",
     deps = [
         "@maven//:com_mikesamuel_json_sanitizer",
@@ -204,7 +263,7 @@
     srcs = [
         "src/main/java/com/example/JsonSanitizerDenylistFuzzer.java",
     ],
-    expected_findings = ["java.lang.AssertionError"],
+    allowed_findings = ["java.lang.AssertionError"],
     target_class = "com.example.JsonSanitizerDenylistFuzzer",
     deps = [
         "@maven//:com_mikesamuel_json_sanitizer",
@@ -219,7 +278,7 @@
     main_class = "com.code_intelligence.jazzer.replay.Replayer",
     runtime_deps = [
         ":JsonSanitizerDenylistFuzzer_target_deploy.jar",
-        "//agent/src/main/java/com/code_intelligence/jazzer/replay:Replayer_deploy.jar",
+        "//src/main/java/com/code_intelligence/jazzer/replay:Replayer_deploy.jar",
     ],
 )
 
@@ -245,7 +304,7 @@
     srcs = [
         "src/main/java/com/example/JsonSanitizerIdempotenceFuzzer.java",
     ],
-    expected_findings = ["java.lang.AssertionError"],
+    allowed_findings = ["java.lang.AssertionError"],
     target_class = "com.example.JsonSanitizerIdempotenceFuzzer",
     deps = [
         "@maven//:com_mikesamuel_json_sanitizer",
@@ -257,7 +316,7 @@
     srcs = [
         "src/main/java/com/example/JsonSanitizerValidJsonFuzzer.java",
     ],
-    expected_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow"],
+    allowed_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow"],
     target_class = "com.example.JsonSanitizerValidJsonFuzzer",
     deps = [
         "@maven//:com_google_code_gson_gson",
@@ -270,7 +329,7 @@
     srcs = [
         "src/main/java/com/example/JacksonCborFuzzer.java",
     ],
-    expected_findings = ["java.lang.NullPointerException"],
+    allowed_findings = ["java.lang.NullPointerException"],
     target_class = "com.example.JacksonCborFuzzer",
     deps = [
         "@maven//:com_fasterxml_jackson_core_jackson_core",
@@ -284,7 +343,7 @@
     srcs = [
         "src/main/java/com/example/FastJsonFuzzer.java",
     ],
-    expected_findings = ["java.lang.NumberFormatException"],
+    allowed_findings = ["java.lang.NumberFormatException"],
     target_class = "com.example.FastJsonFuzzer",
     deps = [
         "@maven//:com_alibaba_fastjson",
@@ -297,17 +356,18 @@
         "src/main/java/com/example/KlaxonFuzzer.kt",
     ],
     deps = [
-        "//agent:jazzer_api_compile_only",
+        "//deploy:jazzer-api",
         "@maven//:com_beust_klaxon",
     ],
 )
 
 java_fuzz_target_test(
     name = "KlaxonFuzzer",
-    expected_findings = [
+    allowed_findings = [
         "java.lang.ClassCastException",
         "java.lang.IllegalStateException",
         "java.lang.NumberFormatException",
+        "java.lang.NullPointerException",
     ],
     fuzzer_args = [
         "--keep_going=7",
@@ -316,6 +376,47 @@
     runtime_deps = [":KlaxonFuzzTarget"],
 )
 
+kt_jvm_library(
+    name = "ExampleKotlinFuzzTarget",
+    srcs = [
+        "src/main/java/com/example/ExampleKotlinFuzzer.kt",
+    ],
+    deps = [
+        "//deploy:jazzer-api",
+    ],
+)
+
+java_fuzz_target_test(
+    name = "ExampleKotlinFuzzer",
+    allowed_findings = [
+        "com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium",
+    ],
+    target_class = "com.example.ExampleKotlinFuzzer",
+    runtime_deps = [":ExampleKotlinFuzzTarget"],
+)
+
+kt_jvm_library(
+    name = "ExampleKotlinValueProfileFuzzTarget",
+    srcs = [
+        "src/main/java/com/example/ExampleKotlinValueProfileFuzzer.kt",
+    ],
+    deps = [
+        "//deploy:jazzer-api",
+    ],
+)
+
+java_fuzz_target_test(
+    name = "ExampleKotlinValueProfileFuzzer",
+    allowed_findings = [
+        "com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium",
+    ],
+    fuzzer_args = [
+        "-use_value_profile=1",
+    ],
+    target_class = "com.example.ExampleKotlinValueProfileFuzzer",
+    runtime_deps = [":ExampleKotlinValueProfileFuzzTarget"],
+)
+
 java_fuzz_target_test(
     name = "TurboJpegFuzzer",
     srcs = [
@@ -327,8 +428,8 @@
     fuzzer_args = [
         "-rss_limit_mb=8196",
         "--jvm_args=-Djava.library.path=../libjpeg_turbo",
+        "--ubsan",
     ],
-    sanitizer = "address",
     tags = ["manual"],
     target_class = "com.example.TurboJpegFuzzer",
     deps = [
@@ -336,11 +437,31 @@
     ],
 )
 
+java_fuzz_target_test(
+    name = "BatikTranscoderFuzzer",
+    srcs = [
+        "src/main/java/com/example/BatikTranscoderFuzzer.java",
+    ],
+    allowed_findings = [
+        "com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium",
+    ],
+    target_class = "com.example.BatikTranscoderFuzzer",
+    verify_crash_reproducer = False,
+    deps = [
+        "@maven//:org_apache_xmlgraphics_batik_anim",
+        "@maven//:org_apache_xmlgraphics_batik_bridge",
+        "@maven//:org_apache_xmlgraphics_batik_css",
+        "@maven//:org_apache_xmlgraphics_batik_transcoder",
+        "@maven//:org_apache_xmlgraphics_batik_util",
+    ],
+)
+
 java_binary(
     name = "examples",
     create_executable = False,
     visibility = ["//visibility:public"],
     runtime_deps = [
+        ":BatikTranscoderFuzzer_target_deploy.jar",
         ":ExampleFuzzer_target_deploy.jar",
         ":ExampleValueProfileFuzzer_target_deploy.jar",
         ":FastJsonFuzzer_target_deploy.jar",
@@ -349,3 +470,5 @@
         ":JsonSanitizerDenylistFuzzer_target_deploy.jar",
     ],
 )
+
+ktlint()
diff --git a/examples/junit-spring-web/.gitignore b/examples/junit-spring-web/.gitignore
new file mode 100644
index 0000000..549e00a
--- /dev/null
+++ b/examples/junit-spring-web/.gitignore
@@ -0,0 +1,33 @@
+HELP.md
+target/
+!.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
diff --git a/examples/junit-spring-web/.mvn/wrapper/maven-wrapper.jar b/examples/junit-spring-web/.mvn/wrapper/maven-wrapper.jar
new file mode 100644
index 0000000..c1dd12f
--- /dev/null
+++ b/examples/junit-spring-web/.mvn/wrapper/maven-wrapper.jar
Binary files differ
diff --git a/examples/junit-spring-web/.mvn/wrapper/maven-wrapper.properties b/examples/junit-spring-web/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000..b74bf7f
--- /dev/null
+++ b/examples/junit-spring-web/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,2 @@
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.6/apache-maven-3.8.6-bin.zip
+wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar
diff --git a/examples/junit-spring-web/build-and-run-tests.sh b/examples/junit-spring-web/build-and-run-tests.sh
new file mode 100755
index 0000000..a2c819f
--- /dev/null
+++ b/examples/junit-spring-web/build-and-run-tests.sh
@@ -0,0 +1,138 @@
+#!/usr/bin/env bash
+# Copyright 2023 Code Intelligence GmbH
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Development-only. This script builds the example project against the local version of Jazzer,
+# runs its unit and fuzz tests, and compares the results with expected results.
+
+set -e
+( cd ../../ &&
+ bazel build //...
+)
+
+# Update jazzer version used for building this project in the pom.xml
+JAZZER_VERSION=$(grep -oP '(?<=JAZZER_VERSION = ")[^"]*' ../../maven.bzl)
+# Find line with "<artifactId>jazzer-junit</artifactId>" and replace the version in the next line
+sed -i "/<artifactId>jazzer-junit<\/artifactId>/ {n;s/<version>.*<\/version>/<version>$JAZZER_VERSION<\/version>/}" pom.xml
+
+# Add locally-built Jazzer to the Maven repository
+./mvnw install:install-file -Dfile=../../bazel-bin/deploy/jazzer-junit-project.jar -DpomFile=../../bazel-bin/deploy/jazzer-junit-pom.xml
+./mvnw install:install-file -Dfile=../../bazel-bin/deploy/jazzer-project.jar       -DpomFile=../../bazel-bin/deploy/jazzer-pom.xml
+./mvnw install:install-file -Dfile=../../bazel-bin/deploy/jazzer-api-project.jar   -DpomFile=../../bazel-bin/deploy/jazzer-api-pom.xml
+
+## Regression and unit tests
+echo "[SPRINGBOOT-JUNIT]: These unit and regression fuzz tests should pass"
+./mvnw test -Dtest="JunitSpringWebApplicationTests#unitTestShouldPass+fuzzTestShouldPass"
+
+echo "[SPRINGBOOT-JUNIT]: This regression fuzz test should fail."
+# Temporarily disable exit on error.
+set +e
+./mvnw test -Dtest="JunitSpringWebApplicationTests#fuzzTestShouldFail"
+declare -i exit_code=$?
+set -e
+
+# Assert that the test failed with exit code 1.
+if [ $exit_code -eq 1 ]
+then
+  echo "[SPRINGBOOT-JUNIT]: Expected failing fuzz tests: continuing"
+else
+  echo "[SPRINGBOOT-JUNIT]: Expected exit code 1, but got $exit_code"
+  exit 1
+fi
+
+## Fuzz tests
+echo "[SPRINGBOOT-JUNIT]: This fuzz test should pass"
+JAZZER_FUZZ=1 ./mvnw test -Dtest="JunitSpringWebApplicationTests#fuzzTestShouldPass"
+
+echo "[SPRINGBOOT-JUNIT]: This fuzz test should fail"
+set +e
+JAZZER_FUZZ=1 ./mvnw test -Dtest="JunitSpringWebApplicationTests#fuzzTestShouldFail"
+declare -i exit_code=$?
+set -e
+
+if [ $exit_code -eq 1 ]
+then
+  echo "[SPRINGBOOT-JUNIT]: Expected failing fuzz tests: continuing"
+else
+  echo "[SPRINGBOOT-JUNIT]: Expected exit code 1, but got $exit_code"
+  exit 1
+fi
+
+echo "[SPRINGBOOT-JUNIT]: This fuzz test using autofuzz should fail"
+set +e
+JAZZER_FUZZ=1 ./mvnw test -Dtest="JunitSpringWebApplicationTests#fuzzTestWithDtoShouldFail"
+declare -i exit_code=$?
+set -e
+
+if [ $exit_code -eq 1 ]
+then
+  echo "[SPRINGBOOT-JUNIT]: Expected failing fuzz tests: continuing"
+else
+  echo "[SPRINGBOOT-JUNIT]: Expected exit code 1, but got $exit_code"
+  exit 1
+fi
+
+## CLI tests
+## Assert transitive JUnit dependencies are specified
+assertDependency() {
+  if ./mvnw dependency:tree | grep -q "$1"
+  then
+    echo "[SPRINGBOOT-JUNIT]: Found $1 dependency in project"
+  else
+    echo "[SPRINGBOOT-JUNIT]: Did not find $1 dependency in project"
+    exit 1
+  fi
+}
+assertDependency "org.junit.jupiter:junit-jupiter-api"
+assertDependency "org.junit.jupiter:junit-jupiter-params"
+assertDependency "org.junit.platform:junit-platform-launcher"
+
+# Only build project and test jars, no need for a fat-jar or test execution
+./mvnw jar:jar
+./mvnw jar:test-jar
+
+# Extract dependency locations
+out=$(./mvnw dependency:build-classpath -DforceStdout)
+deps=$(echo "$out" | sed '/^\[/d')
+
+# Directly execute Jazzer without Maven
+echo "[SPRINGBOOT-JUNIT]: Direct Jazzer execution of fuzz test should pass"
+java -cp "target/*:${deps}" \
+  com.code_intelligence.jazzer.Jazzer \
+  --target_class=com.example.JunitSpringWebApplicationTests \
+  --target_method=fuzzTestShouldPass \
+  --instrumentation_includes=com.example.* \
+  --custom_hook_includes=com.example.*
+
+
+echo "[SPRINGBOOT-JUNIT]: Direct Jazzer execution of fuzz test using autofuzz should fail"
+set +e
+JAZZER_FUZZ=1 java -cp "target/*:${deps}" \
+  com.code_intelligence.jazzer.Jazzer \
+  --target_class=com.example.JunitSpringWebApplicationTests \
+  --target_method=fuzzTestWithDtoShouldFail \
+  --instrumentation_includes=com.example.* \
+  --custom_hook_includes=com.example.*
+declare -i exit_code=$?
+set -e
+
+if [ $exit_code -eq 77 ]
+then
+  echo "[SPRINGBOOT-JUNIT]: Expected failing fuzz tests: continuing"
+else
+  echo "[SPRINGBOOT-JUNIT]: Expected exit code 77, but got $exit_code"
+  exit 1
+fi
+
+echo "[SPRINGBOOT-JUNIT]: All tests passed"
diff --git a/examples/junit-spring-web/mvnw b/examples/junit-spring-web/mvnw
new file mode 100755
index 0000000..8a8fb22
--- /dev/null
+++ b/examples/junit-spring-web/mvnw
@@ -0,0 +1,316 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#    https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Maven Start Up Batch script
+#
+# Required ENV vars:
+# ------------------
+#   JAVA_HOME - location of a JDK home dir
+#
+# Optional ENV vars
+# -----------------
+#   M2_HOME - location of maven2's installed home dir
+#   MAVEN_OPTS - parameters passed to the Java VM when running Maven
+#     e.g. to debug Maven itself, use
+#       set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
+#   MAVEN_SKIP_RC - flag to disable loading of mavenrc files
+# ----------------------------------------------------------------------------
+
+if [ -z "$MAVEN_SKIP_RC" ] ; then
+
+  if [ -f /usr/local/etc/mavenrc ] ; then
+    . /usr/local/etc/mavenrc
+  fi
+
+  if [ -f /etc/mavenrc ] ; then
+    . /etc/mavenrc
+  fi
+
+  if [ -f "$HOME/.mavenrc" ] ; then
+    . "$HOME/.mavenrc"
+  fi
+
+fi
+
+# OS specific support.  $var _must_ be set to either true or false.
+cygwin=false;
+darwin=false;
+mingw=false
+case "`uname`" in
+  CYGWIN*) cygwin=true ;;
+  MINGW*) mingw=true;;
+  Darwin*) darwin=true
+    # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home
+    # See https://developer.apple.com/library/mac/qa/qa1170/_index.html
+    if [ -z "$JAVA_HOME" ]; then
+      if [ -x "/usr/libexec/java_home" ]; then
+        export JAVA_HOME="`/usr/libexec/java_home`"
+      else
+        export JAVA_HOME="/Library/Java/Home"
+      fi
+    fi
+    ;;
+esac
+
+if [ -z "$JAVA_HOME" ] ; then
+  if [ -r /etc/gentoo-release ] ; then
+    JAVA_HOME=`java-config --jre-home`
+  fi
+fi
+
+if [ -z "$M2_HOME" ] ; then
+  ## resolve links - $0 may be a link to maven's home
+  PRG="$0"
+
+  # need this for relative symlinks
+  while [ -h "$PRG" ] ; do
+    ls=`ls -ld "$PRG"`
+    link=`expr "$ls" : '.*-> \(.*\)$'`
+    if expr "$link" : '/.*' > /dev/null; then
+      PRG="$link"
+    else
+      PRG="`dirname "$PRG"`/$link"
+    fi
+  done
+
+  saveddir=`pwd`
+
+  M2_HOME=`dirname "$PRG"`/..
+
+  # make it fully qualified
+  M2_HOME=`cd "$M2_HOME" && pwd`
+
+  cd "$saveddir"
+  # echo Using m2 at $M2_HOME
+fi
+
+# For Cygwin, ensure paths are in UNIX format before anything is touched
+if $cygwin ; then
+  [ -n "$M2_HOME" ] &&
+    M2_HOME=`cygpath --unix "$M2_HOME"`
+  [ -n "$JAVA_HOME" ] &&
+    JAVA_HOME=`cygpath --unix "$JAVA_HOME"`
+  [ -n "$CLASSPATH" ] &&
+    CLASSPATH=`cygpath --path --unix "$CLASSPATH"`
+fi
+
+# For Mingw, ensure paths are in UNIX format before anything is touched
+if $mingw ; then
+  [ -n "$M2_HOME" ] &&
+    M2_HOME="`(cd "$M2_HOME"; pwd)`"
+  [ -n "$JAVA_HOME" ] &&
+    JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`"
+fi
+
+if [ -z "$JAVA_HOME" ]; then
+  javaExecutable="`which javac`"
+  if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then
+    # readlink(1) is not available as standard on Solaris 10.
+    readLink=`which readlink`
+    if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then
+      if $darwin ; then
+        javaHome="`dirname \"$javaExecutable\"`"
+        javaExecutable="`cd \"$javaHome\" && pwd -P`/javac"
+      else
+        javaExecutable="`readlink -f \"$javaExecutable\"`"
+      fi
+      javaHome="`dirname \"$javaExecutable\"`"
+      javaHome=`expr "$javaHome" : '\(.*\)/bin'`
+      JAVA_HOME="$javaHome"
+      export JAVA_HOME
+    fi
+  fi
+fi
+
+if [ -z "$JAVACMD" ] ; then
+  if [ -n "$JAVA_HOME"  ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+      # IBM's JDK on AIX uses strange locations for the executables
+      JAVACMD="$JAVA_HOME/jre/sh/java"
+    else
+      JAVACMD="$JAVA_HOME/bin/java"
+    fi
+  else
+    JAVACMD="`\\unset -f command; \\command -v java`"
+  fi
+fi
+
+if [ ! -x "$JAVACMD" ] ; then
+  echo "Error: JAVA_HOME is not defined correctly." >&2
+  echo "  We cannot execute $JAVACMD" >&2
+  exit 1
+fi
+
+if [ -z "$JAVA_HOME" ] ; then
+  echo "Warning: JAVA_HOME environment variable is not set."
+fi
+
+CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher
+
+# traverses directory structure from process work directory to filesystem root
+# first directory with .mvn subdirectory is considered project base directory
+find_maven_basedir() {
+
+  if [ -z "$1" ]
+  then
+    echo "Path not specified to find_maven_basedir"
+    return 1
+  fi
+
+  basedir="$1"
+  wdir="$1"
+  while [ "$wdir" != '/' ] ; do
+    if [ -d "$wdir"/.mvn ] ; then
+      basedir=$wdir
+      break
+    fi
+    # workaround for JBEAP-8937 (on Solaris 10/Sparc)
+    if [ -d "${wdir}" ]; then
+      wdir=`cd "$wdir/.."; pwd`
+    fi
+    # end of workaround
+  done
+  echo "${basedir}"
+}
+
+# concatenates all lines of a file
+concat_lines() {
+  if [ -f "$1" ]; then
+    echo "$(tr -s '\n' ' ' < "$1")"
+  fi
+}
+
+BASE_DIR=`find_maven_basedir "$(pwd)"`
+if [ -z "$BASE_DIR" ]; then
+  exit 1;
+fi
+
+##########################################################################################
+# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
+# This allows using the maven wrapper in projects that prohibit checking in binary data.
+##########################################################################################
+if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then
+    if [ "$MVNW_VERBOSE" = true ]; then
+      echo "Found .mvn/wrapper/maven-wrapper.jar"
+    fi
+else
+    if [ "$MVNW_VERBOSE" = true ]; then
+      echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..."
+    fi
+    if [ -n "$MVNW_REPOURL" ]; then
+      jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar"
+    else
+      jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar"
+    fi
+    while IFS="=" read key value; do
+      case "$key" in (wrapperUrl) jarUrl="$value"; break ;;
+      esac
+    done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties"
+    if [ "$MVNW_VERBOSE" = true ]; then
+      echo "Downloading from: $jarUrl"
+    fi
+    wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar"
+    if $cygwin; then
+      wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"`
+    fi
+
+    if command -v wget > /dev/null; then
+        if [ "$MVNW_VERBOSE" = true ]; then
+          echo "Found wget ... using wget"
+        fi
+        if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
+            wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
+        else
+            wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
+        fi
+    elif command -v curl > /dev/null; then
+        if [ "$MVNW_VERBOSE" = true ]; then
+          echo "Found curl ... using curl"
+        fi
+        if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
+            curl -o "$wrapperJarPath" "$jarUrl" -f
+        else
+            curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f
+        fi
+
+    else
+        if [ "$MVNW_VERBOSE" = true ]; then
+          echo "Falling back to using Java to download"
+        fi
+        javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java"
+        # For Cygwin, switch paths to Windows format before running javac
+        if $cygwin; then
+          javaClass=`cygpath --path --windows "$javaClass"`
+        fi
+        if [ -e "$javaClass" ]; then
+            if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then
+                if [ "$MVNW_VERBOSE" = true ]; then
+                  echo " - Compiling MavenWrapperDownloader.java ..."
+                fi
+                # Compiling the Java class
+                ("$JAVA_HOME/bin/javac" "$javaClass")
+            fi
+            if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then
+                # Running the downloader
+                if [ "$MVNW_VERBOSE" = true ]; then
+                  echo " - Running MavenWrapperDownloader.java ..."
+                fi
+                ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR")
+            fi
+        fi
+    fi
+fi
+##########################################################################################
+# End of extension
+##########################################################################################
+
+export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}
+if [ "$MVNW_VERBOSE" = true ]; then
+  echo $MAVEN_PROJECTBASEDIR
+fi
+MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS"
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin; then
+  [ -n "$M2_HOME" ] &&
+    M2_HOME=`cygpath --path --windows "$M2_HOME"`
+  [ -n "$JAVA_HOME" ] &&
+    JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"`
+  [ -n "$CLASSPATH" ] &&
+    CLASSPATH=`cygpath --path --windows "$CLASSPATH"`
+  [ -n "$MAVEN_PROJECTBASEDIR" ] &&
+    MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"`
+fi
+
+# Provide a "standardized" way to retrieve the CLI args that will
+# work with both Windows and non-Windows executions.
+MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@"
+export MAVEN_CMD_LINE_ARGS
+
+WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
+
+exec "$JAVACMD" \
+  $MAVEN_OPTS \
+  $MAVEN_DEBUG_OPTS \
+  -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \
+  "-Dmaven.home=${M2_HOME}" \
+  "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \
+  ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@"
diff --git a/examples/junit-spring-web/mvnw.cmd b/examples/junit-spring-web/mvnw.cmd
new file mode 100644
index 0000000..1d8ab01
--- /dev/null
+++ b/examples/junit-spring-web/mvnw.cmd
@@ -0,0 +1,188 @@
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements.  See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership.  The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License.  You may obtain a copy of the License at
+@REM
+@REM    https://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied.  See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Maven Start Up Batch script
+@REM
+@REM Required ENV vars:
+@REM JAVA_HOME - location of a JDK home dir
+@REM
+@REM Optional ENV vars
+@REM M2_HOME - location of maven2's installed home dir
+@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands
+@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending
+@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven
+@REM     e.g. to debug Maven itself, use
+@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
+@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files
+@REM ----------------------------------------------------------------------------
+
+@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
+@echo off
+@REM set title of command window
+title %0
+@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on'
+@if "%MAVEN_BATCH_ECHO%" == "on"  echo %MAVEN_BATCH_ECHO%
+
+@REM set %HOME% to equivalent of $HOME
+if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%")
+
+@REM Execute a user defined script before this one
+if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre
+@REM check for pre script, once with legacy .bat ending and once with .cmd ending
+if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %*
+if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %*
+:skipRcPre
+
+@setlocal
+
+set ERROR_CODE=0
+
+@REM To isolate internal variables from possible post scripts, we use another setlocal
+@setlocal
+
+@REM ==== START VALIDATION ====
+if not "%JAVA_HOME%" == "" goto OkJHome
+
+echo.
+echo Error: JAVA_HOME not found in your environment. >&2
+echo Please set the JAVA_HOME variable in your environment to match the >&2
+echo location of your Java installation. >&2
+echo.
+goto error
+
+:OkJHome
+if exist "%JAVA_HOME%\bin\java.exe" goto init
+
+echo.
+echo Error: JAVA_HOME is set to an invalid directory. >&2
+echo JAVA_HOME = "%JAVA_HOME%" >&2
+echo Please set the JAVA_HOME variable in your environment to match the >&2
+echo location of your Java installation. >&2
+echo.
+goto error
+
+@REM ==== END VALIDATION ====
+
+:init
+
+@REM Find the project base dir, i.e. the directory that contains the folder ".mvn".
+@REM Fallback to current working directory if not found.
+
+set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR%
+IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir
+
+set EXEC_DIR=%CD%
+set WDIR=%EXEC_DIR%
+:findBaseDir
+IF EXIST "%WDIR%"\.mvn goto baseDirFound
+cd ..
+IF "%WDIR%"=="%CD%" goto baseDirNotFound
+set WDIR=%CD%
+goto findBaseDir
+
+:baseDirFound
+set MAVEN_PROJECTBASEDIR=%WDIR%
+cd "%EXEC_DIR%"
+goto endDetectBaseDir
+
+:baseDirNotFound
+set MAVEN_PROJECTBASEDIR=%EXEC_DIR%
+cd "%EXEC_DIR%"
+
+:endDetectBaseDir
+
+IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig
+
+@setlocal EnableExtensions EnableDelayedExpansion
+for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a
+@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS%
+
+:endReadAdditionalConfig
+
+SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe"
+set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar"
+set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
+
+set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar"
+
+FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO (
+    IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B
+)
+
+@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
+@REM This allows using the maven wrapper in projects that prohibit checking in binary data.
+if exist %WRAPPER_JAR% (
+    if "%MVNW_VERBOSE%" == "true" (
+        echo Found %WRAPPER_JAR%
+    )
+) else (
+    if not "%MVNW_REPOURL%" == "" (
+        SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar"
+    )
+    if "%MVNW_VERBOSE%" == "true" (
+        echo Couldn't find %WRAPPER_JAR%, downloading it ...
+        echo Downloading from: %DOWNLOAD_URL%
+    )
+
+    powershell -Command "&{"^
+		"$webclient = new-object System.Net.WebClient;"^
+		"if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^
+		"$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^
+		"}"^
+		"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^
+		"}"
+    if "%MVNW_VERBOSE%" == "true" (
+        echo Finished downloading %WRAPPER_JAR%
+    )
+)
+@REM End of extension
+
+@REM Provide a "standardized" way to retrieve the CLI args that will
+@REM work with both Windows and non-Windows executions.
+set MAVEN_CMD_LINE_ARGS=%*
+
+%MAVEN_JAVA_EXE% ^
+  %JVM_CONFIG_MAVEN_PROPS% ^
+  %MAVEN_OPTS% ^
+  %MAVEN_DEBUG_OPTS% ^
+  -classpath %WRAPPER_JAR% ^
+  "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^
+  %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %*
+if ERRORLEVEL 1 goto error
+goto end
+
+:error
+set ERROR_CODE=1
+
+:end
+@endlocal & set ERROR_CODE=%ERROR_CODE%
+
+if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost
+@REM check for post script, once with legacy .bat ending and once with .cmd ending
+if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat"
+if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd"
+:skipRcPost
+
+@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on'
+if "%MAVEN_BATCH_PAUSE%"=="on" pause
+
+if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE%
+
+cmd /C exit /B %ERROR_CODE%
diff --git a/examples/junit-spring-web/pom.xml b/examples/junit-spring-web/pom.xml
new file mode 100644
index 0000000..b871452
--- /dev/null
+++ b/examples/junit-spring-web/pom.xml
@@ -0,0 +1,73 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Copyright 2023 Code Intelligence GmbH
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+	<modelVersion>4.0.0</modelVersion>
+	<parent>
+		<groupId>org.springframework.boot</groupId>
+		<artifactId>spring-boot-starter-parent</artifactId>
+		<version>3.0.5</version>
+		<relativePath/> <!-- lookup parent from repository -->
+	</parent>
+
+	<groupId>com.example</groupId>
+	<artifactId>junit-spring-web</artifactId>
+	<version>0.0.1-SNAPSHOT</version>
+	<name>junit-spring-web</name>
+	<description>Demo project for Spring Boot</description>
+	<properties>
+		<java.version>11</java.version>
+	</properties>
+	<dependencies>
+		<dependency>
+			<groupId>org.springframework.boot</groupId>
+			<artifactId>spring-boot-starter-web</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>org.springframework.boot</groupId>
+			<artifactId>spring-boot-starter-actuator</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>org.springframework.boot</groupId>
+			<artifactId>spring-boot-starter-test</artifactId>
+			<scope>test</scope>
+		</dependency>
+		<dependency>
+			<groupId>com.code-intelligence</groupId>
+			<artifactId>jazzer-junit</artifactId>
+			<version>0.16.1</version>
+			<scope>test</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.junit.jupiter</groupId>
+			<artifactId>junit-jupiter</artifactId>
+			<version>5.9.2</version>
+			<scope>test</scope>
+		</dependency>
+	</dependencies>
+
+	<build>
+		<plugins>
+			<plugin>
+				<groupId>org.springframework.boot</groupId>
+				<artifactId>spring-boot-maven-plugin</artifactId>
+			</plugin>
+		</plugins>
+	</build>
+
+</project>
diff --git a/examples/junit-spring-web/src/main/java/com/example/JunitSpringWebApplication.java b/examples/junit-spring-web/src/main/java/com/example/JunitSpringWebApplication.java
new file mode 100644
index 0000000..148c4d4
--- /dev/null
+++ b/examples/junit-spring-web/src/main/java/com/example/JunitSpringWebApplication.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@SpringBootApplication
+@RestController
+public class JunitSpringWebApplication {
+  public static final class HelloRequest {
+    public static final HelloRequest DEFAULT = new HelloRequest();
+
+    String prefix = "Hello ";
+    String name = "World";
+    String suffix = "!";
+
+    public String getPrefix() {
+      return prefix;
+    }
+
+    public void setPrefix(String prefix) {
+      this.prefix = prefix;
+    }
+
+    public String getName() {
+      return name;
+    }
+
+    public void setName(String name) {
+      this.name = name;
+    }
+
+    public String getSuffix() {
+      return suffix;
+    }
+
+    public void setSuffix(String suffix) {
+      this.suffix = suffix;
+    }
+  }
+
+  @GetMapping("/hello")
+  public String sayHello(@RequestParam(required = false, defaultValue = "World") String name) {
+    return "Hello " + name;
+  }
+
+  @GetMapping("/buggy-hello")
+  public String buggyHello(@RequestParam(required = false, defaultValue = "World") String name)
+      throws Error {
+    if (name.equals("error")) {
+      throw new Error("Error found!");
+    }
+    return "Hello " + name;
+  }
+
+  @PostMapping("/hello")
+  public String postHello(@RequestBody HelloRequest request) {
+    if ("error".equals(request.name)) {
+      throw new Error("Error found!");
+    }
+    return request.prefix + request.name + request.suffix;
+  }
+
+  public static void main(String[] args) {
+    SpringApplication.run(JunitSpringWebApplication.class, args);
+  }
+}
diff --git a/examples/junit-spring-web/src/main/resources/application.properties b/examples/junit-spring-web/src/main/resources/application.properties
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/examples/junit-spring-web/src/main/resources/application.properties
diff --git a/examples/junit-spring-web/src/test/java/com/example/JunitSpringWebApplicationTests.java b/examples/junit-spring-web/src/test/java/com/example/JunitSpringWebApplicationTests.java
new file mode 100644
index 0000000..2cf356f
--- /dev/null
+++ b/examples/junit-spring-web/src/test/java/com/example/JunitSpringWebApplicationTests.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+
+import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import com.code_intelligence.jazzer.junit.FuzzTest;
+import com.example.JunitSpringWebApplication.HelloRequest;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+
+@WebMvcTest
+public class JunitSpringWebApplicationTests {
+  private static final ObjectMapper mapper = new ObjectMapper();
+
+  @Autowired private MockMvc mockMvc;
+  private boolean beforeCalled = false;
+
+  @BeforeEach
+  public void beforeEach() {
+    beforeCalled = true;
+  }
+
+  @AfterEach
+  public void afterEach() {
+    beforeCalled = false;
+  }
+
+  @Test
+  public void unitTestShouldPass() throws Exception {
+    mockMvc.perform(get("/hello").param("name", "Maven"));
+  }
+
+  @Test
+  public void unitTestShouldFail() throws Exception {
+    mockMvc.perform(get("/buggy-hello").param("name", "error"));
+  }
+
+  @FuzzTest(maxDuration = "10s")
+  public void fuzzTestShouldPass(FuzzedDataProvider data) throws Exception {
+    if (!beforeCalled) {
+      throw new RuntimeException("BeforeEach was not called");
+    }
+
+    String name = data.consumeRemainingAsString();
+    mockMvc.perform(get("/hello").param("name", name));
+  }
+
+  @FuzzTest(maxDuration = "10s")
+  public void fuzzTestShouldFail(FuzzedDataProvider data) throws Exception {
+    if (!beforeCalled) {
+      throw new RuntimeException("BeforeEach was not called");
+    }
+
+    String name = data.consumeRemainingAsString();
+    mockMvc.perform(get("/buggy-hello").param("name", name))
+        .andExpect(content().string(containsString(name)));
+  }
+
+  @FuzzTest(maxDuration = "10s")
+  public void fuzzTestWithDtoShouldFail(HelloRequest helloRequest) throws Exception {
+    if (!beforeCalled) {
+      throw new RuntimeException("BeforeEach was not called");
+    }
+    Assumptions.assumeTrue(
+        helloRequest != null && helloRequest.name != null && !helloRequest.name.isBlank());
+
+    mockMvc
+        .perform(post("/hello")
+                     .contentType(MediaType.APPLICATION_JSON)
+                     .content(mapper.writeValueAsString(helloRequest)))
+        .andExpect(content().string(containsString(helloRequest.name)));
+  }
+}
diff --git a/examples/junit-spring-web/src/test/resources/application.properties b/examples/junit-spring-web/src/test/resources/application.properties
new file mode 100644
index 0000000..64c1967
--- /dev/null
+++ b/examples/junit-spring-web/src/test/resources/application.properties
@@ -0,0 +1 @@
+logging.level.org.springframework.web=INFO
diff --git a/examples/junit-spring-web/src/test/resources/com/example/JunitSpringWebApplicationTestsInputs/Test-001 b/examples/junit-spring-web/src/test/resources/com/example/JunitSpringWebApplicationTestsInputs/Test-001
new file mode 100644
index 0000000..760589c
--- /dev/null
+++ b/examples/junit-spring-web/src/test/resources/com/example/JunitSpringWebApplicationTestsInputs/Test-001
@@ -0,0 +1 @@
+error
\ No newline at end of file
diff --git a/examples/junit-spring-web/src/test/resources/com/example/JunitSpringWebApplicationTestsInputs/crash-11f9578d05e6f7bb58a3cdd00107e9f4e3882671 b/examples/junit-spring-web/src/test/resources/com/example/JunitSpringWebApplicationTestsInputs/crash-11f9578d05e6f7bb58a3cdd00107e9f4e3882671
new file mode 100644
index 0000000..760589c
--- /dev/null
+++ b/examples/junit-spring-web/src/test/resources/com/example/JunitSpringWebApplicationTestsInputs/crash-11f9578d05e6f7bb58a3cdd00107e9f4e3882671
@@ -0,0 +1 @@
+error
\ No newline at end of file
diff --git a/examples/junit-spring-web/src/test/resources/com/example/JunitSpringWebApplicationTestsInputs/crash-4acd17b34d3dafa673ab1f7ade3a8a29582a5730 b/examples/junit-spring-web/src/test/resources/com/example/JunitSpringWebApplicationTestsInputs/crash-4acd17b34d3dafa673ab1f7ade3a8a29582a5730
new file mode 100644
index 0000000..5800242
--- /dev/null
+++ b/examples/junit-spring-web/src/test/resources/com/example/JunitSpringWebApplicationTestsInputs/crash-4acd17b34d3dafa673ab1f7ade3a8a29582a5730
Binary files differ
diff --git a/examples/junit-spring-web/src/test/resources/com/example/JunitSpringWebApplicationTestsInputs/fuzzTestWithDtoShouldFail/Test-001 b/examples/junit-spring-web/src/test/resources/com/example/JunitSpringWebApplicationTestsInputs/fuzzTestWithDtoShouldFail/Test-001
new file mode 100644
index 0000000..760589c
--- /dev/null
+++ b/examples/junit-spring-web/src/test/resources/com/example/JunitSpringWebApplicationTestsInputs/fuzzTestWithDtoShouldFail/Test-001
@@ -0,0 +1 @@
+error
\ No newline at end of file
diff --git a/examples/junit-spring-web/src/test/resources/com/example/JunitSpringWebApplicationTestsInputs/fuzzTestWithDtoShouldFail/crash-11f9578d05e6f7bb58a3cdd00107e9f4e3882671 b/examples/junit-spring-web/src/test/resources/com/example/JunitSpringWebApplicationTestsInputs/fuzzTestWithDtoShouldFail/crash-11f9578d05e6f7bb58a3cdd00107e9f4e3882671
new file mode 100644
index 0000000..760589c
--- /dev/null
+++ b/examples/junit-spring-web/src/test/resources/com/example/JunitSpringWebApplicationTestsInputs/fuzzTestWithDtoShouldFail/crash-11f9578d05e6f7bb58a3cdd00107e9f4e3882671
@@ -0,0 +1 @@
+error
\ No newline at end of file
diff --git a/examples/junit-spring-web/src/test/resources/com/example/JunitSpringWebApplicationTestsInputs/fuzzTestWithDtoShouldFail/crash-4acd17b34d3dafa673ab1f7ade3a8a29582a5730 b/examples/junit-spring-web/src/test/resources/com/example/JunitSpringWebApplicationTestsInputs/fuzzTestWithDtoShouldFail/crash-4acd17b34d3dafa673ab1f7ade3a8a29582a5730
new file mode 100644
index 0000000..5800242
--- /dev/null
+++ b/examples/junit-spring-web/src/test/resources/com/example/JunitSpringWebApplicationTestsInputs/fuzzTestWithDtoShouldFail/crash-4acd17b34d3dafa673ab1f7ade3a8a29582a5730
Binary files differ
diff --git a/examples/junit-spring-web/src/test/resources/junit-platform.properties b/examples/junit-spring-web/src/test/resources/junit-platform.properties
new file mode 100644
index 0000000..0229061
--- /dev/null
+++ b/examples/junit-spring-web/src/test/resources/junit-platform.properties
@@ -0,0 +1 @@
+jazzer.instrument=com.example.**,com.other.package.**
diff --git a/examples/junit/.gitignore b/examples/junit/.gitignore
new file mode 100644
index 0000000..f419dc7
--- /dev/null
+++ b/examples/junit/.gitignore
@@ -0,0 +1,2 @@
+/.idea
+/target
diff --git a/examples/junit/pom.xml b/examples/junit/pom.xml
new file mode 100644
index 0000000..cd3bae9
--- /dev/null
+++ b/examples/junit/pom.xml
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Copyright 2022 Code Intelligence GmbH
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <groupId>org.example</groupId>
+    <artifactId>jazzer-junit-example</artifactId>
+    <version>1.0-SNAPSHOT</version>
+
+    <properties>
+        <maven.compiler.source>11</maven.compiler.source>
+        <maven.compiler.target>11</maven.compiler.target>
+    </properties>
+    <dependencies>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter</artifactId>
+            <version>5.9.2</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>com.code-intelligence</groupId>
+            <artifactId>jazzer-junit</artifactId>
+            <version>0.16.1</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>5.2.0</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <version>2.22.2</version>
+            </plugin>
+        </plugins>
+        <testResources>
+            <testResource>
+                <directory>${project.basedir}/src/test/resources</directory>
+            </testResource>
+        </testResources>
+    </build>
+
+</project>
diff --git a/examples/junit/src/main/java/com/example/BUILD.bazel b/examples/junit/src/main/java/com/example/BUILD.bazel
new file mode 100644
index 0000000..6965459
--- /dev/null
+++ b/examples/junit/src/main/java/com/example/BUILD.bazel
@@ -0,0 +1,5 @@
+java_library(
+    name = "parser",
+    srcs = ["Parser.java"],
+    visibility = ["//examples/junit/src/test/java/com/example:__pkg__"],
+)
diff --git a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h b/examples/junit/src/main/java/com/example/Parser.java
similarity index 67%
copy from driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
copy to examples/junit/src/main/java/com/example/Parser.java
index 0e8846c..966a0d3 100644
--- a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
+++ b/examples/junit/src/main/java/com/example/Parser.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2021 Code Intelligence GmbH
+ * Copyright 2022 Code Intelligence GmbH
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,15 +14,12 @@
  * limitations under the License.
  */
 
-#pragma once
+package com.example;
 
-#include <jni.h>
-
-namespace jazzer {
-/*
- * Print the stack traces of all active JVM threads.
- *
- * This function can be called from any thread.
- */
-void DumpJvmStackTraces();
-}  // namespace jazzer
+public class Parser {
+  public static void parse(byte[] data) {
+    if (data[4] == 'c' && new String(data).startsWith("aaaaaa")) {
+      throw new IllegalStateException("Not reached");
+    }
+  }
+}
diff --git a/examples/junit/src/test/java/com/example/AutofuzzFuzzTest.java b/examples/junit/src/test/java/com/example/AutofuzzFuzzTest.java
new file mode 100644
index 0000000..4f316c3
--- /dev/null
+++ b/examples/junit/src/test/java/com/example/AutofuzzFuzzTest.java
@@ -0,0 +1,41 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.example;
+
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import com.code_intelligence.jazzer.junit.FuzzTest;
+
+class AutofuzzFuzzTest {
+  private static class IntHolder {
+    private final int i;
+
+    IntHolder(int i) {
+      this.i = i;
+    }
+
+    public int getI() {
+      return i;
+    }
+  }
+
+  @FuzzTest(maxDuration = "5m")
+  void autofuzz(String str, IntHolder holder) {
+    assumeTrue(holder != null);
+    if (holder.getI() == 1234 && str != null && str.contains("jazzer")) {
+      throw new RuntimeException();
+    }
+  }
+}
diff --git a/examples/junit/src/test/java/com/example/AutofuzzLifecycleFuzzTest.java b/examples/junit/src/test/java/com/example/AutofuzzLifecycleFuzzTest.java
new file mode 100644
index 0000000..b82f1ab
--- /dev/null
+++ b/examples/junit/src/test/java/com/example/AutofuzzLifecycleFuzzTest.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example;
+
+import com.code_intelligence.jazzer.junit.FuzzTest;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.TestInstancePostProcessor;
+
+@TestMethodOrder(MethodOrderer.MethodName.class)
+@ExtendWith(AutofuzzLifecycleFuzzTest.AutofuzzLifecycleInstancePostProcessor.class)
+class AutofuzzLifecycleFuzzTest {
+  // Use a TestInstancePostProcessor to inject an object into the JUnit test instance,
+  // simulating other JUnit extensions like the Spring Boot Test, to check that autofuzz
+  // invokes the test function on the correct instance.
+  private Object injectedObject;
+
+  @FuzzTest(maxDuration = "1s")
+  void autofuzzLifecycleFuzz(String ignored, String ignoredAsWell) {
+    Assertions.assertNotNull(injectedObject);
+  }
+
+  static class AutofuzzLifecycleInstancePostProcessor implements TestInstancePostProcessor {
+    @Override
+    public void postProcessTestInstance(Object o, ExtensionContext extensionContext) {
+      ((AutofuzzLifecycleFuzzTest) o).injectedObject = new Object();
+    }
+  }
+}
diff --git a/driver/test_main.cpp b/examples/junit/src/test/java/com/example/AutofuzzWithCorpusFuzzTest.java
similarity index 64%
copy from driver/test_main.cpp
copy to examples/junit/src/test/java/com/example/AutofuzzWithCorpusFuzzTest.java
index 14340b8..a533703 100644
--- a/driver/test_main.cpp
+++ b/examples/junit/src/test/java/com/example/AutofuzzWithCorpusFuzzTest.java
@@ -1,4 +1,4 @@
-// Copyright 2021 Code Intelligence GmbH
+// Copyright 2022 Code Intelligence GmbH
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,14 +12,15 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-#include <rules_jni.h>
+package com.example;
 
-#include "gflags/gflags.h"
-#include "gtest/gtest.h"
+import com.code_intelligence.jazzer.junit.FuzzTest;
 
-int main(int argc, char **argv) {
-  rules_jni_init(argv[0]);
-  ::testing::InitGoogleTest(&argc, argv);
-  gflags::ParseCommandLineFlags(&argc, &argv, true);
-  return RUN_ALL_TESTS();
+class AutofuzzWithCorpusFuzzTest {
+  @FuzzTest
+  void autofuzzWithCorpus(String str, int i) {
+    if ("jazzer".equals(str) && i == 1234) {
+      throw new RuntimeException();
+    }
+  }
 }
diff --git a/examples/junit/src/test/java/com/example/BUILD.bazel b/examples/junit/src/test/java/com/example/BUILD.bazel
new file mode 100644
index 0000000..8bbdcec
--- /dev/null
+++ b/examples/junit/src/test/java/com/example/BUILD.bazel
@@ -0,0 +1,232 @@
+load("//bazel:fuzz_target.bzl", "java_fuzz_target_test")
+
+java_binary(
+    name = "ExampleFuzzTests",
+    testonly = True,
+    srcs = glob(["*.java"]),
+    create_executable = False,
+    visibility = [
+        "//src/test/java/com/code_intelligence/jazzer/junit:__pkg__",
+    ],
+    deps = [
+        "//deploy:jazzer",
+        "//deploy:jazzer-api",
+        "//deploy:jazzer-junit",
+        "//examples/junit/src/main/java/com/example:parser",
+        "//examples/junit/src/test/resources:example_seed_corpora",
+        "@maven//:org_junit_jupiter_junit_jupiter_api",
+        "@maven//:org_junit_jupiter_junit_jupiter_params",
+        "@maven//:org_mockito_mockito_core",
+    ],
+)
+
+java_fuzz_target_test(
+    name = "DataFuzzTest",
+    srcs = ["ValidFuzzTests.java"],
+    allowed_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium"],
+    fuzzer_args = [
+        "-runs=0",
+    ],
+    target_class = "com.example.ValidFuzzTests",
+    target_method = "dataFuzz",
+    verify_crash_reproducer = False,
+    runtime_deps = [
+        ":junit_runtime",
+    ],
+    deps = [
+        "//examples/junit/src/main/java/com/example:parser",
+        "//src/main/java/com/code_intelligence/jazzer/junit:fuzz_test",
+        "@maven//:org_junit_jupiter_junit_jupiter_api",
+    ],
+)
+
+java_fuzz_target_test(
+    name = "ByteFuzzTest",
+    srcs = ["ByteFuzzTest.java"],
+    allowed_findings = ["org.opentest4j.AssertionFailedError"],
+    fuzzer_args = [
+        "-runs=0",
+    ],
+    target_class = "com.example.ByteFuzzTest",
+    target_method = "byteFuzz",
+    verify_crash_reproducer = False,
+    runtime_deps = [
+        ":junit_runtime",
+    ],
+    deps = [
+        "//examples/junit/src/main/java/com/example:parser",
+        "//src/main/java/com/code_intelligence/jazzer/junit:fuzz_test",
+        "@maven//:org_junit_jupiter_junit_jupiter_api",
+    ],
+)
+
+java_fuzz_target_test(
+    name = "LifecycleFuzzTest",
+    srcs = ["LifecycleFuzzTest.java"],
+    allowed_findings = ["java.io.IOException"],
+    fuzzer_args = [
+        "-runs=0",
+    ],
+    target_class = "com.example.LifecycleFuzzTest",
+    verify_crash_reproducer = False,
+    runtime_deps = [
+        ":junit_runtime",
+    ],
+    deps = [
+        "//examples/junit/src/main/java/com/example:parser",
+        "//src/main/java/com/code_intelligence/jazzer/junit:fuzz_test",
+        "@maven//:org_junit_jupiter_junit_jupiter_api",
+    ],
+)
+
+java_fuzz_target_test(
+    name = "KeepGoingFuzzTest",
+    srcs = ["KeepGoingFuzzTest.java"],
+    allowed_findings = ["java.lang.IllegalArgumentException"],
+    expect_crash = False,
+    fuzzer_args = [
+        "--keep_going=3",
+        "-runs=10",
+    ],
+    target_class = "com.example.KeepGoingFuzzTest",
+    runtime_deps = [
+        ":junit_runtime",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/junit:fuzz_test",
+        "@maven//:org_junit_jupiter_junit_jupiter_api",
+    ],
+)
+
+# Verifies that fuzzer command-line arguments are honored for @FuzzTests.
+java_fuzz_target_test(
+    name = "CommandLineFuzzTest",
+    srcs = ["CommandLineFuzzTest.java"],
+    allowed_findings = ["java.lang.Error"],
+    fuzzer_args = [
+        # Ignore the first two findings.
+        "--ignore=d5e250a5298b81e6,d86371e6d41739ec",
+    ],
+    target_class = "com.example.CommandLineFuzzTest",
+    verify_crash_reproducer = False,
+    runtime_deps = [
+        ":junit_runtime",
+    ],
+    deps = [
+        "//examples/junit/src/main/java/com/example:parser",
+        "//src/main/java/com/code_intelligence/jazzer/junit:fuzz_test",
+        "@maven//:org_junit_jupiter_junit_jupiter_api",
+    ],
+)
+
+# Verify that Mockito is properly ignored.
+# Using version 5+ could otherwise introduce cyclic instrumentation.
+java_fuzz_target_test(
+    name = "MockitoFuzzTest",
+    srcs = ["MockitoFuzzTest.java"],
+    fuzzer_args = [
+        "-runs=1",
+    ],
+    tags = ["no-jdk8"],
+    target_class = "com.example.MockitoFuzzTest",
+    verify_crash_reproducer = False,
+    runtime_deps = [
+        ":junit_runtime",
+    ],
+    deps = [
+        "//examples/junit/src/main/java/com/example:parser",
+        "//src/main/java/com/code_intelligence/jazzer/junit:fuzz_test",
+        "@maven//:org_junit_jupiter_junit_jupiter_api",
+        "@maven//:org_mockito_mockito_core",
+    ],
+)
+
+java_fuzz_target_test(
+    name = "AutofuzzLifecycleFuzzTest",
+    srcs = ["AutofuzzLifecycleFuzzTest.java"],
+    fuzzer_args = [
+        "-runs=0",
+    ],
+    target_class = "com.example.AutofuzzLifecycleFuzzTest",
+    verify_crash_reproducer = False,
+    runtime_deps = [
+        ":junit_runtime",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/junit:fuzz_test",
+        "@maven//:org_junit_jupiter_junit_jupiter_api",
+    ],
+)
+
+java_fuzz_target_test(
+    name = "MutatorFuzzTest",
+    srcs = ["MutatorFuzzTest.java"],
+    allowed_findings = ["java.lang.AssertionError"],
+    data = [
+        "//examples/junit/src/test/resources:MutatorFuzzTestInputs",
+    ],
+    env = {
+        "JAZZER_FUZZ": "1",
+    },
+    fuzzer_args = [
+        "--experimental_mutator",
+        "$(rlocationpaths //examples/junit/src/test/resources:MutatorFuzzTestInputs)",
+    ],
+    target_class = "com.example.MutatorFuzzTest",
+    verify_crash_reproducer = False,
+    runtime_deps = [
+        ":junit_runtime",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/driver:fuzz_target_runner",
+        "//src/main/java/com/code_intelligence/jazzer/junit:fuzz_test",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/annotation",
+        "@maven//:org_junit_jupiter_junit_jupiter_api",
+    ],
+)
+
+java_fuzz_target_test(
+    name = "JavaSeedFuzzTest",
+    srcs = ["JavaSeedFuzzTest.java"],
+    allowed_findings = ["java.lang.Error"],
+    env = {"JAZZER_FUZZ": "1"},
+    fuzzer_args = [
+        "--experimental_mutator",
+    ],
+    target_class = "com.example.JavaSeedFuzzTest",
+    verify_crash_reproducer = False,
+    runtime_deps = [
+        ":junit_runtime",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/junit:fuzz_test",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/annotation",
+        "@maven//:org_junit_jupiter_junit_jupiter_api",
+        "@maven//:org_junit_jupiter_junit_jupiter_params",
+    ],
+)
+
+java_fuzz_target_test(
+    name = "JavaBinarySeedFuzzTest",
+    srcs = ["JavaBinarySeedFuzzTest.java"],
+    allowed_findings = ["java.lang.Error"],
+    env = {"JAZZER_FUZZ": "1"},
+    target_class = "com.example.JavaBinarySeedFuzzTest",
+    verify_crash_reproducer = False,
+    runtime_deps = [
+        ":junit_runtime",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/junit:fuzz_test",
+        "@maven//:org_junit_jupiter_junit_jupiter_api",
+        "@maven//:org_junit_jupiter_junit_jupiter_params",
+    ],
+)
+
+java_library(
+    name = "junit_runtime",
+    runtime_deps = [
+        "@maven//:org_junit_jupiter_junit_jupiter_engine",
+        "@maven//:org_junit_platform_junit_platform_launcher",
+    ],
+)
diff --git a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h b/examples/junit/src/test/java/com/example/ByteFuzzTest.java
similarity index 62%
copy from driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
copy to examples/junit/src/test/java/com/example/ByteFuzzTest.java
index 0e8846c..506ef89 100644
--- a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
+++ b/examples/junit/src/test/java/com/example/ByteFuzzTest.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2021 Code Intelligence GmbH
+ * Copyright 2022 Code Intelligence GmbH
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,15 +14,20 @@
  * limitations under the License.
  */
 
-#pragma once
+package com.example;
 
-#include <jni.h>
+import static org.junit.jupiter.api.Assertions.fail;
 
-namespace jazzer {
-/*
- * Print the stack traces of all active JVM threads.
- *
- * This function can be called from any thread.
- */
-void DumpJvmStackTraces();
-}  // namespace jazzer
+import com.code_intelligence.jazzer.junit.FuzzTest;
+
+class ByteFuzzTest {
+  @FuzzTest
+  void byteFuzz(byte[] data) {
+    if (data.length < 1) {
+      return;
+    }
+    if (data[0] % 2 == 0) {
+      fail();
+    }
+  }
+}
diff --git a/examples/junit/src/test/java/com/example/CommandLineFuzzTest.java b/examples/junit/src/test/java/com/example/CommandLineFuzzTest.java
new file mode 100644
index 0000000..e79d463
--- /dev/null
+++ b/examples/junit/src/test/java/com/example/CommandLineFuzzTest.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example;
+
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import com.code_intelligence.jazzer.junit.FuzzTest;
+
+class CommandLineFuzzTest {
+  int run = 0;
+
+  @FuzzTest
+  void commandLineFuzz(byte[] bytes) {
+    assumeTrue(bytes.length > 0);
+    switch (run++) {
+      case 0:
+        throw new RuntimeException();
+      case 1:
+        throw new IllegalStateException();
+      case 2:
+        throw new Error();
+    }
+  }
+}
diff --git a/examples/junit/src/test/java/com/example/CorpusDirectoryFuzzTest.java b/examples/junit/src/test/java/com/example/CorpusDirectoryFuzzTest.java
new file mode 100644
index 0000000..465c94c
--- /dev/null
+++ b/examples/junit/src/test/java/com/example/CorpusDirectoryFuzzTest.java
@@ -0,0 +1,45 @@
+// Copyright 2023 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.example;
+
+import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium;
+import com.code_intelligence.jazzer.junit.FuzzTest;
+
+public class CorpusDirectoryFuzzTest {
+  private static int invocations = 0;
+
+  @FuzzTest(maxDuration = "5s")
+  public void corpusDirectoryFuzz(FuzzedDataProvider data) {
+    // Throw on the third invocation to generate corpus entries.
+    if (data.remainingBytes() == 0) {
+      return;
+    }
+    // Add a few branch statements to generate different coverage.
+    switch (invocations) {
+      case 0:
+        invocations++;
+        break;
+      case 1:
+        invocations++;
+        break;
+      case 2:
+        invocations++;
+        break;
+      case 3:
+        throw new FuzzerSecurityIssueMedium();
+    }
+  }
+}
diff --git a/examples/junit/src/test/java/com/example/DirectoryInputsFuzzTest.java b/examples/junit/src/test/java/com/example/DirectoryInputsFuzzTest.java
new file mode 100644
index 0000000..1d1ce2c
--- /dev/null
+++ b/examples/junit/src/test/java/com/example/DirectoryInputsFuzzTest.java
@@ -0,0 +1,39 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.example;
+
+import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium;
+import com.code_intelligence.jazzer.junit.FuzzTest;
+
+public class DirectoryInputsFuzzTest {
+  private static boolean firstSeed = true;
+
+  @FuzzTest(maxDuration = "0s")
+  public void inputsFuzz(FuzzedDataProvider data) {
+    // Only execute the fuzz test logic on the empty input and the only seed.
+    if (data.remainingBytes() == 0) {
+      return;
+    }
+    String input = data.consumeRemainingAsString();
+    if (!firstSeed && !input.equals("directory")) {
+      throw new IllegalStateException("Should have crashed on the first non-empty input");
+    }
+    firstSeed = false;
+    if (input.equals("directory")) {
+      throw new FuzzerSecurityIssueMedium();
+    }
+  }
+}
diff --git a/examples/junit/src/test/java/com/example/HermeticInstrumentationFuzzTest.java b/examples/junit/src/test/java/com/example/HermeticInstrumentationFuzzTest.java
new file mode 100644
index 0000000..97e0381
--- /dev/null
+++ b/examples/junit/src/test/java/com/example/HermeticInstrumentationFuzzTest.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2022 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example;
+
+import com.code_intelligence.jazzer.junit.FuzzTest;
+import java.util.regex.Pattern;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Execution;
+import org.junit.jupiter.api.parallel.ExecutionMode;
+
+@SuppressWarnings("InvalidPatternSyntax")
+@Execution(ExecutionMode.CONCURRENT)
+class HermeticInstrumentationFuzzTest {
+  class VulnerableFuzzClass {
+    public void vulnerableMethod(String input) {
+      Pattern.compile(input);
+    }
+  }
+
+  class VulnerableUnitClass {
+    public void vulnerableMethod(String input) {
+      Pattern.compile(input);
+    }
+  }
+
+  @FuzzTest
+  @Execution(ExecutionMode.CONCURRENT)
+  void fuzzTest1(byte[] data) {
+    new VulnerableFuzzClass().vulnerableMethod("[");
+  }
+
+  @Test
+  @Execution(ExecutionMode.CONCURRENT)
+  void unitTest1() {
+    new VulnerableUnitClass().vulnerableMethod("[");
+  }
+
+  @FuzzTest
+  @Execution(ExecutionMode.CONCURRENT)
+  void fuzzTest2(byte[] data) {
+    Pattern.compile("[");
+  }
+
+  @Test
+  @Execution(ExecutionMode.CONCURRENT)
+  void unitTest2() {
+    Pattern.compile("[");
+  }
+}
diff --git a/driver/test_main.cpp b/examples/junit/src/test/java/com/example/InvalidFuzzTests.java
similarity index 64%
copy from driver/test_main.cpp
copy to examples/junit/src/test/java/com/example/InvalidFuzzTests.java
index 14340b8..acaecb8 100644
--- a/driver/test_main.cpp
+++ b/examples/junit/src/test/java/com/example/InvalidFuzzTests.java
@@ -1,4 +1,4 @@
-// Copyright 2021 Code Intelligence GmbH
+// Copyright 2022 Code Intelligence GmbH
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,14 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-#include <rules_jni.h>
+package com.example;
 
-#include "gflags/gflags.h"
-#include "gtest/gtest.h"
+import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import com.code_intelligence.jazzer.junit.FuzzTest;
 
-int main(int argc, char **argv) {
-  rules_jni_init(argv[0]);
-  ::testing::InitGoogleTest(&argc, argv);
-  gflags::ParseCommandLineFlags(&argc, &argv, true);
-  return RUN_ALL_TESTS();
+class InvalidFuzzTests {
+  @FuzzTest
+  void invalidParameterCountFuzz() {}
 }
diff --git a/examples/junit/src/test/java/com/example/JavaBinarySeedFuzzTest.java b/examples/junit/src/test/java/com/example/JavaBinarySeedFuzzTest.java
new file mode 100644
index 0000000..70b3535
--- /dev/null
+++ b/examples/junit/src/test/java/com/example/JavaBinarySeedFuzzTest.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import com.code_intelligence.jazzer.junit.FuzzTest;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+import org.junit.jupiter.params.converter.ArgumentConversionException;
+import org.junit.jupiter.params.converter.ConvertWith;
+import org.junit.jupiter.params.converter.SimpleArgumentConverter;
+import org.junit.jupiter.params.provider.ValueSource;
+
+class JavaBinarySeedFuzzTest {
+  // Generated via:
+  // printf 'tH15_1S-4_53Cr3T.fl4G' | openssl dgst -binary -sha256 | openssl base64 -A
+  // Luckily the fuzzer can't read comments ;-)
+  private static final byte[] FLAG_SHA256 =
+      Base64.getDecoder().decode("q0vPdz5oeJIW3k2U4VJ+aWDufzzZbKAcevc9cNoUTSM=");
+
+  static class Utf8BytesConverter extends SimpleArgumentConverter {
+    @Override
+    protected Object convert(Object source, Class<?> targetType)
+        throws ArgumentConversionException {
+      assertEquals(byte[].class, targetType);
+      assertTrue(source instanceof byte[] || source instanceof String);
+      if (source instanceof byte[]) {
+        return source;
+      }
+      return ((String) source).getBytes(UTF_8);
+    }
+  }
+
+  @ValueSource(strings = {"red herring", "tH15_1S-4_53Cr3T.fl4Ga"})
+  @FuzzTest
+  void fuzzTheFlag(@ConvertWith(Utf8BytesConverter.class) byte[] bytes)
+      throws NoSuchAlgorithmException {
+    assumeTrue(bytes.length > 0);
+    MessageDigest digest = MessageDigest.getInstance("SHA-256");
+    digest.update(bytes, 0, bytes.length - 1);
+    byte[] hash = digest.digest();
+    byte secret = bytes[bytes.length - 1];
+    if (MessageDigest.isEqual(hash, FLAG_SHA256) && secret == 's') {
+      throw new Error("Fl4g 4nd s3cr3et f0und!");
+    }
+  }
+}
diff --git a/examples/junit/src/test/java/com/example/JavaSeedFuzzTest.java b/examples/junit/src/test/java/com/example/JavaSeedFuzzTest.java
new file mode 100644
index 0000000..4f63e9a
--- /dev/null
+++ b/examples/junit/src/test/java/com/example/JavaSeedFuzzTest.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example;
+
+import static java.util.Arrays.asList;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+import com.code_intelligence.jazzer.junit.FuzzTest;
+import com.code_intelligence.jazzer.mutation.annotation.NotNull;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+import java.util.List;
+import java.util.stream.Stream;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+class JavaSeedFuzzTest {
+  // Generated via:
+  // printf 'tH15_1S-4_53Cr3T.fl4G' | openssl dgst -binary -sha256 | openssl base64 -A
+  // Luckily the fuzzer can't read comments ;-)
+  private static final byte[] FLAG_SHA256 =
+      Base64.getDecoder().decode("q0vPdz5oeJIW3k2U4VJ+aWDufzzZbKAcevc9cNoUTSM=");
+
+  static Stream<Arguments> fuzzTheFlag() {
+    return Stream.of(arguments(asList("red", "herring"), 0),
+        // This argument passes the hash check, but does not trigger the finding right away. This
+        // is meant to verify that the seed ends up in the corpus, serving as the base for future
+        // mutations rather than just being executed once.
+        arguments(asList("tH15_1S", "-4_53Cr3T", ".fl4G"), 42));
+  }
+
+  @MethodSource
+  @FuzzTest
+  void fuzzTheFlag(@NotNull List<@NotNull String> flagParts, int secret)
+      throws NoSuchAlgorithmException {
+    byte[] hash = MessageDigest.getInstance("SHA-256").digest(
+        String.join("", flagParts).getBytes(StandardCharsets.UTF_8));
+    if (MessageDigest.isEqual(hash, FLAG_SHA256) && secret == 1337) {
+      throw new Error("Fl4g 4nd s3cr3et f0und!");
+    }
+  }
+}
diff --git a/examples/junit/src/test/java/com/example/KeepGoingFuzzTest.java b/examples/junit/src/test/java/com/example/KeepGoingFuzzTest.java
new file mode 100644
index 0000000..ad5d09d
--- /dev/null
+++ b/examples/junit/src/test/java/com/example/KeepGoingFuzzTest.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example;
+
+import com.code_intelligence.jazzer.junit.FuzzTest;
+
+public class KeepGoingFuzzTest {
+  private static int counter = 0;
+
+  @FuzzTest
+  public void keepGoingFuzzTest(byte[] ignored) {
+    counter++;
+    if (counter == 1) {
+      throw new IllegalArgumentException("error1");
+    }
+    if (counter == 2) {
+      throw new IllegalArgumentException("error2");
+    }
+  }
+}
diff --git a/examples/junit/src/test/java/com/example/LifecycleFuzzTest.java b/examples/junit/src/test/java/com/example/LifecycleFuzzTest.java
new file mode 100644
index 0000000..0d5dc2c
--- /dev/null
+++ b/examples/junit/src/test/java/com/example/LifecycleFuzzTest.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2022 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example;
+
+import com.code_intelligence.jazzer.junit.FuzzTest;
+import java.io.IOException;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.TestInstancePostProcessor;
+
+@TestMethodOrder(MethodOrderer.MethodName.class)
+@ExtendWith(LifecycleFuzzTest.LifecycleInstancePostProcessor.class)
+class LifecycleFuzzTest {
+  // In fuzzing mode, the test is invoked once on the empty input and once with Jazzer.
+  private static final int EXPECTED_EACH_COUNT =
+      System.getenv().getOrDefault("JAZZER_FUZZ", "").isEmpty() ? 1 : 2;
+
+  private static int beforeAllCount = 0;
+  private static int beforeEachGlobalCount = 0;
+  private static int afterEachGlobalCount = 0;
+  private static int afterAllCount = 0;
+
+  private boolean beforeEachCalledOnInstance = false;
+  private boolean testInstancePostProcessorCalledOnInstance = false;
+
+  @BeforeAll
+  static void beforeAll() {
+    beforeAllCount++;
+  }
+
+  @BeforeEach
+  void beforeEach() {
+    beforeEachGlobalCount++;
+    beforeEachCalledOnInstance = true;
+  }
+
+  @Disabled
+  @FuzzTest
+  void disabledFuzz(byte[] data) {
+    throw new AssertionError("This test should not be executed");
+  }
+
+  @FuzzTest(maxDuration = "1s")
+  void lifecycleFuzz(byte[] data) {
+    Assertions.assertEquals(1, beforeAllCount);
+    Assertions.assertEquals(beforeEachGlobalCount, afterEachGlobalCount + 1);
+    Assertions.assertTrue(beforeEachCalledOnInstance);
+    Assertions.assertTrue(testInstancePostProcessorCalledOnInstance);
+  }
+
+  @AfterEach
+  void afterEach() {
+    afterEachGlobalCount++;
+  }
+
+  @AfterAll
+  static void afterAll() throws IOException {
+    afterAllCount++;
+    Assertions.assertEquals(1, beforeAllCount);
+    Assertions.assertEquals(EXPECTED_EACH_COUNT, beforeEachGlobalCount);
+    Assertions.assertEquals(EXPECTED_EACH_COUNT, afterEachGlobalCount);
+    Assertions.assertEquals(1, afterAllCount);
+    throw new IOException();
+  }
+
+  static class LifecycleInstancePostProcessor implements TestInstancePostProcessor {
+    @Override
+    public void postProcessTestInstance(Object o, ExtensionContext extensionContext) {
+      ((LifecycleFuzzTest) o).testInstancePostProcessorCalledOnInstance = true;
+    }
+  }
+}
diff --git a/examples/junit/src/test/java/com/example/MockitoFuzzTest.java b/examples/junit/src/test/java/com/example/MockitoFuzzTest.java
new file mode 100644
index 0000000..c3c2e97
--- /dev/null
+++ b/examples/junit/src/test/java/com/example/MockitoFuzzTest.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example;
+
+import com.code_intelligence.jazzer.junit.FuzzTest;
+import org.mockito.Mockito;
+
+public class MockitoFuzzTest {
+  public static class Foo {
+    public String bar(String ignored) {
+      return "bar";
+    }
+  }
+
+  @FuzzTest
+  void fuzzWithMockito(byte[] bytes) {
+    // Mock the Foo class to trigger an instrumentation cycle,
+    // if not properly ignored.
+    Foo foo = Mockito.mock(Foo.class);
+    foo.bar(new String(bytes));
+  }
+}
diff --git a/examples/junit/src/test/java/com/example/MutatorFuzzTest.java b/examples/junit/src/test/java/com/example/MutatorFuzzTest.java
new file mode 100644
index 0000000..f364479
--- /dev/null
+++ b/examples/junit/src/test/java/com/example/MutatorFuzzTest.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.code_intelligence.jazzer.driver.FuzzTargetRunner;
+import com.code_intelligence.jazzer.junit.FuzzTest;
+import com.code_intelligence.jazzer.mutation.annotation.NotNull;
+import java.util.List;
+import org.junit.jupiter.api.AfterAll;
+
+class MutatorFuzzTest {
+  @FuzzTest
+  void mutatorFuzz(List<@NotNull String> list) {
+    // Check that the mutator is actually doing something.
+    if (list != null && list.size() > 3 && list.get(2).equals("mutator")) {
+      throw new AssertionError("Found expected JUnit mutator test issue");
+    }
+  }
+
+  @AfterAll
+  static void assertFuzzTargetRunner() {
+    // FuzzTargetRunner values are not set in JUnit engine tests.
+    String jazzerFuzz = System.getenv("JAZZER_FUZZ");
+    if (jazzerFuzz != null && !jazzerFuzz.isEmpty()) {
+      assertTrue(FuzzTargetRunner.invalidCorpusFilesPresent());
+      assertEquals(FuzzTargetRunner.mutatorDebugString(), "Arguments[Nullable<List<String>>]");
+    }
+  }
+}
diff --git a/agent/src/main/java/jaz/Ter.java b/examples/junit/src/test/java/com/example/ThrowingFuzzTest.java
similarity index 62%
copy from agent/src/main/java/jaz/Ter.java
copy to examples/junit/src/test/java/com/example/ThrowingFuzzTest.java
index 7814396..eabfb85 100644
--- a/agent/src/main/java/jaz/Ter.java
+++ b/examples/junit/src/test/java/com/example/ThrowingFuzzTest.java
@@ -1,4 +1,4 @@
-// Copyright 2021 Code Intelligence GmbH
+// Copyright 2023 Code Intelligence GmbH
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,13 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package jaz;
+package com.example;
 
-/**
- * A safe to use companion of {@link jaz.Zer} that is used to produce serializable instances of it
- * with only light patching.
- */
-@SuppressWarnings("unused")
-public class Ter implements java.io.Serializable {
-  static final long serialVersionUID = 42L;
+import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import com.code_intelligence.jazzer.junit.FuzzTest;
+
+public class ThrowingFuzzTest {
+  @FuzzTest
+  public void throwingFuzz(FuzzedDataProvider ignored) {
+    throw new IllegalStateException("This is a test.");
+  }
 }
diff --git a/examples/junit/src/test/java/com/example/ValidFuzzTests.java b/examples/junit/src/test/java/com/example/ValidFuzzTests.java
new file mode 100644
index 0000000..465d3b7
--- /dev/null
+++ b/examples/junit/src/test/java/com/example/ValidFuzzTests.java
@@ -0,0 +1,76 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.example;
+
+import static org.junit.jupiter.api.Assertions.fail;
+
+import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium;
+import com.code_intelligence.jazzer.junit.FuzzTest;
+import java.io.IOException;
+import java.util.regex.Pattern;
+
+@SuppressWarnings("InvalidPatternSyntax")
+class ValidFuzzTests {
+  @FuzzTest
+  void dataFuzz(FuzzedDataProvider data) {
+    switch (data.consumeRemainingAsString()) {
+      case "no_crash":
+        return;
+      case "assert":
+        fail("JUnit assert failed");
+      case "honeypot":
+        try {
+          Class.forName("jaz.Zer").newInstance();
+        } catch (ClassNotFoundException | InstantiationException | IllegalAccessException ignored) {
+          // Ignored, but the honeypot class should still throw an exception.
+        }
+      case "sanitizer_internal_class":
+        try {
+          new ProcessBuilder("jazze").start();
+        } catch (IOException ignored) {
+          // Ignored, but the sanitizer should still throw an exception.
+        }
+      case "sanitizer_user_class":
+        try {
+          Pattern.compile("[");
+        } catch (Throwable ignored) {
+          // Ignored, but the JUnit test should report an error even though all throwables are
+          // caught - just like Jazzer would.
+        }
+      case "":
+      default:
+        throw new FuzzerSecurityIssueMedium();
+    }
+  }
+
+  @FuzzTest
+  void byteFuzz(byte[] data) {
+    if (data.length < 1) {
+      return;
+    }
+    if (data[0] % 2 == 0) {
+      fail();
+    }
+  }
+
+  @FuzzTest(maxDuration = "10s")
+  void noCrashFuzz(byte[] data) {
+    if (data.length < 10) {
+      return;
+    }
+    Parser.parse(data);
+  }
+}
diff --git a/examples/junit/src/test/java/com/example/ValueProfileFuzzTest.java b/examples/junit/src/test/java/com/example/ValueProfileFuzzTest.java
new file mode 100644
index 0000000..e69562f
--- /dev/null
+++ b/examples/junit/src/test/java/com/example/ValueProfileFuzzTest.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2022 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example;
+
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium;
+import com.code_intelligence.jazzer.junit.FuzzTest;
+import java.util.Base64;
+
+class ValueProfileFuzzTest {
+  // Only passed with the configuration parameter jazzer.valueprofile=true.
+  @FuzzTest(maxDuration = "20s")
+  void valueProfileFuzz(byte[] data) {
+    // Trigger some coverage even with value profiling disabled.
+    if (data.length < 1 || data[0] > 100) {
+      return;
+    }
+    if (base64(data).equals("SmF6emVy")) {
+      throw new FuzzerSecurityIssueMedium();
+    }
+  }
+
+  private static String base64(byte[] input) {
+    return Base64.getEncoder().encodeToString(input);
+  }
+}
diff --git a/examples/junit/src/test/resources/BUILD.bazel b/examples/junit/src/test/resources/BUILD.bazel
new file mode 100644
index 0000000..6934305
--- /dev/null
+++ b/examples/junit/src/test/resources/BUILD.bazel
@@ -0,0 +1,11 @@
+java_library(
+    name = "example_seed_corpora",
+    resources = glob(["com/example/*Inputs/**"]),
+    visibility = ["//examples/junit/src/test/java/com/example:__pkg__"],
+)
+
+filegroup(
+    name = "MutatorFuzzTestInputs",
+    srcs = ["com/example/MutatorFuzzTestInputs"],
+    visibility = ["//visibility:public"],
+)
diff --git a/examples/junit/src/test/resources/com/example/AutofuzzWithCorpusFuzzTestInputs/autofuzzWithCorpus/crashing_input b/examples/junit/src/test/resources/com/example/AutofuzzWithCorpusFuzzTestInputs/autofuzzWithCorpus/crashing_input
new file mode 100644
index 0000000..2c92661
--- /dev/null
+++ b/examples/junit/src/test/resources/com/example/AutofuzzWithCorpusFuzzTestInputs/autofuzzWithCorpus/crashing_input
Binary files differ
diff --git a/examples/junit/src/test/resources/com/example/ByteFuzzTestInputs/byteFuzz/fails b/examples/junit/src/test/resources/com/example/ByteFuzzTestInputs/byteFuzz/fails
new file mode 100644
index 0000000..63d8dbd
--- /dev/null
+++ b/examples/junit/src/test/resources/com/example/ByteFuzzTestInputs/byteFuzz/fails
@@ -0,0 +1 @@
+b
\ No newline at end of file
diff --git a/examples/junit/src/test/resources/com/example/ByteFuzzTestInputs/byteFuzz/succeeds b/examples/junit/src/test/resources/com/example/ByteFuzzTestInputs/byteFuzz/succeeds
new file mode 100644
index 0000000..2e65efe
--- /dev/null
+++ b/examples/junit/src/test/resources/com/example/ByteFuzzTestInputs/byteFuzz/succeeds
@@ -0,0 +1 @@
+a
\ No newline at end of file
diff --git a/examples/junit/src/test/resources/com/example/MutatorFuzzTestInputs/mutatorFuzz/invalid b/examples/junit/src/test/resources/com/example/MutatorFuzzTestInputs/mutatorFuzz/invalid
new file mode 100644
index 0000000..acbe86c
--- /dev/null
+++ b/examples/junit/src/test/resources/com/example/MutatorFuzzTestInputs/mutatorFuzz/invalid
@@ -0,0 +1 @@
+abcd
diff --git a/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/byteFuzz/assert b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/byteFuzz/assert
new file mode 100644
index 0000000..60d58a7
--- /dev/null
+++ b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/byteFuzz/assert
@@ -0,0 +1 @@
+assert
\ No newline at end of file
diff --git a/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/byteFuzz/honeypot b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/byteFuzz/honeypot
new file mode 100644
index 0000000..ff56ae2
--- /dev/null
+++ b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/byteFuzz/honeypot
@@ -0,0 +1 @@
+honeypot
\ No newline at end of file
diff --git a/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/byteFuzz/sanitizer_internal_class b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/byteFuzz/sanitizer_internal_class
new file mode 100644
index 0000000..05ead16
--- /dev/null
+++ b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/byteFuzz/sanitizer_internal_class
@@ -0,0 +1 @@
+sanitizer_internal_class
\ No newline at end of file
diff --git a/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/byteFuzz/sanitizer_user_class b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/byteFuzz/sanitizer_user_class
new file mode 100644
index 0000000..067da78
--- /dev/null
+++ b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/byteFuzz/sanitizer_user_class
@@ -0,0 +1 @@
+sanitizer_user_class
\ No newline at end of file
diff --git a/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/dataFuzz/assert b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/dataFuzz/assert
new file mode 100644
index 0000000..60d58a7
--- /dev/null
+++ b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/dataFuzz/assert
@@ -0,0 +1 @@
+assert
\ No newline at end of file
diff --git a/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/dataFuzz/honeypot b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/dataFuzz/honeypot
new file mode 100644
index 0000000..ff56ae2
--- /dev/null
+++ b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/dataFuzz/honeypot
@@ -0,0 +1 @@
+honeypot
\ No newline at end of file
diff --git a/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/dataFuzz/sanitizer_internal_class b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/dataFuzz/sanitizer_internal_class
new file mode 100644
index 0000000..05ead16
--- /dev/null
+++ b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/dataFuzz/sanitizer_internal_class
@@ -0,0 +1 @@
+sanitizer_internal_class
\ No newline at end of file
diff --git a/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/dataFuzz/sanitizer_user_class b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/dataFuzz/sanitizer_user_class
new file mode 100644
index 0000000..067da78
--- /dev/null
+++ b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/dataFuzz/sanitizer_user_class
@@ -0,0 +1 @@
+sanitizer_user_class
\ No newline at end of file
diff --git a/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/noCrashFuzz/assert b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/noCrashFuzz/assert
new file mode 100644
index 0000000..60d58a7
--- /dev/null
+++ b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/noCrashFuzz/assert
@@ -0,0 +1 @@
+assert
\ No newline at end of file
diff --git a/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/noCrashFuzz/honeypot b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/noCrashFuzz/honeypot
new file mode 100644
index 0000000..ff56ae2
--- /dev/null
+++ b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/noCrashFuzz/honeypot
@@ -0,0 +1 @@
+honeypot
\ No newline at end of file
diff --git a/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/noCrashFuzz/sanitizer_internal_class b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/noCrashFuzz/sanitizer_internal_class
new file mode 100644
index 0000000..05ead16
--- /dev/null
+++ b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/noCrashFuzz/sanitizer_internal_class
@@ -0,0 +1 @@
+sanitizer_internal_class
\ No newline at end of file
diff --git a/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/noCrashFuzz/sanitizer_user_class b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/noCrashFuzz/sanitizer_user_class
new file mode 100644
index 0000000..067da78
--- /dev/null
+++ b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/noCrashFuzz/sanitizer_user_class
@@ -0,0 +1 @@
+sanitizer_user_class
\ No newline at end of file
diff --git a/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/no_crash b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/no_crash
new file mode 100644
index 0000000..6543558
--- /dev/null
+++ b/examples/junit/src/test/resources/com/example/ValidFuzzTestsInputs/no_crash
@@ -0,0 +1 @@
+no_crash
\ No newline at end of file
diff --git a/examples/junit/src/test/resources/com/example/ValueProfileFuzzTestInputs/valueProfileFuzz/empty_seed b/examples/junit/src/test/resources/com/example/ValueProfileFuzzTestInputs/valueProfileFuzz/empty_seed
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/examples/junit/src/test/resources/com/example/ValueProfileFuzzTestInputs/valueProfileFuzz/empty_seed
diff --git a/examples/junit/src/test/resources/junit-platform.properties b/examples/junit/src/test/resources/junit-platform.properties
new file mode 100644
index 0000000..0229061
--- /dev/null
+++ b/examples/junit/src/test/resources/junit-platform.properties
@@ -0,0 +1 @@
+jazzer.instrument=com.example.**,com.other.package.**
diff --git a/examples/src/main/java/com/example/BatikTranscoderFuzzer.java b/examples/src/main/java/com/example/BatikTranscoderFuzzer.java
new file mode 100644
index 0000000..cdd2216
--- /dev/null
+++ b/examples/src/main/java/com/example/BatikTranscoderFuzzer.java
@@ -0,0 +1,44 @@
+// Copyright 2023 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.example;
+
+import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import java.io.*;
+import org.apache.batik.transcoder.TranscoderException;
+import org.apache.batik.transcoder.TranscoderInput;
+import org.apache.batik.transcoder.TranscoderOutput;
+import org.apache.batik.transcoder.image.JPEGTranscoder;
+
+public class BatikTranscoderFuzzer {
+  public static void fuzzerTestOneInput(FuzzedDataProvider data) throws IOException {
+    String host = data.consumeRemainingAsString();
+
+    byte[] svg =
+        ("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" >\n"
+            + "<image width=\"50\" height=\"50\" xlink:href=\"https://" + host + "/\"></image>\n"
+            + "</svg>")
+            .getBytes();
+
+    // Convert SVG to JPEG
+    try {
+      JPEGTranscoder transcoder = new JPEGTranscoder();
+      TranscoderInput input = new TranscoderInput(new ByteArrayInputStream(svg));
+      TranscoderOutput output = new TranscoderOutput(new ByteArrayOutputStream());
+      transcoder.transcode(input, output);
+    } catch (TranscoderException | IllegalArgumentException e) {
+      // Ignored
+    }
+  }
+}
diff --git a/examples/src/main/java/com/example/CommonsTextFuzzer.java b/examples/src/main/java/com/example/CommonsTextFuzzer.java
new file mode 100644
index 0000000..32b309d
--- /dev/null
+++ b/examples/src/main/java/com/example/CommonsTextFuzzer.java
@@ -0,0 +1,28 @@
+// Copyright 2023 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.example;
+
+import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import org.apache.commons.text.StringSubstitutor;
+
+public class CommonsTextFuzzer {
+  public static void fuzzerTestOneInput(FuzzedDataProvider data) {
+    try {
+      StringSubstitutor.createInterpolator().replace(data.consumeAsciiString(20));
+    } catch (
+        java.lang.IllegalArgumentException | java.lang.ArrayIndexOutOfBoundsException ignored) {
+    }
+  }
+}
diff --git a/examples/src/main/java/com/example/ExampleFuzzerWithNative.java b/examples/src/main/java/com/example/ExampleFuzzerWithNative.java
index b9a13e2..90639de 100644
--- a/examples/src/main/java/com/example/ExampleFuzzerWithNative.java
+++ b/examples/src/main/java/com/example/ExampleFuzzerWithNative.java
@@ -19,7 +19,7 @@
 
 public class ExampleFuzzerWithNative {
   static {
-    String native_lib = System.getProperty("jazzer.native_lib");
+    String native_lib = System.getenv("EXAMPLE_NATIVE_LIB");
     RulesJni.loadLibrary(native_lib, ExampleFuzzerWithNative.class);
   }
 
diff --git a/examples/src/main/java/com/example/ExampleKotlinFuzzer.kt b/examples/src/main/java/com/example/ExampleKotlinFuzzer.kt
new file mode 100644
index 0000000..eb1aea8
--- /dev/null
+++ b/examples/src/main/java/com/example/ExampleKotlinFuzzer.kt
@@ -0,0 +1,38 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.example
+
+import com.code_intelligence.jazzer.api.FuzzedDataProvider
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium
+
+object ExampleKotlinFuzzer {
+
+    @JvmStatic
+    fun fuzzerTestOneInput(data: FuzzedDataProvider) {
+        exploreMe(data.consumeString(8), data.consumeInt(), data.consumeRemainingAsString())
+    }
+
+    private fun exploreMe(prefix: String, n: Int, suffix: String) {
+        if (prefix.findAnyOf(arrayListOf("Fuzz", "Test")) != null) {
+            if (n >= 2000000) {
+                if (suffix.startsWith("@")) {
+                    if (suffix.substring(1) == "Jazzer") {
+                        throw FuzzerSecurityIssueMedium("Jazzer resolved string comparisons in Kotlin")
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/examples/src/main/java/com/example/ExampleKotlinValueProfileFuzzer.kt b/examples/src/main/java/com/example/ExampleKotlinValueProfileFuzzer.kt
new file mode 100644
index 0000000..c86824e
--- /dev/null
+++ b/examples/src/main/java/com/example/ExampleKotlinValueProfileFuzzer.kt
@@ -0,0 +1,37 @@
+// Copyright 2023 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.example
+
+import com.code_intelligence.jazzer.api.FuzzedDataProvider
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium
+
+object ExampleKotlinValueProfileFuzzer {
+
+    @JvmStatic
+    fun fuzzerTestOneInput(data: FuzzedDataProvider) {
+        if (data.consumeInt().compareTo(0x11223344) != 0) {
+            return
+        }
+        if (encrypt(data.consumeLong()).compareTo(5788627691251634856) == 0 &&
+            encrypt(data.consumeLong()).compareTo(6293579535917519017) == 0
+        ) {
+            throw FuzzerSecurityIssueMedium("Jazzer can handle integral comparisons in Kotlin")
+        }
+    }
+
+    private fun encrypt(n: Long): Long {
+        return n.xor(0x1122334455667788)
+    }
+}
diff --git a/examples/src/main/java/com/example/ExampleOutOfMemoryFuzzer.java b/examples/src/main/java/com/example/ExampleOutOfMemoryFuzzer.java
index d704da3..494bff1 100644
--- a/examples/src/main/java/com/example/ExampleOutOfMemoryFuzzer.java
+++ b/examples/src/main/java/com/example/ExampleOutOfMemoryFuzzer.java
@@ -14,15 +14,13 @@
 
 package com.example;
 
-import java.util.ArrayList;
-
 public class ExampleOutOfMemoryFuzzer {
+  public static long[] leak;
+
   public static void fuzzerTestOneInput(byte[] input) {
-    ArrayList<Byte> bytes = new ArrayList<>();
-    int pos = 0;
-    while (pos >= 0 && pos < input.length) {
-      bytes.add(input[pos]);
-      pos += input[pos] + 1;
+    if (input.length == 0) {
+      return;
     }
+    leak = new long[Integer.MAX_VALUE];
   }
 }
diff --git a/examples/src/main/java/com/example/ExamplePathTraversalFuzzerHooks.java b/examples/src/main/java/com/example/ExamplePathTraversalFuzzerHooks.java
index b027de5..109db94 100644
--- a/examples/src/main/java/com/example/ExamplePathTraversalFuzzerHooks.java
+++ b/examples/src/main/java/com/example/ExamplePathTraversalFuzzerHooks.java
@@ -24,6 +24,8 @@
 import java.nio.file.Paths;
 
 public class ExamplePathTraversalFuzzerHooks {
+  private static final String publicFilesRootPath = "/app/upload/";
+
   @MethodHook(type = HookType.BEFORE, targetClassName = "java.io.File", targetMethod = "<init>",
       targetMethodDescriptor = "(Ljava/lang/String;)V")
   public static void
@@ -36,7 +38,7 @@
       // Invalid paths are correctly rejected by the application.
       return;
     }
-    if (!normalizedPath.startsWith(ExamplePathTraversalFuzzer.publicFilesRootPath)) {
+    if (!normalizedPath.startsWith(publicFilesRootPath)) {
       // Simply throwing an exception from here would not work as the calling code catches and
       // ignores all Throwables. Instead, use the Jazzer API to report a finding from a hook.
       Jazzer.reportFindingFromHook(new FuzzerSecurityIssueHigh(
diff --git a/examples/src/main/java/com/example/MazeFuzzer.java b/examples/src/main/java/com/example/MazeFuzzer.java
index 9d3448c..beab610 100644
--- a/examples/src/main/java/com/example/MazeFuzzer.java
+++ b/examples/src/main/java/com/example/MazeFuzzer.java
@@ -17,6 +17,7 @@
 import com.code_intelligence.jazzer.api.Consumer3;
 import com.code_intelligence.jazzer.api.Jazzer;
 import java.util.Arrays;
+import java.util.Objects;
 import java.util.stream.Collectors;
 
 // A fuzz target that shows how manually informing the fuzzer about important state can make a fuzz
@@ -60,7 +61,7 @@
       // This is the key line that makes this fuzz target work: It instructs the fuzzer to track
       // every new combination of x and y as a new feature. Without it, the fuzzer would be
       // completely lost in the maze as guessing an escaping path by chance is close to impossible.
-      Jazzer.exploreState(hash(x, y), 0);
+      Jazzer.exploreState((byte) Objects.hash(x, y), 0);
       if (REACHED_FIELDS[y][x] == ' ') {
         // Fuzzer reached a new field in the maze, print its progress.
         REACHED_FIELDS[y][x] = '.';
@@ -69,18 +70,6 @@
     });
   }
 
-  // Hash function with good mixing properties published by Thomas Mueller
-  // under the terms of CC BY-SA 4.0 at
-  // https://stackoverflow.com/a/12996028
-  // https://creativecommons.org/licenses/by-sa/4.0/
-  private static byte hash(byte x, byte y) {
-    int h = (x << 8) | y;
-    h = ((h >> 16) ^ h) * 0x45d9f3b;
-    h = ((h >> 16) ^ h) * 0x45d9f3b;
-    h = (h >> 16) ^ h;
-    return (byte) h;
-  }
-
   private static class TreasureFoundException extends RuntimeException {
     TreasureFoundException(byte[] commands) {
       super(renderPath(commands));
diff --git a/examples/src/main/native/com/example/BUILD.bazel b/examples/src/main/native/com/example/BUILD.bazel
index 4c44327..338be9e 100644
--- a/examples/src/main/native/com/example/BUILD.bazel
+++ b/examples/src/main/native/com/example/BUILD.bazel
@@ -17,13 +17,14 @@
         "_DISABLE_VECTOR_ANNOTATION=1",
     ],
     linkopts = select({
-        "//:clang_on_linux": ["-fuse-ld=lld"],
         "@platforms//os:windows": [
             # Windows requires all symbols that should be imported from the main
             # executable to be defined by an import lib.
             "/wholearchive:clang_rt.asan_dll_thunk-x86_64.lib",
         ],
-        "//conditions:default": [],
+        "//conditions:default": [
+            "-fsanitize=fuzzer-no-link,address",
+        ],
     }),
     visibility = ["//examples:__pkg__"],
     deps = [
@@ -41,13 +42,14 @@
         "-fno-sanitize-recover=all",
     ],
     linkopts = select({
-        "//:clang_on_linux": ["-fuse-ld=lld"],
         "@platforms//os:windows": [
             # Using the asan thunk is correct here as it contains symbols for
             # UBSan and SanCov as well.
             "/wholearchive:clang_rt.asan_dll_thunk-x86_64.lib",
         ],
-        "//conditions:default": [],
+        "//conditions:default": [
+            "-fsanitize=fuzzer-no-link,undefined",
+        ],
     }),
     visibility = ["//examples:__pkg__"],
     deps = [
diff --git a/examples/src/main/native/com/example/com_example_ExampleFuzzerWithNative.cpp b/examples/src/main/native/com/example/com_example_ExampleFuzzerWithNative.cpp
index 774e599..971ea74 100644
--- a/examples/src/main/native/com/example/com_example_ExampleFuzzerWithNative.cpp
+++ b/examples/src/main/native/com/example/com_example_ExampleFuzzerWithNative.cpp
@@ -14,6 +14,7 @@
 
 #include "com_example_ExampleFuzzerWithNative.h"
 
+#include <cstring>
 #include <limits>
 #include <string>
 
@@ -27,8 +28,10 @@
   }
   if (input[0] == 'a' && input[1] == 'b' && input[5] == 'c') {
     if (input.find("secret_in_native_library") != std::string::npos) {
-      // Crashes with ASan.
-      [[maybe_unused]] char foo = input[input.size() + 2];
+      // Crashes with ASan, whose use-after-free hooks detect
+      const char *mem = static_cast<const char *>(malloc(2));
+      free((void *)mem);
+      [[maybe_unused]] bool foo = memcmp(mem, mem + 1, 1);
     }
   }
 }
diff --git a/format.sh b/format.sh
index f1440f1..980c1c7 100755
--- a/format.sh
+++ b/format.sh
@@ -1,14 +1,40 @@
-# C++ & Java
-find -name '*.cpp' -o -name '*.c' -o -name '*.h' -o -name '*.java' | xargs clang-format-13 -i
+#!/usr/bin/env bash
+# Copyright 2023 Code Intelligence GmbH
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
 
-# Kotlin
-# curl -sSLO https://github.com/pinterest/ktlint/releases/download/0.42.1/ktlint && chmod a+x ktlint
-ktlint -F "agent/**/*.kt" "driver/**/*.kt" "examples/**/*.kt" "sanitizers/**/*.kt" "tests/**/*.kt"
 
-# BUILD files
-# go get github.com/bazelbuild/buildtools/buildifier
-buildifier -r .
+set -euo pipefail
+
+THIS_DIR="$(pwd -P)"
 
 # Licence headers
-# go get -u github.com/google/addlicense
-addlicense -c "Code Intelligence GmbH" agent/ bazel/ deploy/ docker/ driver/ examples/ sanitizers/ tests/ *.bzl
+bazel run --config=quiet //:addlicense -- -c "Code Intelligence GmbH" -ignore '**/third_party/**' -ignore '**/.github/**' "$THIS_DIR"
+
+# C++ & Java
+find "$THIS_DIR" \( -name '*.cpp' -o -name '*.c' -o -name '*.h' -o -name '*.java' \) -print0 | xargs -0 bazel run --config=quiet //:clang-format -- -i
+
+# No need to run in CI as these formatters have corresponding Bazel tests.
+if [[ "${CI:-0}" == 0 ]]; then
+    # Kotlin
+    # Check which ktlint_tests failed and run the corresponding fix targets. This is much faster than
+    # running all ktlint_fix targets when e.g. only a few or no .kt files changed.
+    # shellcheck disable=SC2046
+    TARGETS_TO_RUN=$(bazel test --config=quiet $(bazel query --config=quiet 'kind(ktlint_test, //...)') | { grep FAILED || true; } | cut -f1 -d' ' | sed -e 's/:ktlint_test/:ktlint_fix/g')
+    if [[ -n "${TARGETS_TO_RUN}" ]]; then
+        echo "$TARGETS_TO_RUN" | xargs -n 1 bazel run --config=quiet
+    fi
+
+    # BUILD files
+    bazel run --config=quiet //:buildifier -- -r "$THIS_DIR"
+fi
diff --git a/init.bzl b/init.bzl
index 4e2a25c..6b260b1 100644
--- a/init.bzl
+++ b/init.bzl
@@ -19,11 +19,13 @@
 load("@io_bazel_rules_kotlin//kotlin:dependencies.bzl", "kt_download_local_dev_dependencies")
 load("@io_bazel_rules_kotlin//kotlin:repositories.bzl", "kotlin_repositories")
 load("@fmeum_rules_jni//jni:repositories.bzl", "rules_jni_dependencies")
+load("@build_bazel_apple_support//lib:repositories.bzl", "apple_support_dependencies")
 
 def jazzer_init():
     bazel_skylib_workspace()
     kt_download_local_dev_dependencies()
     kotlin_repositories()
-    native.register_toolchains("@jazzer//:kotlin_toolchain")
+    native.register_toolchains("@jazzer//bazel/toolchains:kotlin_toolchain")
     jar_jar_repositories()
     rules_jni_dependencies()
+    apple_support_dependencies()
diff --git a/jazzer-api.pom b/jazzer-api.pom
deleted file mode 100644
index ef413bb..0000000
--- a/jazzer-api.pom
+++ /dev/null
@@ -1,38 +0,0 @@
-<project>
-  <modelVersion>4.0.0</modelVersion>
-  <groupId>{groupId}</groupId>
-  <artifactId>{artifactId}</artifactId>
-  <version>{version}</version>
-  <packaging>jar</packaging>
-  {dependencies}
-
-  <name>Jazzer API</name>
-  <description>Helper functions and annotations for Jazzer fuzz targets</description>
-  <url>https://github.com/CodeIntelligenceTesting/jazzer</url>
-
-  <licenses>
-    <license>
-      <name>Apache License, Version 2.0</name>
-      <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
-      <distribution>repo</distribution>
-    </license>
-  </licenses>
-
-  <organization>
-    <name>Code Intelligence GmbH</name>
-    <url>https://code-intelligence.com</url>
-  </organization>
-
-  <developers>
-    <developer>
-      <id>fmeum</id>
-      <name>Fabian Meumertzheim</name>
-      <email>meumertzheim@code-intelligence.com</email>
-      <organization>Code Intelligence GmbH</organization>
-    </developer>
-  </developers>
-
-  <scm>
-    <url>https://github.com/CodeIntelligenceTesting/jazzer</url>
-  </scm>
-</project>
\ No newline at end of file
diff --git a/launcher/BUILD.bazel b/launcher/BUILD.bazel
new file mode 100644
index 0000000..50bd477
--- /dev/null
+++ b/launcher/BUILD.bazel
@@ -0,0 +1,99 @@
+load("@build_bazel_apple_support//rules:universal_binary.bzl", "universal_binary")
+
+cc_library(
+    name = "jazzer_main",
+    srcs = ["jazzer_main.cpp"],
+    deps = [
+        ":jvm_tooling_lib",
+        "@com_google_absl//absl/strings",
+        "@fmeum_rules_jni//jni:libjvm",
+    ],
+)
+
+cc_library(
+    name = "jvm_tooling_lib",
+    srcs = ["jvm_tooling.cpp"],
+    hdrs = ["jvm_tooling.h"],
+    data = [
+        "//src/main/java/com/code_intelligence/jazzer:jazzer_standalone_deploy.jar",
+    ],
+    linkopts = select({
+        "@platforms//os:android": ["-ldl"],
+        "//conditions:default": [],
+    }),
+    deps = [
+        "@bazel_tools//tools/cpp/runfiles",
+        "@com_google_absl//absl/strings",
+        "@com_google_absl//absl/strings:str_format",
+        "@fmeum_rules_jni//jni",
+    ],
+)
+
+cc_binary(
+    name = "jazzer_single_arch",
+    linkstatic = True,
+    tags = ["manual"],
+    visibility = ["//launcher/android:__pkg__"],
+    deps = [":jazzer_main"],
+)
+
+# On macOS, builds a binary that supports both x86_64 and arm64.
+# On all other platforms, it just symlinks the input binary.
+universal_binary(
+    name = "jazzer",
+    binary = ":jazzer_single_arch",
+    visibility = ["//visibility:public"],
+)
+
+cc_test(
+    name = "jvm_tooling_test",
+    size = "small",
+    srcs = ["jvm_tooling_test.cpp"],
+    data = [
+        "//launcher/testdata:fuzz_target_mocks_deploy.jar",
+    ],
+    env = {
+        "JAVA_OPTS": "-Djazzer.hooks=false",
+    },
+    includes = ["."],
+    deps = [
+        ":jvm_tooling_lib",
+        ":test_main",
+        "@bazel_tools//tools/cpp/runfiles",
+        "@googletest//:gtest",
+    ],
+)
+
+cc_test(
+    name = "fuzzed_data_provider_test",
+    size = "medium",
+    srcs = ["fuzzed_data_provider_test.cpp"],
+    copts = select({
+        "@platforms//os:windows": ["/std:c++17"],
+        "//conditions:default": ["-std=c++17"],
+    }),
+    data = [
+        "//launcher/testdata:fuzz_target_mocks_deploy.jar",
+    ],
+    env = {
+        "JAVA_OPTS": "-Djazzer.hooks=false",
+    },
+    includes = ["."],
+    deps = [
+        ":jvm_tooling_lib",
+        ":test_main",
+        "//src/main/native/com/code_intelligence/jazzer/driver:fuzzed_data_provider",
+        "@bazel_tools//tools/cpp/runfiles",
+        "@googletest//:gtest",
+    ],
+)
+
+cc_library(
+    name = "test_main",
+    srcs = ["test_main.cpp"],
+    linkstatic = True,
+    deps = [
+        "@fmeum_rules_jni//jni:libjvm",
+        "@googletest//:gtest",
+    ],
+)
diff --git a/launcher/android/AndroidManifest.xml b/launcher/android/AndroidManifest.xml
new file mode 100644
index 0000000..7a3c2a2
--- /dev/null
+++ b/launcher/android/AndroidManifest.xml
@@ -0,0 +1,21 @@
+<!--
+ Copyright 2023 Code Intelligence GmbH
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.code_intelligence.jazzer"
+    android:versionCode="1"
+    android:versionName="1.0" >
+</manifest>
diff --git a/launcher/android/BUILD.bazel b/launcher/android/BUILD.bazel
new file mode 100644
index 0000000..502f612
--- /dev/null
+++ b/launcher/android/BUILD.bazel
@@ -0,0 +1,32 @@
+load("//bazel:compat.bzl", "SKIP_ON_WINDOWS")
+
+android_library(
+    name = "jazzer_android_lib",
+    data = [
+        "//launcher:jazzer_single_arch",
+        "//src/main/java/com/code_intelligence/jazzer/android:jazzer_standalone_android.apk",
+    ],
+    tags = ["manual"],
+    target_compatible_with = SKIP_ON_WINDOWS,
+)
+
+android_binary(
+    name = "jazzer_android",
+    manifest = ":android_manifest",
+    min_sdk_version = 26,
+    tags = ["manual"],
+    target_compatible_with = SKIP_ON_WINDOWS,
+    visibility = ["//visibility:public"],
+    deps = [
+        ":jazzer_android_lib",
+    ],
+)
+
+filegroup(
+    name = "android_manifest",
+    srcs = ["AndroidManifest.xml"],
+    tags = ["manual"],
+    visibility = [
+        "//visibility:public",
+    ],
+)
diff --git a/launcher/fuzzed_data_provider_test.cpp b/launcher/fuzzed_data_provider_test.cpp
new file mode 100644
index 0000000..9907b75
--- /dev/null
+++ b/launcher/fuzzed_data_provider_test.cpp
@@ -0,0 +1,101 @@
+// Copyright 2021 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include <cstddef>
+#include <cstdint>
+#include <random>
+#include <string>
+#include <vector>
+
+#include "gtest/gtest.h"
+#include "launcher/jvm_tooling.h"
+#include "tools/cpp/runfiles/runfiles.h"
+
+namespace jazzer {
+
+std::pair<std::string, jint> FixUpModifiedUtf8(const uint8_t* pos,
+                                               jint max_bytes, jint max_length,
+                                               bool ascii_only,
+                                               bool stop_on_backslash);
+
+class FuzzedDataProviderTest : public ::testing::Test {
+ protected:
+  // After DestroyJavaVM() no new JVM instance can be created in the same
+  // process, so we set up a single JVM instance for this test binary which gets
+  // destroyed after all tests in this test suite have finished.
+  static void SetUpTestCase() {
+    using ::bazel::tools::cpp::runfiles::Runfiles;
+    std::unique_ptr<Runfiles> runfiles(Runfiles::CreateForTest());
+    FLAGS_cp = runfiles->Rlocation(
+        "jazzer/launcher/testdata/fuzz_target_mocks_deploy.jar");
+
+    jvm_ = std::make_unique<JVM>();
+  }
+
+  static void TearDownTestCase() { jvm_.reset(nullptr); }
+
+  static std::unique_ptr<JVM> jvm_;
+};
+
+std::unique_ptr<JVM> FuzzedDataProviderTest::jvm_ = nullptr;
+
+constexpr std::size_t kValidModifiedUtf8NumRuns = 1000;
+constexpr std::size_t kValidModifiedUtf8NumBytes = 100000;
+constexpr uint32_t kValidModifiedUtf8Seed = 0x12345678;
+
+TEST_F(FuzzedDataProviderTest, InvalidModifiedUtf8AfterFixup) {
+  auto& env = jvm_->GetEnv();
+  auto modified_utf8_validator = env.FindClass("test/ModifiedUtf8Encoder");
+  ASSERT_NE(nullptr, modified_utf8_validator);
+  auto string_to_modified_utf_bytes = env.GetStaticMethodID(
+      modified_utf8_validator, "encode", "(Ljava/lang/String;)[B");
+  ASSERT_NE(nullptr, string_to_modified_utf_bytes);
+  auto random_bytes = std::vector<uint8_t>(kValidModifiedUtf8NumBytes);
+  auto random = std::mt19937(kValidModifiedUtf8Seed);
+  for (bool ascii_only : {false, true}) {
+    for (bool stop_on_backslash : {false, true}) {
+      for (std::size_t i = 0; i < kValidModifiedUtf8NumRuns; ++i) {
+        std::generate(random_bytes.begin(), random_bytes.end(), random);
+        std::string fixed_string;
+        std::tie(fixed_string, std::ignore) = FixUpModifiedUtf8(
+            random_bytes.data(), random_bytes.size(),
+            std::numeric_limits<jint>::max(), ascii_only, stop_on_backslash);
+
+        jstring jni_fixed_string = env.NewStringUTF(fixed_string.c_str());
+        auto jni_roundtripped_bytes = (jbyteArray)env.CallStaticObjectMethod(
+            modified_utf8_validator, string_to_modified_utf_bytes,
+            jni_fixed_string);
+        ASSERT_FALSE(env.ExceptionCheck());
+        env.DeleteLocalRef(jni_fixed_string);
+        jint roundtripped_bytes_length =
+            env.GetArrayLength(jni_roundtripped_bytes);
+        jbyte* roundtripped_bytes =
+            env.GetByteArrayElements(jni_roundtripped_bytes, nullptr);
+        auto roundtripped_string =
+            std::string(reinterpret_cast<char*>(roundtripped_bytes),
+                        roundtripped_bytes_length);
+        env.ReleaseByteArrayElements(jni_roundtripped_bytes, roundtripped_bytes,
+                                     JNI_ABORT);
+        env.DeleteLocalRef(jni_roundtripped_bytes);
+
+        // Verify that the bytes obtained from running our modified UTF-8 fix-up
+        // function remain unchanged when turned into a Java string and
+        // reencoded into modified UTF-8. This will only happen if the our
+        // fix-up function indeed returned valid modified UTF-8.
+        ASSERT_EQ(fixed_string, roundtripped_string);
+      }
+    }
+  }
+}
+}  // namespace jazzer
diff --git a/launcher/jazzer_main.cpp b/launcher/jazzer_main.cpp
new file mode 100644
index 0000000..d2c6e29
--- /dev/null
+++ b/launcher/jazzer_main.cpp
@@ -0,0 +1,109 @@
+// Copyright 2021 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/*
+ * Jazzer's native main function, which starts a JVM suitably configured for
+ * fuzzing and passes control to the Java part of the driver.
+ */
+
+#include <rules_jni.h>
+
+#include <algorithm>
+#include <iostream>
+#include <memory>
+#include <vector>
+
+#include "absl/strings/str_split.h"
+#include "jvm_tooling.h"
+
+namespace {
+const std::string kJazzerClassName = "com/code_intelligence/jazzer/Jazzer";
+
+void StartLibFuzzer(std::unique_ptr<jazzer::JVM> jvm,
+                    std::vector<std::string> argv) {
+  JNIEnv &env = jvm->GetEnv();
+  jclass runner = env.FindClass(kJazzerClassName.c_str());
+  if (runner == nullptr) {
+    env.ExceptionDescribe();
+    exit(1);
+  }
+  jmethodID startDriver = env.GetStaticMethodID(runner, "main", "([[B)V");
+  if (startDriver == nullptr) {
+    env.ExceptionDescribe();
+    exit(1);
+  }
+  jclass byteArrayClass = env.FindClass("[B");
+  if (byteArrayClass == nullptr) {
+    env.ExceptionDescribe();
+    exit(1);
+  }
+  jobjectArray args = env.NewObjectArray(argv.size(), byteArrayClass, nullptr);
+  if (args == nullptr) {
+    env.ExceptionDescribe();
+    exit(1);
+  }
+  for (jsize i = 0; i < argv.size(); ++i) {
+    jint len = argv[i].size();
+    jbyteArray arg = env.NewByteArray(len);
+    if (arg == nullptr) {
+      env.ExceptionDescribe();
+      exit(1);
+    }
+    // startDriver expects UTF-8 encoded strings that are not null-terminated.
+    env.SetByteArrayRegion(arg, 0, len,
+                           reinterpret_cast<const jbyte *>(argv[i].data()));
+    if (env.ExceptionCheck()) {
+      env.ExceptionDescribe();
+      exit(1);
+    }
+    env.SetObjectArrayElement(args, i, arg);
+    if (env.ExceptionCheck()) {
+      env.ExceptionDescribe();
+      exit(1);
+    }
+    env.DeleteLocalRef(arg);
+  }
+  env.CallStaticVoidMethod(runner, startDriver, args);
+  // Should not return.
+  if (env.ExceptionCheck()) {
+    env.ExceptionDescribe();
+  }
+  exit(1);
+}
+}  // namespace
+
+int main(int argc, char **argv) {
+  rules_jni_init(argv[0]);
+
+  for (int i = 1; i < argc; ++i) {
+    const std::string &arg = argv[i];
+    std::vector<std::string> split =
+        absl::StrSplit(arg, absl::MaxSplits('=', 1));
+    if (split.size() < 2) {
+      continue;
+    }
+    if (split[0] == "--cp") {
+      FLAGS_cp = split[1];
+    } else if (split[0] == "--jvm_args") {
+      FLAGS_jvm_args = split[1];
+    } else if (split[0] == "--additional_jvm_args") {
+      FLAGS_additional_jvm_args = split[1];
+    } else if (split[0] == "--agent_path") {
+      FLAGS_agent_path = split[1];
+    }
+  }
+
+  StartLibFuzzer(std::unique_ptr<jazzer::JVM>(new jazzer::JVM()),
+                 std::vector<std::string>(argv + 1, argv + argc));
+}
diff --git a/launcher/jvm_tooling.cpp b/launcher/jvm_tooling.cpp
new file mode 100644
index 0000000..3b731a8
--- /dev/null
+++ b/launcher/jvm_tooling.cpp
@@ -0,0 +1,314 @@
+// Copyright 2021 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "jvm_tooling.h"
+
+#if defined(_ANDROID)
+#include <dlfcn.h>
+#elif defined(__APPLE__)
+#include <mach-o/dyld.h>
+#elif defined(_WIN32)
+#define WIN32_LEAN_AND_MEAN
+#include <windows.h>
+#else  // Assume Linux
+#include <unistd.h>
+#endif
+
+#include <cstdlib>
+#include <fstream>
+#include <iostream>
+#include <memory>
+#include <string>
+#include <vector>
+
+#include "absl/strings/str_format.h"
+#include "absl/strings/str_join.h"
+#include "absl/strings/str_replace.h"
+#include "absl/strings/str_split.h"
+#include "tools/cpp/runfiles/runfiles.h"
+
+std::string FLAGS_cp = ".";
+std::string FLAGS_jvm_args;
+std::string FLAGS_additional_jvm_args;
+std::string FLAGS_agent_path;
+
+#if defined(_WIN32) || defined(_WIN64)
+#define ARG_SEPARATOR ";"
+constexpr auto kPathSeparator = '\\';
+#else
+#define ARG_SEPARATOR ":"
+constexpr auto kPathSeparator = '/';
+#endif
+
+namespace {
+constexpr auto kJazzerBazelRunfilesPath =
+    "jazzer/src/main/java/com/code_intelligence/jazzer/"
+    "jazzer_standalone_deploy.jar";
+constexpr auto kJazzerFileName = "jazzer_standalone.jar";
+
+// Returns the absolute path to the current executable. Compared to argv[0],
+// this path can always be used to locate the Jazzer JAR next to it, even when
+// Jazzer is executed from PATH.
+std::string getExecutablePath() {
+  char buf[655536];
+#if defined(__APPLE__)
+  uint32_t buf_size = sizeof(buf);
+  uint32_t read_bytes = buf_size - 1;
+  bool failed = (_NSGetExecutablePath(buf, &buf_size) != 0);
+#elif defined(_WIN32)
+  DWORD read_bytes = GetModuleFileNameA(NULL, buf, sizeof(buf));
+  bool failed = (read_bytes == 0);
+#elif defined(_ANDROID)
+  bool failed = true;
+  uint32_t read_bytes = 0;
+#else  // Assume Linux
+  ssize_t read_bytes = readlink("/proc/self/exe", buf, sizeof(buf));
+  bool failed = (read_bytes == -1);
+#endif
+  if (failed) {
+    return "";
+  }
+  buf[read_bytes] = '\0';
+  return {buf};
+}
+
+std::string dirFromFullPath(const std::string &path) {
+  const auto pos = path.rfind(kPathSeparator);
+  if (pos != std::string::npos) {
+    return path.substr(0, pos);
+  }
+  return "";
+}
+
+// getInstrumentorAgentPath searches for the fuzzing instrumentation agent and
+// returns the location if it is found. Otherwise it calls exit(0).
+std::string getInstrumentorAgentPath() {
+  // User provided agent location takes precedence.
+  if (!FLAGS_agent_path.empty()) {
+    if (std::ifstream(FLAGS_agent_path).good()) return FLAGS_agent_path;
+    std::cerr << "ERROR: Could not find " << kJazzerFileName << " at \""
+              << FLAGS_agent_path << "\"" << std::endl;
+    exit(1);
+  }
+
+  auto executable_path = getExecutablePath();
+
+  if (!executable_path.empty()) {
+    // First check if we are running inside the Bazel tree and use the agent
+    // runfile.
+    using bazel::tools::cpp::runfiles::Runfiles;
+    std::string error;
+    std::unique_ptr<Runfiles> runfiles(Runfiles::Create(
+        std::string(executable_path), BAZEL_CURRENT_REPOSITORY, &error));
+    if (runfiles != nullptr) {
+      auto bazel_path = runfiles->Rlocation(kJazzerBazelRunfilesPath);
+      if (!bazel_path.empty() && std::ifstream(bazel_path).good())
+        return bazel_path;
+    }
+
+    // If the agent is not in the bazel path we look next to the jazzer binary.
+    const auto dir = dirFromFullPath(executable_path);
+    auto agent_path =
+        absl::StrFormat("%s%c%s", dir, kPathSeparator, kJazzerFileName);
+    if (std::ifstream(agent_path).good()) return agent_path;
+  }
+
+  std::cerr << "ERROR: Could not find " << kJazzerFileName
+            << ". Please provide the pathname via the --agent_path flag."
+            << std::endl;
+  exit(1);
+}
+
+// Splits a string at the ARG_SEPARATOR unless it is escaped with a backslash.
+// Backslash itself can be escaped with another backslash.
+std::vector<std::string> splitEscaped(const std::string &str) {
+  // Protect \\ and \<separator> against splitting.
+  const std::string BACKSLASH_BACKSLASH_REPLACEMENT =
+      "%%JAZZER_BACKSLASH_BACKSLASH_REPLACEMENT%%";
+  const std::string BACKSLASH_SEPARATOR_REPLACEMENT =
+      "%%JAZZER_BACKSLASH_SEPARATOR_REPLACEMENT%%";
+  std::string protected_str =
+      absl::StrReplaceAll(str, {{"\\\\", BACKSLASH_BACKSLASH_REPLACEMENT}});
+  protected_str = absl::StrReplaceAll(
+      protected_str, {{"\\" ARG_SEPARATOR, BACKSLASH_SEPARATOR_REPLACEMENT}});
+
+  std::vector<std::string> parts = absl::StrSplit(protected_str, ARG_SEPARATOR);
+  std::transform(parts.begin(), parts.end(), parts.begin(),
+                 [&BACKSLASH_SEPARATOR_REPLACEMENT,
+                  &BACKSLASH_BACKSLASH_REPLACEMENT](const std::string &part) {
+                   return absl::StrReplaceAll(
+                       part,
+                       {
+                           {BACKSLASH_SEPARATOR_REPLACEMENT, ARG_SEPARATOR},
+                           {BACKSLASH_BACKSLASH_REPLACEMENT, "\\"},
+                       });
+                 });
+
+  return parts;
+}
+}  // namespace
+
+namespace jazzer {
+
+#if defined(_ANDROID)
+typedef jint (*JNI_CreateJavaVM_t)(JavaVM **, JNIEnv **, void *);
+JNI_CreateJavaVM_t LoadAndroidVMLibs() {
+  std::cout << "Loading Android libraries" << std::endl;
+
+  void *art_so = nullptr;
+  art_so = dlopen("libnativehelper.so", RTLD_NOW);
+
+  if (art_so == nullptr) {
+    std::cerr << "Could not find ART library" << std::endl;
+    exit(1);
+  }
+
+  typedef void *(*JniInvocationCreate_t)();
+  JniInvocationCreate_t JniInvocationCreate =
+      reinterpret_cast<JniInvocationCreate_t>(
+          dlsym(art_so, "JniInvocationCreate"));
+  if (JniInvocationCreate == nullptr) {
+    std::cout << "JniInvocationCreate is null" << std::endl;
+    exit(1);
+  }
+
+  void *impl = JniInvocationCreate();
+  typedef bool (*JniInvocationInit_t)(void *, const char *);
+  JniInvocationInit_t JniInvocationInit =
+      reinterpret_cast<JniInvocationInit_t>(dlsym(art_so, "JniInvocationInit"));
+  if (JniInvocationInit == nullptr) {
+    std::cout << "JniInvocationInit is null" << std::endl;
+    exit(1);
+  }
+
+  JniInvocationInit(impl, nullptr);
+
+  constexpr char create_jvm_symbol[] = "JNI_CreateJavaVM";
+  typedef jint (*JNI_CreateJavaVM_t)(JavaVM **, JNIEnv **, void *);
+  JNI_CreateJavaVM_t JNI_CreateArtVM =
+      reinterpret_cast<JNI_CreateJavaVM_t>(dlsym(art_so, create_jvm_symbol));
+  if (JNI_CreateArtVM == nullptr) {
+    std::cout << "JNI_CreateJavaVM is null" << std::endl;
+    exit(1);
+  }
+
+  return JNI_CreateArtVM;
+}
+#endif
+
+std::string GetClassPath() {
+  // combine class path from command line flags and JAVA_FUZZER_CLASSPATH env
+  // variable
+  std::string class_path = absl::StrFormat("-Djava.class.path=%s", FLAGS_cp);
+  const auto class_path_from_env = std::getenv("JAVA_FUZZER_CLASSPATH");
+  if (class_path_from_env) {
+    class_path += absl::StrCat(ARG_SEPARATOR, class_path_from_env);
+  }
+
+  class_path += absl::StrCat(ARG_SEPARATOR, getInstrumentorAgentPath());
+  return class_path;
+}
+
+JVM::JVM() {
+  std::string class_path = GetClassPath();
+
+  std::vector<JavaVMOption> options;
+  options.push_back(
+      JavaVMOption{.optionString = const_cast<char *>(class_path.c_str())});
+
+#if !defined(_ANDROID)
+  // Set the maximum heap size to a value that is slightly smaller than
+  // libFuzzer's default rss_limit_mb. This prevents erroneous oom reports.
+  options.push_back(JavaVMOption{.optionString = (char *)"-Xmx1800m"});
+  // Preserve and emit stack trace information even on hot paths.
+  // This may hurt performance, but also helps find flaky bugs.
+  options.push_back(
+      JavaVMOption{.optionString = (char *)"-XX:-OmitStackTraceInFastThrow"});
+  // Optimize GC for high throughput rather than low latency.
+  options.push_back(JavaVMOption{.optionString = (char *)"-XX:+UseParallelGC"});
+  // CriticalJNINatives has been removed in JDK 18.
+  options.push_back(
+      JavaVMOption{.optionString = (char *)"-XX:+IgnoreUnrecognizedVMOptions"});
+  options.push_back(
+      JavaVMOption{.optionString = (char *)"-XX:+CriticalJNINatives"});
+#endif
+
+  std::vector<std::string> java_opts_args;
+  const char *java_opts = std::getenv("JAVA_OPTS");
+  if (java_opts != nullptr) {
+    // Mimic the behavior of the JVM when it sees JAVA_TOOL_OPTIONS.
+    std::cerr << "Picked up JAVA_OPTS: " << java_opts << std::endl;
+
+    java_opts_args = absl::StrSplit(java_opts, ' ');
+    for (const std::string &java_opt : java_opts_args) {
+      options.push_back(
+          JavaVMOption{.optionString = const_cast<char *>(java_opt.c_str())});
+    }
+  }
+
+  // Add additional jvm options set through command line flags.
+  // Keep the vectors in scope as they contain the strings backing the C strings
+  // added to options.
+  std::vector<std::string> jvm_args;
+  if (!FLAGS_jvm_args.empty()) {
+    jvm_args = splitEscaped(FLAGS_jvm_args);
+    for (const auto &arg : jvm_args) {
+      options.push_back(
+          JavaVMOption{.optionString = const_cast<char *>(arg.c_str())});
+    }
+  }
+
+  std::vector<std::string> additional_jvm_args;
+  if (!FLAGS_additional_jvm_args.empty()) {
+    additional_jvm_args = splitEscaped(FLAGS_additional_jvm_args);
+    for (const auto &arg : additional_jvm_args) {
+      options.push_back(
+          JavaVMOption{.optionString = const_cast<char *>(arg.c_str())});
+    }
+  }
+
+#if !defined(_ANDROID)
+  jint jni_version = JNI_VERSION_1_8;
+#else
+  jint jni_version = JNI_VERSION_1_6;
+#endif
+
+  JavaVMInitArgs jvm_init_args = {.version = jni_version,
+                                  .nOptions = (int)options.size(),
+                                  .options = options.data(),
+                                  .ignoreUnrecognized = JNI_FALSE};
+
+#if !defined(_ANDROID)
+  int ret = JNI_CreateJavaVM(&jvm_, (void **)&env_, &jvm_init_args);
+#else
+  JNI_CreateJavaVM_t CreateArtVM = LoadAndroidVMLibs();
+  if (CreateArtVM == nullptr) {
+    std::cerr << "JNI_CreateJavaVM for Android not found" << std::endl;
+    exit(1);
+  }
+
+  std::cout << "Starting Art VM" << std::endl;
+  int ret = CreateArtVM(&jvm_, (JNIEnv_ **)&env_, &jvm_init_args);
+#endif
+
+  if (ret != JNI_OK) {
+    throw std::runtime_error(
+        absl::StrFormat("JNI_CreateJavaVM returned code %d", ret));
+  }
+}
+
+JNIEnv &JVM::GetEnv() const { return *env_; }
+
+JVM::~JVM() { jvm_->DestroyJavaVM(); }
+}  // namespace jazzer
diff --git a/driver/jvm_tooling.h b/launcher/jvm_tooling.h
similarity index 88%
rename from driver/jvm_tooling.h
rename to launcher/jvm_tooling.h
index 2a4a133..d7129a1 100644
--- a/driver/jvm_tooling.h
+++ b/launcher/jvm_tooling.h
@@ -20,6 +20,11 @@
 
 #include <string>
 
+extern std::string FLAGS_cp;
+extern std::string FLAGS_jvm_args;
+extern std::string FLAGS_additional_jvm_args;
+extern std::string FLAGS_agent_path;
+
 namespace jazzer {
 
 void DumpJvmStackTraces();
@@ -35,7 +40,7 @@
  public:
   // Creates a JVM instance with default options + options that were provided as
   // command line flags.
-  explicit JVM(const std::string &executable_path);
+  explicit JVM();
 
   // Destroy the running JVM instance.
   ~JVM();
diff --git a/driver/jvm_tooling_test.cpp b/launcher/jvm_tooling_test.cpp
similarity index 89%
rename from driver/jvm_tooling_test.cpp
rename to launcher/jvm_tooling_test.cpp
index 5aceadd..2a70dcb 100644
--- a/driver/jvm_tooling_test.cpp
+++ b/launcher/jvm_tooling_test.cpp
@@ -16,14 +16,9 @@
 
 #include <memory>
 
-#include "gflags/gflags.h"
 #include "gtest/gtest.h"
 #include "tools/cpp/runfiles/runfiles.h"
 
-DECLARE_string(cp);
-DECLARE_bool(hooks);
-DECLARE_string(jvm_args);
-
 #ifdef _WIN32
 #define ARG_SEPARATOR ";"
 #else
@@ -38,14 +33,14 @@
   // process, so we set up a single JVM instance for this test binary which gets
   // destroyed after all tests in this test suite have finished.
   static void SetUpTestCase() {
-    FLAGS_hooks = false;
     FLAGS_jvm_args =
         "-Denv1=va\\" ARG_SEPARATOR "l1\\\\" ARG_SEPARATOR "-Denv2=val2";
     using ::bazel::tools::cpp::runfiles::Runfiles;
-    Runfiles *runfiles = Runfiles::CreateForTest();
-    FLAGS_cp = runfiles->Rlocation(FLAGS_cp);
+    std::unique_ptr<Runfiles> runfiles(Runfiles::CreateForTest());
+    FLAGS_cp = runfiles->Rlocation(
+        "jazzer/launcher/testdata/fuzz_target_mocks_deploy.jar");
 
-    jvm_ = std::unique_ptr<JVM>(new JVM("test_executable"));
+    jvm_ = std::unique_ptr<JVM>(new JVM());
   }
 
   static void TearDownTestCase() { jvm_.reset(nullptr); }
diff --git a/driver/test_main.cpp b/launcher/test_main.cpp
similarity index 90%
rename from driver/test_main.cpp
rename to launcher/test_main.cpp
index 14340b8..cd7c8e8 100644
--- a/driver/test_main.cpp
+++ b/launcher/test_main.cpp
@@ -14,12 +14,10 @@
 
 #include <rules_jni.h>
 
-#include "gflags/gflags.h"
 #include "gtest/gtest.h"
 
 int main(int argc, char **argv) {
   rules_jni_init(argv[0]);
   ::testing::InitGoogleTest(&argc, argv);
-  gflags::ParseCommandLineFlags(&argc, &argv, true);
   return RUN_ALL_TESTS();
 }
diff --git a/driver/testdata/BUILD.bazel b/launcher/testdata/BUILD.bazel
similarity index 100%
rename from driver/testdata/BUILD.bazel
rename to launcher/testdata/BUILD.bazel
diff --git a/driver/testdata/test/ModifiedUtf8Encoder.java b/launcher/testdata/test/ModifiedUtf8Encoder.java
similarity index 100%
rename from driver/testdata/test/ModifiedUtf8Encoder.java
rename to launcher/testdata/test/ModifiedUtf8Encoder.java
diff --git a/driver/testdata/test/PropertyPrinter.java b/launcher/testdata/test/PropertyPrinter.java
similarity index 100%
rename from driver/testdata/test/PropertyPrinter.java
rename to launcher/testdata/test/PropertyPrinter.java
diff --git a/maven.bzl b/maven.bzl
index a13f175..f306791 100644
--- a/maven.bzl
+++ b/maven.bzl
@@ -14,8 +14,10 @@
 
 load("@rules_jvm_external//:specs.bzl", "maven")
 
-JAZZER_API_VERSION = "0.11.0"
-JAZZER_API_COORDINATES = "com.code-intelligence:jazzer-api:%s" % JAZZER_API_VERSION
+JAZZER_VERSION = "0.17.1"
+JAZZER_COORDINATES = "com.code-intelligence:jazzer:%s" % JAZZER_VERSION
+JAZZER_API_COORDINATES = "com.code-intelligence:jazzer-api:%s" % JAZZER_VERSION
+JAZZER_JUNIT_COORDINATES = "com.code-intelligence:jazzer-junit:%s" % JAZZER_VERSION
 
 # **WARNING**: These Maven dependencies have known vulnerabilities and are only used to test that
 #              Jazzer finds these issues. DO NOT USE.
@@ -25,8 +27,10 @@
     "com.fasterxml.jackson.core:jackson-core:2.12.1",
     "com.fasterxml.jackson.core:jackson-databind:2.12.1",
     "com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.12.1",
-    "com.github.jsqlparser:jsqlparser:4.4",  # for SQL validation
     "com.google.code.gson:gson:2.8.6",
+    "com.google.truth:truth:1.1.3",
+    "com.google.truth.extensions:truth-java8-extension:1.1.3",
+    "com.google.truth.extensions:truth-proto-extension:1.1.3",
     "com.mikesamuel:json-sanitizer:1.2.1",
     "com.unboundid:unboundid-ldapsdk:6.0.3",
     "javax.el:javax.el-api:3.0.1-b06",
@@ -36,9 +40,27 @@
     "org.apache.commons:commons-imaging:1.0-alpha2",
     "org.glassfish:javax.el:3.0.1-b06",
     "org.hibernate:hibernate-validator:5.2.4.Final",
+    "org.jacoco:org.jacoco.core:0.8.8",
+    "org.junit.jupiter:junit-jupiter-api:5.9.0",
+    "org.junit.jupiter:junit-jupiter-engine:5.9.0",
+    "org.junit.jupiter:junit-jupiter-params:5.9.0",
+    "org.junit.platform:junit-platform-commons:jar:1.9.0",
+    "org.junit.platform:junit-platform-launcher:jar:1.9.0",
+    "org.junit.platform:junit-platform-engine:jar:1.9.0",
+    "org.junit.platform:junit-platform-reporting:jar:1.9.0",
+    "org.junit.platform:junit-platform-testkit:jar:1.9.0",
     "org.openjdk.jmh:jmh-core:1.34",
     "org.openjdk.jmh:jmh-generator-annprocess:1.34",
+    "org.opentest4j:opentest4j:jar:1.2.0",
+    "org.assertj:assertj-core:jar:3.23.1",
+    "org.mockito:mockito-core:5.2.0",
+    "org.apache.xmlgraphics:batik-bridge:1.14",
+    "org.apache.xmlgraphics:batik-util:1.14",
+    "org.apache.xmlgraphics:batik-anim:1.14",
+    "org.apache.xmlgraphics:batik-transcoder:1.14",
+    "org.apache.xmlgraphics:batik-css:1.14",
     maven.artifact("org.apache.logging.log4j", "log4j-api", "2.14.1", testonly = True),
     maven.artifact("org.apache.logging.log4j", "log4j-core", "2.14.1", testonly = True),
+    maven.artifact("org.apache.commons", "commons-text", "1.9", testonly = True),
     maven.artifact("com.h2database", "h2", "2.1.212", testonly = True),
 ]
diff --git a/maven_install.json b/maven_install.json
index cda4c94..588f75a 100644
--- a/maven_install.json
+++ b/maven_install.json
@@ -1,428 +1,2135 @@
 {
-    "dependency_tree": {
-        "__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL",
-        "__INPUT_ARTIFACTS_HASH": -921920920,
-        "__RESOLVED_ARTIFACTS_HASH": 43450148,
-        "conflict_resolution": {},
-        "dependencies": [
-            {
-                "coord": "com.alibaba:fastjson:1.2.75",
-                "dependencies": [],
-                "directDependencies": [],
-                "file": "v1/https/repo1.maven.org/maven2/com/alibaba/fastjson/1.2.75/fastjson-1.2.75.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/com/alibaba/fastjson/1.2.75/fastjson-1.2.75.jar"
-                ],
-                "sha256": "9ba58edc4473ee813eca9b8dd4a066378023b7b7e2e605823c07a16abfade189",
-                "url": "https://repo1.maven.org/maven2/com/alibaba/fastjson/1.2.75/fastjson-1.2.75.jar"
-            },
-            {
-                "coord": "com.beust:klaxon:5.5",
-                "dependencies": [
-                    "org.jetbrains.kotlin:kotlin-stdlib:1.4.31",
-                    "org.jetbrains:annotations:13.0",
-                    "org.jetbrains.kotlin:kotlin-reflect:1.4.31",
-                    "org.jetbrains.kotlin:kotlin-stdlib-common:1.4.31"
-                ],
-                "directDependencies": [
-                    "org.jetbrains.kotlin:kotlin-reflect:1.4.31",
-                    "org.jetbrains.kotlin:kotlin-stdlib:1.4.31"
-                ],
-                "file": "v1/https/repo1.maven.org/maven2/com/beust/klaxon/5.5/klaxon-5.5.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/com/beust/klaxon/5.5/klaxon-5.5.jar"
-                ],
-                "sha256": "7f70ecba1cdce3d0cea5c94eaae19e6d0c5a82181b6c24d3dd808a3419e83663",
-                "url": "https://repo1.maven.org/maven2/com/beust/klaxon/5.5/klaxon-5.5.jar"
-            },
-            {
-                "coord": "com.fasterxml.jackson.core:jackson-annotations:2.12.1",
-                "dependencies": [],
-                "directDependencies": [],
-                "file": "v1/https/repo1.maven.org/maven2/com/fasterxml/jackson/core/jackson-annotations/2.12.1/jackson-annotations-2.12.1.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/com/fasterxml/jackson/core/jackson-annotations/2.12.1/jackson-annotations-2.12.1.jar"
-                ],
-                "sha256": "203cefdfa6c81e6aa84e11f292f29ca97344a3c3bc0293abea065cd837592873",
-                "url": "https://repo1.maven.org/maven2/com/fasterxml/jackson/core/jackson-annotations/2.12.1/jackson-annotations-2.12.1.jar"
-            },
-            {
-                "coord": "com.fasterxml.jackson.core:jackson-core:2.12.1",
-                "dependencies": [],
-                "directDependencies": [],
-                "file": "v1/https/repo1.maven.org/maven2/com/fasterxml/jackson/core/jackson-core/2.12.1/jackson-core-2.12.1.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/com/fasterxml/jackson/core/jackson-core/2.12.1/jackson-core-2.12.1.jar"
-                ],
-                "sha256": "cc899cb6eae0c80b87d590eea86528797369cc4feb7b79463207d6bb18f0c257",
-                "url": "https://repo1.maven.org/maven2/com/fasterxml/jackson/core/jackson-core/2.12.1/jackson-core-2.12.1.jar"
-            },
-            {
-                "coord": "com.fasterxml.jackson.core:jackson-databind:2.12.1",
-                "dependencies": [
-                    "com.fasterxml.jackson.core:jackson-annotations:2.12.1",
-                    "com.fasterxml.jackson.core:jackson-core:2.12.1"
-                ],
-                "directDependencies": [
-                    "com.fasterxml.jackson.core:jackson-annotations:2.12.1",
-                    "com.fasterxml.jackson.core:jackson-core:2.12.1"
-                ],
-                "file": "v1/https/repo1.maven.org/maven2/com/fasterxml/jackson/core/jackson-databind/2.12.1/jackson-databind-2.12.1.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/com/fasterxml/jackson/core/jackson-databind/2.12.1/jackson-databind-2.12.1.jar"
-                ],
-                "sha256": "f2ca3c28ebded59c98447d51afe945323df961540af66a063c015597af936aa0",
-                "url": "https://repo1.maven.org/maven2/com/fasterxml/jackson/core/jackson-databind/2.12.1/jackson-databind-2.12.1.jar"
-            },
-            {
-                "coord": "com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.12.1",
-                "dependencies": [
-                    "com.fasterxml.jackson.core:jackson-annotations:2.12.1",
-                    "com.fasterxml.jackson.core:jackson-databind:2.12.1",
-                    "com.fasterxml.jackson.core:jackson-core:2.12.1"
-                ],
-                "directDependencies": [
-                    "com.fasterxml.jackson.core:jackson-core:2.12.1",
-                    "com.fasterxml.jackson.core:jackson-databind:2.12.1"
-                ],
-                "file": "v1/https/repo1.maven.org/maven2/com/fasterxml/jackson/dataformat/jackson-dataformat-cbor/2.12.1/jackson-dataformat-cbor-2.12.1.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/com/fasterxml/jackson/dataformat/jackson-dataformat-cbor/2.12.1/jackson-dataformat-cbor-2.12.1.jar"
-                ],
-                "sha256": "e76779aea9427ca73d7f407e5fa808a0405578ec056653a865c26e4c6f01d428",
-                "url": "https://repo1.maven.org/maven2/com/fasterxml/jackson/dataformat/jackson-dataformat-cbor/2.12.1/jackson-dataformat-cbor-2.12.1.jar"
-            },
-            {
-                "coord": "com.fasterxml:classmate:1.1.0",
-                "dependencies": [],
-                "directDependencies": [],
-                "file": "v1/https/repo1.maven.org/maven2/com/fasterxml/classmate/1.1.0/classmate-1.1.0.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/com/fasterxml/classmate/1.1.0/classmate-1.1.0.jar"
-                ],
-                "sha256": "610d23db8ece7268e93930562d89b91546c79fc80f3966baf433e5e93110b118",
-                "url": "https://repo1.maven.org/maven2/com/fasterxml/classmate/1.1.0/classmate-1.1.0.jar"
-            },
-            {
-                "coord": "com.github.jsqlparser:jsqlparser:4.4",
-                "dependencies": [],
-                "directDependencies": [],
-                "file": "v1/https/repo1.maven.org/maven2/com/github/jsqlparser/jsqlparser/4.4/jsqlparser-4.4.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/com/github/jsqlparser/jsqlparser/4.4/jsqlparser-4.4.jar"
-                ],
-                "sha256": "101e22917b22a339787fc85447ea057ea57b572e2a777a4628b6562354da117d",
-                "url": "https://repo1.maven.org/maven2/com/github/jsqlparser/jsqlparser/4.4/jsqlparser-4.4.jar"
-            },
-            {
-                "coord": "com.google.code.gson:gson:2.8.6",
-                "dependencies": [],
-                "directDependencies": [],
-                "file": "v1/https/repo1.maven.org/maven2/com/google/code/gson/gson/2.8.6/gson-2.8.6.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/com/google/code/gson/gson/2.8.6/gson-2.8.6.jar"
-                ],
-                "sha256": "c8fb4839054d280b3033f800d1f5a97de2f028eb8ba2eb458ad287e536f3f25f",
-                "url": "https://repo1.maven.org/maven2/com/google/code/gson/gson/2.8.6/gson-2.8.6.jar"
-            },
-            {
-                "coord": "com.h2database:h2:2.1.212",
-                "dependencies": [],
-                "directDependencies": [],
-                "file": "v1/https/repo1.maven.org/maven2/com/h2database/h2/2.1.212/h2-2.1.212.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/com/h2database/h2/2.1.212/h2-2.1.212.jar"
-                ],
-                "sha256": "db9284c6ff9bf3bc0087851edbd34563f1180df3ae87c67c5fe2203c0e67a536",
-                "url": "https://repo1.maven.org/maven2/com/h2database/h2/2.1.212/h2-2.1.212.jar"
-            },
-            {
-                "coord": "com.mikesamuel:json-sanitizer:1.2.1",
-                "dependencies": [],
-                "directDependencies": [],
-                "file": "v1/https/repo1.maven.org/maven2/com/mikesamuel/json-sanitizer/1.2.1/json-sanitizer-1.2.1.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/com/mikesamuel/json-sanitizer/1.2.1/json-sanitizer-1.2.1.jar"
-                ],
-                "sha256": "0f7d702ba2cdfebac1d4f1154d4b107f508d5920c268263087a5f4b80ddb7446",
-                "url": "https://repo1.maven.org/maven2/com/mikesamuel/json-sanitizer/1.2.1/json-sanitizer-1.2.1.jar"
-            },
-            {
-                "coord": "com.unboundid:unboundid-ldapsdk:6.0.3",
-                "dependencies": [],
-                "directDependencies": [],
-                "file": "v1/https/repo1.maven.org/maven2/com/unboundid/unboundid-ldapsdk/6.0.3/unboundid-ldapsdk-6.0.3.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/com/unboundid/unboundid-ldapsdk/6.0.3/unboundid-ldapsdk-6.0.3.jar"
-                ],
-                "sha256": "a635f130b482d8b02cc317632de762518d6bfedfecbd6972d1029124aaaf89d8",
-                "url": "https://repo1.maven.org/maven2/com/unboundid/unboundid-ldapsdk/6.0.3/unboundid-ldapsdk-6.0.3.jar"
-            },
-            {
-                "coord": "javax.activation:javax.activation-api:1.2.0",
-                "dependencies": [],
-                "directDependencies": [],
-                "file": "v1/https/repo1.maven.org/maven2/javax/activation/javax.activation-api/1.2.0/javax.activation-api-1.2.0.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/javax/activation/javax.activation-api/1.2.0/javax.activation-api-1.2.0.jar"
-                ],
-                "sha256": "43fdef0b5b6ceb31b0424b208b930c74ab58fac2ceeb7b3f6fd3aeb8b5ca4393",
-                "url": "https://repo1.maven.org/maven2/javax/activation/javax.activation-api/1.2.0/javax.activation-api-1.2.0.jar"
-            },
-            {
-                "coord": "javax.el:javax.el-api:3.0.1-b06",
-                "dependencies": [],
-                "directDependencies": [],
-                "file": "v1/https/repo1.maven.org/maven2/javax/el/javax.el-api/3.0.1-b06/javax.el-api-3.0.1-b06.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/javax/el/javax.el-api/3.0.1-b06/javax.el-api-3.0.1-b06.jar"
-                ],
-                "sha256": "0b46b36709ecbb9791ac4ba44d16125b9d65b576112afdaaa286052b6e498bc4",
-                "url": "https://repo1.maven.org/maven2/javax/el/javax.el-api/3.0.1-b06/javax.el-api-3.0.1-b06.jar"
-            },
-            {
-                "coord": "javax.validation:validation-api:2.0.1.Final",
-                "dependencies": [],
-                "directDependencies": [],
-                "file": "v1/https/repo1.maven.org/maven2/javax/validation/validation-api/2.0.1.Final/validation-api-2.0.1.Final.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/javax/validation/validation-api/2.0.1.Final/validation-api-2.0.1.Final.jar"
-                ],
-                "sha256": "9873b46df1833c9ee8f5bc1ff6853375115dadd8897bcb5a0dffb5848835ee6c",
-                "url": "https://repo1.maven.org/maven2/javax/validation/validation-api/2.0.1.Final/validation-api-2.0.1.Final.jar"
-            },
-            {
-                "coord": "javax.xml.bind:jaxb-api:2.3.1",
-                "dependencies": [
-                    "javax.activation:javax.activation-api:1.2.0"
-                ],
-                "directDependencies": [
-                    "javax.activation:javax.activation-api:1.2.0"
-                ],
-                "file": "v1/https/repo1.maven.org/maven2/javax/xml/bind/jaxb-api/2.3.1/jaxb-api-2.3.1.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/javax/xml/bind/jaxb-api/2.3.1/jaxb-api-2.3.1.jar"
-                ],
-                "sha256": "88b955a0df57880a26a74708bc34f74dcaf8ebf4e78843a28b50eae945732b06",
-                "url": "https://repo1.maven.org/maven2/javax/xml/bind/jaxb-api/2.3.1/jaxb-api-2.3.1.jar"
-            },
-            {
-                "coord": "junit:junit:4.12",
-                "dependencies": [
-                    "org.hamcrest:hamcrest-core:1.3"
-                ],
-                "directDependencies": [
-                    "org.hamcrest:hamcrest-core:1.3"
-                ],
-                "file": "v1/https/repo1.maven.org/maven2/junit/junit/4.12/junit-4.12.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/junit/junit/4.12/junit-4.12.jar"
-                ],
-                "sha256": "59721f0805e223d84b90677887d9ff567dc534d7c502ca903c0c2b17f05c116a",
-                "url": "https://repo1.maven.org/maven2/junit/junit/4.12/junit-4.12.jar"
-            },
-            {
-                "coord": "net.sf.jopt-simple:jopt-simple:5.0.4",
-                "dependencies": [],
-                "directDependencies": [],
-                "file": "v1/https/repo1.maven.org/maven2/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar"
-                ],
-                "sha256": "df26cc58f235f477db07f753ba5a3ab243ebe5789d9f89ecf68dd62ea9a66c28",
-                "url": "https://repo1.maven.org/maven2/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar"
-            },
-            {
-                "coord": "org.apache.commons:commons-imaging:1.0-alpha2",
-                "dependencies": [],
-                "directDependencies": [],
-                "file": "v1/https/repo1.maven.org/maven2/org/apache/commons/commons-imaging/1.0-alpha2/commons-imaging-1.0-alpha2.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/org/apache/commons/commons-imaging/1.0-alpha2/commons-imaging-1.0-alpha2.jar"
-                ],
-                "sha256": "64d649007364d70dcab24a1f895646e6976f5e2b339ba73a4af20642d041666a",
-                "url": "https://repo1.maven.org/maven2/org/apache/commons/commons-imaging/1.0-alpha2/commons-imaging-1.0-alpha2.jar"
-            },
-            {
-                "coord": "org.apache.commons:commons-math3:3.2",
-                "dependencies": [],
-                "directDependencies": [],
-                "file": "v1/https/repo1.maven.org/maven2/org/apache/commons/commons-math3/3.2/commons-math3-3.2.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/org/apache/commons/commons-math3/3.2/commons-math3-3.2.jar"
-                ],
-                "sha256": "6268a9a0ea3e769fc493a21446664c0ef668e48c93d126791f6f3f757978fee2",
-                "url": "https://repo1.maven.org/maven2/org/apache/commons/commons-math3/3.2/commons-math3-3.2.jar"
-            },
-            {
-                "coord": "org.apache.logging.log4j:log4j-api:2.14.1",
-                "dependencies": [],
-                "directDependencies": [],
-                "file": "v1/https/repo1.maven.org/maven2/org/apache/logging/log4j/log4j-api/2.14.1/log4j-api-2.14.1.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-api/2.14.1/log4j-api-2.14.1.jar"
-                ],
-                "sha256": "8caf58db006c609949a0068110395a33067a2bad707c3da35e959c0473f9a916",
-                "url": "https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-api/2.14.1/log4j-api-2.14.1.jar"
-            },
-            {
-                "coord": "org.apache.logging.log4j:log4j-core:2.14.1",
-                "dependencies": [
-                    "org.apache.logging.log4j:log4j-api:2.14.1"
-                ],
-                "directDependencies": [
-                    "org.apache.logging.log4j:log4j-api:2.14.1"
-                ],
-                "file": "v1/https/repo1.maven.org/maven2/org/apache/logging/log4j/log4j-core/2.14.1/log4j-core-2.14.1.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-core/2.14.1/log4j-core-2.14.1.jar"
-                ],
-                "sha256": "ade7402a70667a727635d5c4c29495f4ff96f061f12539763f6f123973b465b0",
-                "url": "https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-core/2.14.1/log4j-core-2.14.1.jar"
-            },
-            {
-                "coord": "org.glassfish:javax.el:3.0.1-b06",
-                "dependencies": [],
-                "directDependencies": [],
-                "file": "v1/https/repo1.maven.org/maven2/org/glassfish/javax.el/3.0.1-b06/javax.el-3.0.1-b06.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/org/glassfish/javax.el/3.0.1-b06/javax.el-3.0.1-b06.jar"
-                ],
-                "sha256": "c255fe3ff4d7e491caf92c10c497f3c77d19acc4832d9bd2e80180d168fcedd2",
-                "url": "https://repo1.maven.org/maven2/org/glassfish/javax.el/3.0.1-b06/javax.el-3.0.1-b06.jar"
-            },
-            {
-                "coord": "org.hamcrest:hamcrest-core:1.3",
-                "dependencies": [],
-                "directDependencies": [],
-                "file": "v1/https/repo1.maven.org/maven2/org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar"
-                ],
-                "sha256": "66fdef91e9739348df7a096aa384a5685f4e875584cce89386a7a47251c4d8e9",
-                "url": "https://repo1.maven.org/maven2/org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar"
-            },
-            {
-                "coord": "org.hibernate:hibernate-validator:5.2.4.Final",
-                "dependencies": [
-                    "org.jboss.logging:jboss-logging:3.2.1.Final",
-                    "com.fasterxml:classmate:1.1.0",
-                    "javax.validation:validation-api:2.0.1.Final"
-                ],
-                "directDependencies": [
-                    "com.fasterxml:classmate:1.1.0",
-                    "javax.validation:validation-api:2.0.1.Final",
-                    "org.jboss.logging:jboss-logging:3.2.1.Final"
-                ],
-                "file": "v1/https/repo1.maven.org/maven2/org/hibernate/hibernate-validator/5.2.4.Final/hibernate-validator-5.2.4.Final.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/org/hibernate/hibernate-validator/5.2.4.Final/hibernate-validator-5.2.4.Final.jar"
-                ],
-                "sha256": "fc7e2ed4079859f61390932a4f4cd5b2447e1ebc77d4915badb1a0655588697a",
-                "url": "https://repo1.maven.org/maven2/org/hibernate/hibernate-validator/5.2.4.Final/hibernate-validator-5.2.4.Final.jar"
-            },
-            {
-                "coord": "org.jboss.logging:jboss-logging:3.2.1.Final",
-                "dependencies": [],
-                "directDependencies": [],
-                "file": "v1/https/repo1.maven.org/maven2/org/jboss/logging/jboss-logging/3.2.1.Final/jboss-logging-3.2.1.Final.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/org/jboss/logging/jboss-logging/3.2.1.Final/jboss-logging-3.2.1.Final.jar"
-                ],
-                "sha256": "a3b0ffa8ae2b2f2387ebdfdce29086d3955d2a46ce7da802c2ba6ae47fa2f1bf",
-                "url": "https://repo1.maven.org/maven2/org/jboss/logging/jboss-logging/3.2.1.Final/jboss-logging-3.2.1.Final.jar"
-            },
-            {
-                "coord": "org.jetbrains.kotlin:kotlin-reflect:1.4.31",
-                "dependencies": [
-                    "org.jetbrains.kotlin:kotlin-stdlib:1.4.31",
-                    "org.jetbrains:annotations:13.0",
-                    "org.jetbrains.kotlin:kotlin-stdlib-common:1.4.31"
-                ],
-                "directDependencies": [
-                    "org.jetbrains.kotlin:kotlin-stdlib:1.4.31"
-                ],
-                "file": "v1/https/repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-reflect/1.4.31/kotlin-reflect-1.4.31.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-reflect/1.4.31/kotlin-reflect-1.4.31.jar"
-                ],
-                "sha256": "91fad0b42974a7d5811e30a61f05706e176b144235717c6de7e81e3a781028f2",
-                "url": "https://repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-reflect/1.4.31/kotlin-reflect-1.4.31.jar"
-            },
-            {
-                "coord": "org.jetbrains.kotlin:kotlin-stdlib-common:1.4.31",
-                "dependencies": [],
-                "directDependencies": [],
-                "file": "v1/https/repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-stdlib-common/1.4.31/kotlin-stdlib-common-1.4.31.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-stdlib-common/1.4.31/kotlin-stdlib-common-1.4.31.jar"
-                ],
-                "sha256": "57962f44371a746b678218a0802a8712c6255206de9a69ede215e3aa4b044708",
-                "url": "https://repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-stdlib-common/1.4.31/kotlin-stdlib-common-1.4.31.jar"
-            },
-            {
-                "coord": "org.jetbrains.kotlin:kotlin-stdlib:1.4.31",
-                "dependencies": [
-                    "org.jetbrains:annotations:13.0",
-                    "org.jetbrains.kotlin:kotlin-stdlib-common:1.4.31"
-                ],
-                "directDependencies": [
-                    "org.jetbrains:annotations:13.0",
-                    "org.jetbrains.kotlin:kotlin-stdlib-common:1.4.31"
-                ],
-                "file": "v1/https/repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-stdlib/1.4.31/kotlin-stdlib-1.4.31.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-stdlib/1.4.31/kotlin-stdlib-1.4.31.jar"
-                ],
-                "sha256": "76a599d88b167e8ac90879b6daa722c6ad3452ba714c9aba19bd196544b97f1c",
-                "url": "https://repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-stdlib/1.4.31/kotlin-stdlib-1.4.31.jar"
-            },
-            {
-                "coord": "org.jetbrains:annotations:13.0",
-                "dependencies": [],
-                "directDependencies": [],
-                "file": "v1/https/repo1.maven.org/maven2/org/jetbrains/annotations/13.0/annotations-13.0.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/org/jetbrains/annotations/13.0/annotations-13.0.jar"
-                ],
-                "sha256": "ace2a10dc8e2d5fd34925ecac03e4988b2c0f851650c94b8cef49ba1bd111478",
-                "url": "https://repo1.maven.org/maven2/org/jetbrains/annotations/13.0/annotations-13.0.jar"
-            },
-            {
-                "coord": "org.openjdk.jmh:jmh-core:1.34",
-                "dependencies": [
-                    "net.sf.jopt-simple:jopt-simple:5.0.4",
-                    "org.apache.commons:commons-math3:3.2"
-                ],
-                "directDependencies": [
-                    "net.sf.jopt-simple:jopt-simple:5.0.4",
-                    "org.apache.commons:commons-math3:3.2"
-                ],
-                "file": "v1/https/repo1.maven.org/maven2/org/openjdk/jmh/jmh-core/1.34/jmh-core-1.34.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/org/openjdk/jmh/jmh-core/1.34/jmh-core-1.34.jar"
-                ],
-                "sha256": "904384762d2ffeca8005aa9b432a7891a0e60c888bfd36f61dfcfa97c3a1d1b3",
-                "url": "https://repo1.maven.org/maven2/org/openjdk/jmh/jmh-core/1.34/jmh-core-1.34.jar"
-            },
-            {
-                "coord": "org.openjdk.jmh:jmh-generator-annprocess:1.34",
-                "dependencies": [
-                    "net.sf.jopt-simple:jopt-simple:5.0.4",
-                    "org.openjdk.jmh:jmh-core:1.34",
-                    "org.apache.commons:commons-math3:3.2"
-                ],
-                "directDependencies": [
-                    "org.openjdk.jmh:jmh-core:1.34"
-                ],
-                "file": "v1/https/repo1.maven.org/maven2/org/openjdk/jmh/jmh-generator-annprocess/1.34/jmh-generator-annprocess-1.34.jar",
-                "mirror_urls": [
-                    "https://repo1.maven.org/maven2/org/openjdk/jmh/jmh-generator-annprocess/1.34/jmh-generator-annprocess-1.34.jar"
-                ],
-                "sha256": "aa0feeefc0da59427b14c50139cba6deba211750e0033fdc39a5b3b8008b2900",
-                "url": "https://repo1.maven.org/maven2/org/openjdk/jmh/jmh-generator-annprocess/1.34/jmh-generator-annprocess-1.34.jar"
-            }
-        ],
-        "version": "0.1.0"
+  "__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL",
+  "__INPUT_ARTIFACTS_HASH": 1679182184,
+  "__RESOLVED_ARTIFACTS_HASH": -1745128755,
+  "conflict_resolution": {
+    "junit:junit:4.12": "junit:junit:4.13.2"
+  },
+  "artifacts": {
+    "com.alibaba:fastjson": {
+      "shasums": {
+        "jar": "9ba58edc4473ee813eca9b8dd4a066378023b7b7e2e605823c07a16abfade189"
+      },
+      "version": "1.2.75"
+    },
+    "com.beust:klaxon": {
+      "shasums": {
+        "jar": "7f70ecba1cdce3d0cea5c94eaae19e6d0c5a82181b6c24d3dd808a3419e83663"
+      },
+      "version": "5.5"
+    },
+    "com.fasterxml.jackson.core:jackson-annotations": {
+      "shasums": {
+        "jar": "203cefdfa6c81e6aa84e11f292f29ca97344a3c3bc0293abea065cd837592873"
+      },
+      "version": "2.12.1"
+    },
+    "com.fasterxml.jackson.core:jackson-core": {
+      "shasums": {
+        "jar": "cc899cb6eae0c80b87d590eea86528797369cc4feb7b79463207d6bb18f0c257"
+      },
+      "version": "2.12.1"
+    },
+    "com.fasterxml.jackson.core:jackson-databind": {
+      "shasums": {
+        "jar": "f2ca3c28ebded59c98447d51afe945323df961540af66a063c015597af936aa0"
+      },
+      "version": "2.12.1"
+    },
+    "com.fasterxml.jackson.dataformat:jackson-dataformat-cbor": {
+      "shasums": {
+        "jar": "e76779aea9427ca73d7f407e5fa808a0405578ec056653a865c26e4c6f01d428"
+      },
+      "version": "2.12.1"
+    },
+    "com.fasterxml:classmate": {
+      "shasums": {
+        "jar": "610d23db8ece7268e93930562d89b91546c79fc80f3966baf433e5e93110b118"
+      },
+      "version": "1.1.0"
+    },
+    "com.google.auto.value:auto-value-annotations": {
+      "shasums": {
+        "jar": "37ec09b47d7ed35a99d13927db5c86fc9071f620f943ead5d757144698310852"
+      },
+      "version": "1.8.1"
+    },
+    "com.google.code.findbugs:jsr305": {
+      "shasums": {
+        "jar": "766ad2a0783f2687962c8ad74ceecc38a28b9f72a2d085ee438b7813e928d0c7"
+      },
+      "version": "3.0.2"
+    },
+    "com.google.code.gson:gson": {
+      "shasums": {
+        "jar": "c8fb4839054d280b3033f800d1f5a97de2f028eb8ba2eb458ad287e536f3f25f"
+      },
+      "version": "2.8.6"
+    },
+    "com.google.errorprone:error_prone_annotations": {
+      "shasums": {
+        "jar": "cd5257c08a246cf8628817ae71cb822be192ef91f6881ca4a3fcff4f1de1cff3"
+      },
+      "version": "2.7.1"
+    },
+    "com.google.guava:failureaccess": {
+      "shasums": {
+        "jar": "a171ee4c734dd2da837e4b16be9df4661afab72a41adaf31eb84dfdaf936ca26"
+      },
+      "version": "1.0.1"
+    },
+    "com.google.guava:guava": {
+      "shasums": {
+        "jar": "355f79352f8c252f2bdaa06c687c4836a38016caccfc4c28d16ae77ecfdffa2f"
+      },
+      "version": "30.1.1-android"
+    },
+    "com.google.guava:listenablefuture": {
+      "shasums": {
+        "jar": "b372a037d4230aa57fbeffdef30fd6123f9c0c2db85d0aced00c91b974f33f99"
+      },
+      "version": "9999.0-empty-to-avoid-conflict-with-guava"
+    },
+    "com.google.j2objc:j2objc-annotations": {
+      "shasums": {
+        "jar": "21af30c92267bd6122c0e0b4d20cccb6641a37eaf956c6540ec471d584e64a7b"
+      },
+      "version": "1.3"
+    },
+    "com.google.protobuf:protobuf-java": {
+      "shasums": {
+        "jar": "9dcfc5c0b38326660d407375757c685dcd5883d67e06ed770bfc7966c12697dc"
+      },
+      "version": "3.17.1"
+    },
+    "com.google.truth.extensions:truth-java8-extension": {
+      "shasums": {
+        "jar": "2bbd32dd2fa9470d17f1bbda4f52b33b60bce4574052c1d46610a0aa371fc446"
+      },
+      "version": "1.1.3"
+    },
+    "com.google.truth.extensions:truth-liteproto-extension": {
+      "shasums": {
+        "jar": "71cce6284554e546d1b5ba48e310ee4b4050676f09fb0eced136d779284ff78d"
+      },
+      "version": "1.1.3"
+    },
+    "com.google.truth.extensions:truth-proto-extension": {
+      "shasums": {
+        "jar": "821993e4794e7034ae4a7b68105ef83f1913f0de6112f2fe4b5a7130f6a2bf49"
+      },
+      "version": "1.1.3"
+    },
+    "com.google.truth:truth": {
+      "shasums": {
+        "jar": "fc0b67782289a2aabfddfdf99eff1dcd5edc890d49143fcd489214b107b8f4f3"
+      },
+      "version": "1.1.3"
+    },
+    "com.h2database:h2": {
+      "shasums": {
+        "jar": "db9284c6ff9bf3bc0087851edbd34563f1180df3ae87c67c5fe2203c0e67a536"
+      },
+      "version": "2.1.212"
+    },
+    "com.mikesamuel:json-sanitizer": {
+      "shasums": {
+        "jar": "0f7d702ba2cdfebac1d4f1154d4b107f508d5920c268263087a5f4b80ddb7446"
+      },
+      "version": "1.2.1"
+    },
+    "com.unboundid:unboundid-ldapsdk": {
+      "shasums": {
+        "jar": "a635f130b482d8b02cc317632de762518d6bfedfecbd6972d1029124aaaf89d8"
+      },
+      "version": "6.0.3"
+    },
+    "commons-io:commons-io": {
+      "shasums": {
+        "jar": "3307319ddc221f1b23e8a1445aef10d2d2308e0ec46977b3f17cbb15c0ef335b"
+      },
+      "version": "1.3.1"
+    },
+    "commons-logging:commons-logging": {
+      "shasums": {
+        "jar": "e94af49749384c11f5aa50e8d0f5fe679be771295b52030338d32843c980351e"
+      },
+      "version": "1.0.4"
+    },
+    "javax.activation:javax.activation-api": {
+      "shasums": {
+        "jar": "43fdef0b5b6ceb31b0424b208b930c74ab58fac2ceeb7b3f6fd3aeb8b5ca4393"
+      },
+      "version": "1.2.0"
+    },
+    "javax.el:javax.el-api": {
+      "shasums": {
+        "jar": "0b46b36709ecbb9791ac4ba44d16125b9d65b576112afdaaa286052b6e498bc4"
+      },
+      "version": "3.0.1-b06"
+    },
+    "javax.validation:validation-api": {
+      "shasums": {
+        "jar": "9873b46df1833c9ee8f5bc1ff6853375115dadd8897bcb5a0dffb5848835ee6c"
+      },
+      "version": "2.0.1.Final"
+    },
+    "javax.xml.bind:jaxb-api": {
+      "shasums": {
+        "jar": "88b955a0df57880a26a74708bc34f74dcaf8ebf4e78843a28b50eae945732b06"
+      },
+      "version": "2.3.1"
+    },
+    "junit:junit": {
+      "shasums": {
+        "jar": "8e495b634469d64fb8acfa3495a065cbacc8a0fff55ce1e31007be4c16dc57d3"
+      },
+      "version": "4.13.2"
+    },
+    "net.bytebuddy:byte-buddy": {
+      "shasums": {
+        "jar": "63479f9a0a1b28f98313230d688a46b02bd80d09a700e127482d0bd635b47bad"
+      },
+      "version": "1.14.1"
+    },
+    "net.bytebuddy:byte-buddy-agent": {
+      "shasums": {
+        "jar": "f4809b9d0f00e71be98a61a25b3e14437f53f6821485694011beeb25e9231dde"
+      },
+      "version": "1.14.1"
+    },
+    "net.sf.jopt-simple:jopt-simple": {
+      "shasums": {
+        "jar": "df26cc58f235f477db07f753ba5a3ab243ebe5789d9f89ecf68dd62ea9a66c28"
+      },
+      "version": "5.0.4"
+    },
+    "org.apache.commons:commons-imaging": {
+      "shasums": {
+        "jar": "64d649007364d70dcab24a1f895646e6976f5e2b339ba73a4af20642d041666a"
+      },
+      "version": "1.0-alpha2"
+    },
+    "org.apache.commons:commons-lang3": {
+      "shasums": {
+        "jar": "4ee380259c068d1dbe9e84ab52186f2acd65de067ec09beff731fca1697fdb16"
+      },
+      "version": "3.11"
+    },
+    "org.apache.commons:commons-math3": {
+      "shasums": {
+        "jar": "6268a9a0ea3e769fc493a21446664c0ef668e48c93d126791f6f3f757978fee2"
+      },
+      "version": "3.2"
+    },
+    "org.apache.commons:commons-text": {
+      "shasums": {
+        "jar": "0812f284ac5dd0d617461d9a2ab6ac6811137f25122dfffd4788a4871e732d00"
+      },
+      "version": "1.9"
+    },
+    "org.apache.logging.log4j:log4j-api": {
+      "shasums": {
+        "jar": "8caf58db006c609949a0068110395a33067a2bad707c3da35e959c0473f9a916"
+      },
+      "version": "2.14.1"
+    },
+    "org.apache.logging.log4j:log4j-core": {
+      "shasums": {
+        "jar": "ade7402a70667a727635d5c4c29495f4ff96f061f12539763f6f123973b465b0"
+      },
+      "version": "2.14.1"
+    },
+    "org.apache.xmlgraphics:batik-anim": {
+      "shasums": {
+        "jar": "a1953099e04c202ee32d8e13912326d15cc488538051c691e897b0b7d2523b40"
+      },
+      "version": "1.14"
+    },
+    "org.apache.xmlgraphics:batik-awt-util": {
+      "shasums": {
+        "jar": "9cbaeae98dacad502aa2b08f206a5c04f14703afe9d99d905f0f7f8b733db5e7"
+      },
+      "version": "1.14"
+    },
+    "org.apache.xmlgraphics:batik-bridge": {
+      "shasums": {
+        "jar": "fc137699f14f9289732d4ff8214f0a14a7ea4f5ea7a6b24b745d9d6d943c259e"
+      },
+      "version": "1.14"
+    },
+    "org.apache.xmlgraphics:batik-constants": {
+      "shasums": {
+        "jar": "7882eb789257905413bcb0adcb1562dd50b8103cadef9534c33612bf51527990"
+      },
+      "version": "1.14"
+    },
+    "org.apache.xmlgraphics:batik-css": {
+      "shasums": {
+        "jar": "968ba271cab6dfdd0458eb9ff42cc51e258d471499225b9063edbda61becbe17"
+      },
+      "version": "1.14"
+    },
+    "org.apache.xmlgraphics:batik-dom": {
+      "shasums": {
+        "jar": "6b71b91514f2cbe9b9e46f9699803c1fe7434addf61388b07755d586e20c57de"
+      },
+      "version": "1.14"
+    },
+    "org.apache.xmlgraphics:batik-ext": {
+      "shasums": {
+        "jar": "1f74fc638058c5d05b6da7b207e895d1a04c19d64250ae9f52c059689e681a7a"
+      },
+      "version": "1.14"
+    },
+    "org.apache.xmlgraphics:batik-gvt": {
+      "shasums": {
+        "jar": "5230e4339035867bbb9c82683a065fb2abe135779f57c43a70b7766395aeab38"
+      },
+      "version": "1.14"
+    },
+    "org.apache.xmlgraphics:batik-i18n": {
+      "shasums": {
+        "jar": "fb1ad02ccaa36f5a60c4115316e15ac071386f96445e5d89bdad0c7e45da9560"
+      },
+      "version": "1.14"
+    },
+    "org.apache.xmlgraphics:batik-parser": {
+      "shasums": {
+        "jar": "aca5e08b52e54af0a1acaf9c134082a0ed0b357b8eef74de369bdba054d1e1e2"
+      },
+      "version": "1.14"
+    },
+    "org.apache.xmlgraphics:batik-script": {
+      "shasums": {
+        "jar": "d3584655227b4f1b56beea081a5b0b1fa228aeb165f19895f10dfbae999bc4e4"
+      },
+      "version": "1.14"
+    },
+    "org.apache.xmlgraphics:batik-shared-resources": {
+      "shasums": {
+        "jar": "987ceb566e2101418465bb3227ec60812b71e7c4b2c3e842d977efa732fa90f5"
+      },
+      "version": "1.14"
+    },
+    "org.apache.xmlgraphics:batik-svg-dom": {
+      "shasums": {
+        "jar": "c6023eca4fe1c6c2616173d2308217713c76895b780167f19382db0e57eac412"
+      },
+      "version": "1.14"
+    },
+    "org.apache.xmlgraphics:batik-svggen": {
+      "shasums": {
+        "jar": "c2a7fba84eddc3815992a1f14f554feeccaabf9c1730867b28569dcbbbc272e2"
+      },
+      "version": "1.14"
+    },
+    "org.apache.xmlgraphics:batik-transcoder": {
+      "shasums": {
+        "jar": "c5f863137fbfe440911b376a0a3e605c9e1b196d1b4a83854007e303a95b9889"
+      },
+      "version": "1.14"
+    },
+    "org.apache.xmlgraphics:batik-util": {
+      "shasums": {
+        "jar": "ad76103ecb3fcad91aac1cd0a34f6d46cd1e4224d0406a5d58b269a64f6c3788"
+      },
+      "version": "1.14"
+    },
+    "org.apache.xmlgraphics:batik-xml": {
+      "shasums": {
+        "jar": "673a82f56185a023e5196a59eb16f8503071477af4a82bb58e7267bb3d4ff828"
+      },
+      "version": "1.14"
+    },
+    "org.apache.xmlgraphics:xmlgraphics-commons": {
+      "shasums": {
+        "jar": "25f21c93462d767d05e340f1dc754862995b9bf8b4618ab5b07cd703d400d413"
+      },
+      "version": "2.6"
+    },
+    "org.apiguardian:apiguardian-api": {
+      "shasums": {
+        "jar": "b509448ac506d607319f182537f0b35d71007582ec741832a1f111e5b5b70b38"
+      },
+      "version": "1.1.2"
+    },
+    "org.assertj:assertj-core": {
+      "shasums": {
+        "jar": "36af798af9fc20537669e02618bd39f2c797f4813824ef222108cb686fa4c88e"
+      },
+      "version": "3.23.1"
+    },
+    "org.checkerframework:checker-compat-qual": {
+      "shasums": {
+        "jar": "11d134b245e9cacc474514d2d66b5b8618f8039a1465cdc55bbc0b34e0008b7a"
+      },
+      "version": "2.5.5"
+    },
+    "org.checkerframework:checker-qual": {
+      "shasums": {
+        "jar": "3ea0dcd73b4d6cb2fb34bd7ed4dad6db327a01ebad7db05eb7894076b3d64491"
+      },
+      "version": "3.13.0"
+    },
+    "org.glassfish:javax.el": {
+      "shasums": {
+        "jar": "c255fe3ff4d7e491caf92c10c497f3c77d19acc4832d9bd2e80180d168fcedd2"
+      },
+      "version": "3.0.1-b06"
+    },
+    "org.hamcrest:hamcrest-core": {
+      "shasums": {
+        "jar": "66fdef91e9739348df7a096aa384a5685f4e875584cce89386a7a47251c4d8e9"
+      },
+      "version": "1.3"
+    },
+    "org.hibernate:hibernate-validator": {
+      "shasums": {
+        "jar": "fc7e2ed4079859f61390932a4f4cd5b2447e1ebc77d4915badb1a0655588697a"
+      },
+      "version": "5.2.4.Final"
+    },
+    "org.jacoco:org.jacoco.core": {
+      "shasums": {
+        "jar": "474c782f809d88924713dfdbf0acb79d330f904be576484803463d0465611643"
+      },
+      "version": "0.8.8"
+    },
+    "org.jboss.logging:jboss-logging": {
+      "shasums": {
+        "jar": "a3b0ffa8ae2b2f2387ebdfdce29086d3955d2a46ce7da802c2ba6ae47fa2f1bf"
+      },
+      "version": "3.2.1.Final"
+    },
+    "org.jetbrains.kotlin:kotlin-reflect": {
+      "shasums": {
+        "jar": "91fad0b42974a7d5811e30a61f05706e176b144235717c6de7e81e3a781028f2"
+      },
+      "version": "1.4.31"
+    },
+    "org.jetbrains.kotlin:kotlin-stdlib": {
+      "shasums": {
+        "jar": "76a599d88b167e8ac90879b6daa722c6ad3452ba714c9aba19bd196544b97f1c"
+      },
+      "version": "1.4.31"
+    },
+    "org.jetbrains.kotlin:kotlin-stdlib-common": {
+      "shasums": {
+        "jar": "57962f44371a746b678218a0802a8712c6255206de9a69ede215e3aa4b044708"
+      },
+      "version": "1.4.31"
+    },
+    "org.jetbrains:annotations": {
+      "shasums": {
+        "jar": "ace2a10dc8e2d5fd34925ecac03e4988b2c0f851650c94b8cef49ba1bd111478"
+      },
+      "version": "13.0"
+    },
+    "org.junit.jupiter:junit-jupiter-api": {
+      "shasums": {
+        "jar": "3e370bcbb1e857fda5f0b203724116d02b05e788faa1eb2518814accf9cfb5b1"
+      },
+      "version": "5.9.0"
+    },
+    "org.junit.jupiter:junit-jupiter-engine": {
+      "shasums": {
+        "jar": "db86cbb3352719fa0a97800edfd09c20463c7f2ab4a04699244430bd8954583b"
+      },
+      "version": "5.9.0"
+    },
+    "org.junit.jupiter:junit-jupiter-params": {
+      "shasums": {
+        "jar": "b8cef7982dd53df84c957a6e9ac89ede967cf2e6d9340ef4c51786e20548c41b"
+      },
+      "version": "5.9.0"
+    },
+    "org.junit.platform:junit-platform-commons": {
+      "shasums": {
+        "jar": "e5894b710094b4caafc6280b8829a439fb764901ea0ae18d06ed80388b309b7a"
+      },
+      "version": "1.9.0"
+    },
+    "org.junit.platform:junit-platform-engine": {
+      "shasums": {
+        "jar": "aaec735f7444a9fc055e206598de3d829c24e9c7a8eea6efdeeb1962087fe811"
+      },
+      "version": "1.9.0"
+    },
+    "org.junit.platform:junit-platform-launcher": {
+      "shasums": {
+        "jar": "13000d464938249dce876d2c783c5319ad8863da2b214bcb9001f8c0ee491214"
+      },
+      "version": "1.9.0"
+    },
+    "org.junit.platform:junit-platform-reporting": {
+      "shasums": {
+        "jar": "e2607d31b971b4dae6dc64cdf440d74354fd0dcf56eb00461ccae7cbf213f810"
+      },
+      "version": "1.9.0"
+    },
+    "org.junit.platform:junit-platform-testkit": {
+      "shasums": {
+        "jar": "2c48473abe076df4df7b1f2b6f5f37c3434d55a5b6e0e1d245ddfd207251750c"
+      },
+      "version": "1.9.0"
+    },
+    "org.mockito:mockito-core": {
+      "shasums": {
+        "jar": "46e3f8dacd8ec62c8aa6fb11f8867624fb44a03e97fdfc628609346d5dc7e159"
+      },
+      "version": "5.2.0"
+    },
+    "org.objenesis:objenesis": {
+      "shasums": {
+        "jar": "02dfd0b0439a5591e35b708ed2f5474eb0948f53abf74637e959b8e4ef69bfeb"
+      },
+      "version": "3.3"
+    },
+    "org.openjdk.jmh:jmh-core": {
+      "shasums": {
+        "jar": "904384762d2ffeca8005aa9b432a7891a0e60c888bfd36f61dfcfa97c3a1d1b3"
+      },
+      "version": "1.34"
+    },
+    "org.openjdk.jmh:jmh-generator-annprocess": {
+      "shasums": {
+        "jar": "aa0feeefc0da59427b14c50139cba6deba211750e0033fdc39a5b3b8008b2900"
+      },
+      "version": "1.34"
+    },
+    "org.opentest4j:opentest4j": {
+      "shasums": {
+        "jar": "58812de60898d976fb81ef3b62da05c6604c18fd4a249f5044282479fc286af2"
+      },
+      "version": "1.2.0"
+    },
+    "org.ow2.asm:asm": {
+      "shasums": {
+        "jar": "b9d4fe4d71938df38839f0eca42aaaa64cf8b313d678da036f0cb3ca199b47f5"
+      },
+      "version": "9.2"
+    },
+    "org.ow2.asm:asm-analysis": {
+      "shasums": {
+        "jar": "878fbe521731c072d14d2d65b983b1beae6ad06fda0007b6a8bae81f73f433c4"
+      },
+      "version": "9.2"
+    },
+    "org.ow2.asm:asm-commons": {
+      "shasums": {
+        "jar": "be4ce53138a238bb522cd781cf91f3ba5ce2f6ca93ec62d46a162a127225e0a6"
+      },
+      "version": "9.2"
+    },
+    "org.ow2.asm:asm-tree": {
+      "shasums": {
+        "jar": "aabf9bd23091a4ebfc109c1f3ee7cf3e4b89f6ba2d3f51c5243f16b3cffae011"
+      },
+      "version": "9.2"
+    },
+    "xalan:serializer": {
+      "shasums": {
+        "jar": "e8f5b4340d3b12a0cfa44ac2db4be4e0639e479ae847df04c4ed8b521734bb4a"
+      },
+      "version": "2.7.2"
+    },
+    "xalan:xalan": {
+      "shasums": {
+        "jar": "a44bd80e82cb0f4cfac0dac8575746223802514e3cec9dc75235bc0de646af14"
+      },
+      "version": "2.7.2"
+    },
+    "xml-apis:xml-apis": {
+      "shasums": {
+        "jar": "a840968176645684bb01aed376e067ab39614885f9eee44abe35a5f20ebe7fad"
+      },
+      "version": "1.4.01"
+    },
+    "xml-apis:xml-apis-ext": {
+      "shasums": {
+        "jar": "d0b4887dc34d57de49074a58affad439a013d0baffa1a8034f8ef2a5ea191646"
+      },
+      "version": "1.3.04"
     }
+  },
+  "dependencies": {
+    "com.beust:klaxon": [
+      "org.jetbrains.kotlin:kotlin-reflect",
+      "org.jetbrains.kotlin:kotlin-stdlib"
+    ],
+    "com.fasterxml.jackson.core:jackson-databind": [
+      "com.fasterxml.jackson.core:jackson-annotations",
+      "com.fasterxml.jackson.core:jackson-core"
+    ],
+    "com.fasterxml.jackson.dataformat:jackson-dataformat-cbor": [
+      "com.fasterxml.jackson.core:jackson-core",
+      "com.fasterxml.jackson.core:jackson-databind"
+    ],
+    "com.google.guava:guava": [
+      "com.google.code.findbugs:jsr305",
+      "com.google.errorprone:error_prone_annotations",
+      "com.google.guava:failureaccess",
+      "com.google.guava:listenablefuture",
+      "com.google.j2objc:j2objc-annotations",
+      "org.checkerframework:checker-compat-qual"
+    ],
+    "com.google.truth.extensions:truth-java8-extension": [
+      "com.google.truth:truth",
+      "org.checkerframework:checker-qual"
+    ],
+    "com.google.truth.extensions:truth-liteproto-extension": [
+      "com.google.auto.value:auto-value-annotations",
+      "com.google.errorprone:error_prone_annotations",
+      "com.google.guava:guava",
+      "com.google.truth:truth",
+      "org.checkerframework:checker-qual"
+    ],
+    "com.google.truth.extensions:truth-proto-extension": [
+      "com.google.auto.value:auto-value-annotations",
+      "com.google.errorprone:error_prone_annotations",
+      "com.google.guava:guava",
+      "com.google.protobuf:protobuf-java",
+      "com.google.truth.extensions:truth-liteproto-extension",
+      "com.google.truth:truth",
+      "org.checkerframework:checker-qual"
+    ],
+    "com.google.truth:truth": [
+      "com.google.auto.value:auto-value-annotations",
+      "com.google.errorprone:error_prone_annotations",
+      "com.google.guava:guava",
+      "junit:junit",
+      "org.checkerframework:checker-qual",
+      "org.ow2.asm:asm"
+    ],
+    "javax.xml.bind:jaxb-api": [
+      "javax.activation:javax.activation-api"
+    ],
+    "junit:junit": [
+      "org.hamcrest:hamcrest-core"
+    ],
+    "org.apache.commons:commons-text": [
+      "org.apache.commons:commons-lang3"
+    ],
+    "org.apache.logging.log4j:log4j-core": [
+      "org.apache.logging.log4j:log4j-api"
+    ],
+    "org.apache.xmlgraphics:batik-anim": [
+      "org.apache.xmlgraphics:batik-awt-util",
+      "org.apache.xmlgraphics:batik-css",
+      "org.apache.xmlgraphics:batik-dom",
+      "org.apache.xmlgraphics:batik-ext",
+      "org.apache.xmlgraphics:batik-parser",
+      "org.apache.xmlgraphics:batik-shared-resources",
+      "org.apache.xmlgraphics:batik-svg-dom",
+      "org.apache.xmlgraphics:batik-util",
+      "xml-apis:xml-apis-ext"
+    ],
+    "org.apache.xmlgraphics:batik-awt-util": [
+      "org.apache.xmlgraphics:batik-shared-resources",
+      "org.apache.xmlgraphics:batik-util",
+      "org.apache.xmlgraphics:xmlgraphics-commons"
+    ],
+    "org.apache.xmlgraphics:batik-bridge": [
+      "org.apache.xmlgraphics:batik-anim",
+      "org.apache.xmlgraphics:batik-awt-util",
+      "org.apache.xmlgraphics:batik-css",
+      "org.apache.xmlgraphics:batik-dom",
+      "org.apache.xmlgraphics:batik-gvt",
+      "org.apache.xmlgraphics:batik-parser",
+      "org.apache.xmlgraphics:batik-script",
+      "org.apache.xmlgraphics:batik-shared-resources",
+      "org.apache.xmlgraphics:batik-svg-dom",
+      "org.apache.xmlgraphics:batik-util",
+      "org.apache.xmlgraphics:batik-xml",
+      "org.apache.xmlgraphics:xmlgraphics-commons",
+      "xml-apis:xml-apis-ext"
+    ],
+    "org.apache.xmlgraphics:batik-constants": [
+      "org.apache.xmlgraphics:batik-shared-resources"
+    ],
+    "org.apache.xmlgraphics:batik-css": [
+      "org.apache.xmlgraphics:batik-shared-resources",
+      "org.apache.xmlgraphics:batik-util",
+      "org.apache.xmlgraphics:xmlgraphics-commons",
+      "xml-apis:xml-apis-ext"
+    ],
+    "org.apache.xmlgraphics:batik-dom": [
+      "org.apache.xmlgraphics:batik-css",
+      "org.apache.xmlgraphics:batik-ext",
+      "org.apache.xmlgraphics:batik-shared-resources",
+      "org.apache.xmlgraphics:batik-util",
+      "org.apache.xmlgraphics:batik-xml",
+      "xalan:xalan",
+      "xml-apis:xml-apis",
+      "xml-apis:xml-apis-ext"
+    ],
+    "org.apache.xmlgraphics:batik-ext": [
+      "org.apache.xmlgraphics:batik-shared-resources"
+    ],
+    "org.apache.xmlgraphics:batik-gvt": [
+      "org.apache.xmlgraphics:batik-awt-util",
+      "org.apache.xmlgraphics:batik-shared-resources",
+      "org.apache.xmlgraphics:batik-util"
+    ],
+    "org.apache.xmlgraphics:batik-i18n": [
+      "org.apache.xmlgraphics:batik-shared-resources"
+    ],
+    "org.apache.xmlgraphics:batik-parser": [
+      "org.apache.xmlgraphics:batik-awt-util",
+      "org.apache.xmlgraphics:batik-shared-resources",
+      "org.apache.xmlgraphics:batik-util",
+      "org.apache.xmlgraphics:batik-xml",
+      "xml-apis:xml-apis-ext"
+    ],
+    "org.apache.xmlgraphics:batik-script": [
+      "org.apache.xmlgraphics:batik-anim",
+      "org.apache.xmlgraphics:batik-shared-resources",
+      "org.apache.xmlgraphics:batik-util"
+    ],
+    "org.apache.xmlgraphics:batik-svg-dom": [
+      "org.apache.xmlgraphics:batik-awt-util",
+      "org.apache.xmlgraphics:batik-css",
+      "org.apache.xmlgraphics:batik-dom",
+      "org.apache.xmlgraphics:batik-ext",
+      "org.apache.xmlgraphics:batik-parser",
+      "org.apache.xmlgraphics:batik-shared-resources",
+      "org.apache.xmlgraphics:batik-util",
+      "xml-apis:xml-apis-ext"
+    ],
+    "org.apache.xmlgraphics:batik-svggen": [
+      "org.apache.xmlgraphics:batik-awt-util",
+      "org.apache.xmlgraphics:batik-shared-resources",
+      "org.apache.xmlgraphics:batik-util"
+    ],
+    "org.apache.xmlgraphics:batik-transcoder": [
+      "org.apache.xmlgraphics:batik-anim",
+      "org.apache.xmlgraphics:batik-awt-util",
+      "org.apache.xmlgraphics:batik-bridge",
+      "org.apache.xmlgraphics:batik-dom",
+      "org.apache.xmlgraphics:batik-gvt",
+      "org.apache.xmlgraphics:batik-shared-resources",
+      "org.apache.xmlgraphics:batik-svggen",
+      "org.apache.xmlgraphics:batik-util",
+      "org.apache.xmlgraphics:batik-xml",
+      "xml-apis:xml-apis-ext"
+    ],
+    "org.apache.xmlgraphics:batik-util": [
+      "org.apache.xmlgraphics:batik-constants",
+      "org.apache.xmlgraphics:batik-i18n",
+      "org.apache.xmlgraphics:batik-shared-resources"
+    ],
+    "org.apache.xmlgraphics:batik-xml": [
+      "org.apache.xmlgraphics:batik-shared-resources",
+      "org.apache.xmlgraphics:batik-util"
+    ],
+    "org.apache.xmlgraphics:xmlgraphics-commons": [
+      "commons-io:commons-io",
+      "commons-logging:commons-logging"
+    ],
+    "org.assertj:assertj-core": [
+      "net.bytebuddy:byte-buddy"
+    ],
+    "org.hibernate:hibernate-validator": [
+      "com.fasterxml:classmate",
+      "javax.validation:validation-api",
+      "org.jboss.logging:jboss-logging"
+    ],
+    "org.jacoco:org.jacoco.core": [
+      "org.ow2.asm:asm",
+      "org.ow2.asm:asm-commons",
+      "org.ow2.asm:asm-tree"
+    ],
+    "org.jetbrains.kotlin:kotlin-reflect": [
+      "org.jetbrains.kotlin:kotlin-stdlib"
+    ],
+    "org.jetbrains.kotlin:kotlin-stdlib": [
+      "org.jetbrains.kotlin:kotlin-stdlib-common",
+      "org.jetbrains:annotations"
+    ],
+    "org.junit.jupiter:junit-jupiter-api": [
+      "org.apiguardian:apiguardian-api",
+      "org.junit.platform:junit-platform-commons",
+      "org.opentest4j:opentest4j"
+    ],
+    "org.junit.jupiter:junit-jupiter-engine": [
+      "org.apiguardian:apiguardian-api",
+      "org.junit.jupiter:junit-jupiter-api",
+      "org.junit.platform:junit-platform-engine"
+    ],
+    "org.junit.jupiter:junit-jupiter-params": [
+      "org.apiguardian:apiguardian-api",
+      "org.junit.jupiter:junit-jupiter-api"
+    ],
+    "org.junit.platform:junit-platform-commons": [
+      "org.apiguardian:apiguardian-api"
+    ],
+    "org.junit.platform:junit-platform-engine": [
+      "org.apiguardian:apiguardian-api",
+      "org.junit.platform:junit-platform-commons",
+      "org.opentest4j:opentest4j"
+    ],
+    "org.junit.platform:junit-platform-launcher": [
+      "org.apiguardian:apiguardian-api",
+      "org.junit.platform:junit-platform-engine"
+    ],
+    "org.junit.platform:junit-platform-reporting": [
+      "org.apiguardian:apiguardian-api",
+      "org.junit.platform:junit-platform-launcher"
+    ],
+    "org.junit.platform:junit-platform-testkit": [
+      "org.apiguardian:apiguardian-api",
+      "org.assertj:assertj-core",
+      "org.junit.platform:junit-platform-launcher",
+      "org.opentest4j:opentest4j"
+    ],
+    "org.mockito:mockito-core": [
+      "net.bytebuddy:byte-buddy",
+      "net.bytebuddy:byte-buddy-agent",
+      "org.objenesis:objenesis"
+    ],
+    "org.openjdk.jmh:jmh-core": [
+      "net.sf.jopt-simple:jopt-simple",
+      "org.apache.commons:commons-math3"
+    ],
+    "org.openjdk.jmh:jmh-generator-annprocess": [
+      "org.openjdk.jmh:jmh-core"
+    ],
+    "org.ow2.asm:asm-analysis": [
+      "org.ow2.asm:asm-tree"
+    ],
+    "org.ow2.asm:asm-commons": [
+      "org.ow2.asm:asm",
+      "org.ow2.asm:asm-analysis",
+      "org.ow2.asm:asm-tree"
+    ],
+    "org.ow2.asm:asm-tree": [
+      "org.ow2.asm:asm"
+    ],
+    "xalan:serializer": [
+      "xml-apis:xml-apis"
+    ],
+    "xalan:xalan": [
+      "xalan:serializer"
+    ]
+  },
+  "packages": {
+    "com.alibaba:fastjson": [
+      "com.alibaba.fastjson",
+      "com.alibaba.fastjson.annotation",
+      "com.alibaba.fastjson.asm",
+      "com.alibaba.fastjson.parser",
+      "com.alibaba.fastjson.parser.deserializer",
+      "com.alibaba.fastjson.serializer",
+      "com.alibaba.fastjson.spi",
+      "com.alibaba.fastjson.support.config",
+      "com.alibaba.fastjson.support.geo",
+      "com.alibaba.fastjson.support.hsf",
+      "com.alibaba.fastjson.support.jaxrs",
+      "com.alibaba.fastjson.support.moneta",
+      "com.alibaba.fastjson.support.retrofit",
+      "com.alibaba.fastjson.support.spring",
+      "com.alibaba.fastjson.support.spring.annotation",
+      "com.alibaba.fastjson.support.spring.messaging",
+      "com.alibaba.fastjson.support.springfox",
+      "com.alibaba.fastjson.util"
+    ],
+    "com.beust:klaxon": [
+      "com.beust.klaxon",
+      "com.beust.klaxon.internal",
+      "com.beust.klaxon.token"
+    ],
+    "com.fasterxml.jackson.core:jackson-annotations": [
+      "com.fasterxml.jackson.annotation"
+    ],
+    "com.fasterxml.jackson.core:jackson-core": [
+      "com.fasterxml.jackson.core",
+      "com.fasterxml.jackson.core.async",
+      "com.fasterxml.jackson.core.base",
+      "com.fasterxml.jackson.core.exc",
+      "com.fasterxml.jackson.core.filter",
+      "com.fasterxml.jackson.core.format",
+      "com.fasterxml.jackson.core.io",
+      "com.fasterxml.jackson.core.json",
+      "com.fasterxml.jackson.core.json.async",
+      "com.fasterxml.jackson.core.sym",
+      "com.fasterxml.jackson.core.type",
+      "com.fasterxml.jackson.core.util"
+    ],
+    "com.fasterxml.jackson.core:jackson-databind": [
+      "com.fasterxml.jackson.databind",
+      "com.fasterxml.jackson.databind.annotation",
+      "com.fasterxml.jackson.databind.cfg",
+      "com.fasterxml.jackson.databind.deser",
+      "com.fasterxml.jackson.databind.deser.impl",
+      "com.fasterxml.jackson.databind.deser.std",
+      "com.fasterxml.jackson.databind.exc",
+      "com.fasterxml.jackson.databind.ext",
+      "com.fasterxml.jackson.databind.introspect",
+      "com.fasterxml.jackson.databind.jdk14",
+      "com.fasterxml.jackson.databind.json",
+      "com.fasterxml.jackson.databind.jsonFormatVisitors",
+      "com.fasterxml.jackson.databind.jsonschema",
+      "com.fasterxml.jackson.databind.jsontype",
+      "com.fasterxml.jackson.databind.jsontype.impl",
+      "com.fasterxml.jackson.databind.module",
+      "com.fasterxml.jackson.databind.node",
+      "com.fasterxml.jackson.databind.ser",
+      "com.fasterxml.jackson.databind.ser.impl",
+      "com.fasterxml.jackson.databind.ser.std",
+      "com.fasterxml.jackson.databind.type",
+      "com.fasterxml.jackson.databind.util"
+    ],
+    "com.fasterxml.jackson.dataformat:jackson-dataformat-cbor": [
+      "com.fasterxml.jackson.dataformat.cbor",
+      "com.fasterxml.jackson.dataformat.cbor.databind"
+    ],
+    "com.fasterxml:classmate": [
+      "com.fasterxml.classmate",
+      "com.fasterxml.classmate.members",
+      "com.fasterxml.classmate.types",
+      "com.fasterxml.classmate.util"
+    ],
+    "com.google.auto.value:auto-value-annotations": [
+      "com.google.auto.value",
+      "com.google.auto.value.extension.memoized",
+      "com.google.auto.value.extension.serializable",
+      "com.google.auto.value.extension.toprettystring"
+    ],
+    "com.google.code.findbugs:jsr305": [
+      "javax.annotation",
+      "javax.annotation.concurrent",
+      "javax.annotation.meta"
+    ],
+    "com.google.code.gson:gson": [
+      "com.google.gson",
+      "com.google.gson.annotations",
+      "com.google.gson.internal",
+      "com.google.gson.internal.bind",
+      "com.google.gson.internal.bind.util",
+      "com.google.gson.internal.reflect",
+      "com.google.gson.reflect",
+      "com.google.gson.stream"
+    ],
+    "com.google.errorprone:error_prone_annotations": [
+      "com.google.errorprone.annotations",
+      "com.google.errorprone.annotations.concurrent"
+    ],
+    "com.google.guava:failureaccess": [
+      "com.google.common.util.concurrent.internal"
+    ],
+    "com.google.guava:guava": [
+      "com.google.common.annotations",
+      "com.google.common.base",
+      "com.google.common.base.internal",
+      "com.google.common.cache",
+      "com.google.common.collect",
+      "com.google.common.escape",
+      "com.google.common.eventbus",
+      "com.google.common.graph",
+      "com.google.common.hash",
+      "com.google.common.html",
+      "com.google.common.io",
+      "com.google.common.math",
+      "com.google.common.net",
+      "com.google.common.primitives",
+      "com.google.common.reflect",
+      "com.google.common.util.concurrent",
+      "com.google.common.xml",
+      "com.google.thirdparty.publicsuffix"
+    ],
+    "com.google.j2objc:j2objc-annotations": [
+      "com.google.j2objc.annotations"
+    ],
+    "com.google.protobuf:protobuf-java": [
+      "com.google.protobuf",
+      "com.google.protobuf.compiler"
+    ],
+    "com.google.truth.extensions:truth-java8-extension": [
+      "com.google.common.truth"
+    ],
+    "com.google.truth.extensions:truth-liteproto-extension": [
+      "com.google.common.truth.extensions.proto"
+    ],
+    "com.google.truth.extensions:truth-proto-extension": [
+      "com.google.common.truth.extensions.proto"
+    ],
+    "com.google.truth:truth": [
+      "com.google.common.truth"
+    ],
+    "com.h2database:h2": [
+      "org.h2",
+      "org.h2.api",
+      "org.h2.bnf",
+      "org.h2.bnf.context",
+      "org.h2.command",
+      "org.h2.command.ddl",
+      "org.h2.command.dml",
+      "org.h2.command.query",
+      "org.h2.compress",
+      "org.h2.constraint",
+      "org.h2.engine",
+      "org.h2.expression",
+      "org.h2.expression.aggregate",
+      "org.h2.expression.analysis",
+      "org.h2.expression.condition",
+      "org.h2.expression.function",
+      "org.h2.expression.function.table",
+      "org.h2.fulltext",
+      "org.h2.index",
+      "org.h2.jdbc",
+      "org.h2.jdbc.meta",
+      "org.h2.jdbcx",
+      "org.h2.jmx",
+      "org.h2.message",
+      "org.h2.mode",
+      "org.h2.mvstore",
+      "org.h2.mvstore.cache",
+      "org.h2.mvstore.db",
+      "org.h2.mvstore.rtree",
+      "org.h2.mvstore.tx",
+      "org.h2.mvstore.type",
+      "org.h2.result",
+      "org.h2.schema",
+      "org.h2.security",
+      "org.h2.security.auth",
+      "org.h2.security.auth.impl",
+      "org.h2.server",
+      "org.h2.server.pg",
+      "org.h2.server.web",
+      "org.h2.store",
+      "org.h2.store.fs",
+      "org.h2.store.fs.async",
+      "org.h2.store.fs.disk",
+      "org.h2.store.fs.encrypt",
+      "org.h2.store.fs.mem",
+      "org.h2.store.fs.niomapped",
+      "org.h2.store.fs.niomem",
+      "org.h2.store.fs.rec",
+      "org.h2.store.fs.retry",
+      "org.h2.store.fs.split",
+      "org.h2.store.fs.zip",
+      "org.h2.table",
+      "org.h2.tools",
+      "org.h2.util",
+      "org.h2.util.geometry",
+      "org.h2.util.json",
+      "org.h2.value",
+      "org.h2.value.lob"
+    ],
+    "com.mikesamuel:json-sanitizer": [
+      "com.google.json"
+    ],
+    "com.unboundid:unboundid-ldapsdk": [
+      "com.unboundid.asn1",
+      "com.unboundid.ldap.listener",
+      "com.unboundid.ldap.listener.interceptor",
+      "com.unboundid.ldap.matchingrules",
+      "com.unboundid.ldap.protocol",
+      "com.unboundid.ldap.sdk",
+      "com.unboundid.ldap.sdk.controls",
+      "com.unboundid.ldap.sdk.examples",
+      "com.unboundid.ldap.sdk.experimental",
+      "com.unboundid.ldap.sdk.extensions",
+      "com.unboundid.ldap.sdk.migrate.jndi",
+      "com.unboundid.ldap.sdk.migrate.ldapjdk",
+      "com.unboundid.ldap.sdk.persist",
+      "com.unboundid.ldap.sdk.schema",
+      "com.unboundid.ldap.sdk.transformations",
+      "com.unboundid.ldap.sdk.unboundidds",
+      "com.unboundid.ldap.sdk.unboundidds.controls",
+      "com.unboundid.ldap.sdk.unboundidds.examples",
+      "com.unboundid.ldap.sdk.unboundidds.extensions",
+      "com.unboundid.ldap.sdk.unboundidds.jsonfilter",
+      "com.unboundid.ldap.sdk.unboundidds.logs",
+      "com.unboundid.ldap.sdk.unboundidds.monitors",
+      "com.unboundid.ldap.sdk.unboundidds.tasks",
+      "com.unboundid.ldap.sdk.unboundidds.tools",
+      "com.unboundid.ldif",
+      "com.unboundid.util",
+      "com.unboundid.util.args",
+      "com.unboundid.util.json",
+      "com.unboundid.util.parallel",
+      "com.unboundid.util.ssl",
+      "com.unboundid.util.ssl.cert"
+    ],
+    "commons-io:commons-io": [
+      "org.apache.commons.io",
+      "org.apache.commons.io.filefilter",
+      "org.apache.commons.io.input",
+      "org.apache.commons.io.output"
+    ],
+    "commons-logging:commons-logging": [
+      "org.apache.commons.logging",
+      "org.apache.commons.logging.impl"
+    ],
+    "javax.activation:javax.activation-api": [
+      "javax.activation"
+    ],
+    "javax.el:javax.el-api": [
+      "javax.el"
+    ],
+    "javax.validation:validation-api": [
+      "javax.validation",
+      "javax.validation.bootstrap",
+      "javax.validation.constraints",
+      "javax.validation.constraintvalidation",
+      "javax.validation.executable",
+      "javax.validation.groups",
+      "javax.validation.metadata",
+      "javax.validation.spi",
+      "javax.validation.valueextraction"
+    ],
+    "javax.xml.bind:jaxb-api": [
+      "javax.xml.bind",
+      "javax.xml.bind.annotation",
+      "javax.xml.bind.annotation.adapters",
+      "javax.xml.bind.attachment",
+      "javax.xml.bind.helpers",
+      "javax.xml.bind.util"
+    ],
+    "junit:junit": [
+      "junit.extensions",
+      "junit.framework",
+      "junit.runner",
+      "junit.textui",
+      "org.junit",
+      "org.junit.experimental",
+      "org.junit.experimental.categories",
+      "org.junit.experimental.max",
+      "org.junit.experimental.results",
+      "org.junit.experimental.runners",
+      "org.junit.experimental.theories",
+      "org.junit.experimental.theories.internal",
+      "org.junit.experimental.theories.suppliers",
+      "org.junit.function",
+      "org.junit.internal",
+      "org.junit.internal.builders",
+      "org.junit.internal.management",
+      "org.junit.internal.matchers",
+      "org.junit.internal.requests",
+      "org.junit.internal.runners",
+      "org.junit.internal.runners.model",
+      "org.junit.internal.runners.rules",
+      "org.junit.internal.runners.statements",
+      "org.junit.matchers",
+      "org.junit.rules",
+      "org.junit.runner",
+      "org.junit.runner.manipulation",
+      "org.junit.runner.notification",
+      "org.junit.runners",
+      "org.junit.runners.model",
+      "org.junit.runners.parameterized",
+      "org.junit.validator"
+    ],
+    "net.bytebuddy:byte-buddy": [
+      "net.bytebuddy",
+      "net.bytebuddy.agent.builder",
+      "net.bytebuddy.asm",
+      "net.bytebuddy.build",
+      "net.bytebuddy.description",
+      "net.bytebuddy.description.annotation",
+      "net.bytebuddy.description.enumeration",
+      "net.bytebuddy.description.field",
+      "net.bytebuddy.description.method",
+      "net.bytebuddy.description.modifier",
+      "net.bytebuddy.description.type",
+      "net.bytebuddy.dynamic",
+      "net.bytebuddy.dynamic.loading",
+      "net.bytebuddy.dynamic.scaffold",
+      "net.bytebuddy.dynamic.scaffold.inline",
+      "net.bytebuddy.dynamic.scaffold.subclass",
+      "net.bytebuddy.implementation",
+      "net.bytebuddy.implementation.attribute",
+      "net.bytebuddy.implementation.auxiliary",
+      "net.bytebuddy.implementation.bind",
+      "net.bytebuddy.implementation.bind.annotation",
+      "net.bytebuddy.implementation.bytecode",
+      "net.bytebuddy.implementation.bytecode.assign",
+      "net.bytebuddy.implementation.bytecode.assign.primitive",
+      "net.bytebuddy.implementation.bytecode.assign.reference",
+      "net.bytebuddy.implementation.bytecode.collection",
+      "net.bytebuddy.implementation.bytecode.constant",
+      "net.bytebuddy.implementation.bytecode.member",
+      "net.bytebuddy.jar.asm",
+      "net.bytebuddy.jar.asm.commons",
+      "net.bytebuddy.jar.asm.signature",
+      "net.bytebuddy.matcher",
+      "net.bytebuddy.pool",
+      "net.bytebuddy.utility",
+      "net.bytebuddy.utility.dispatcher",
+      "net.bytebuddy.utility.nullability",
+      "net.bytebuddy.utility.privilege",
+      "net.bytebuddy.utility.visitor"
+    ],
+    "net.bytebuddy:byte-buddy-agent": [
+      "net.bytebuddy.agent",
+      "net.bytebuddy.agent.utility.nullability"
+    ],
+    "net.sf.jopt-simple:jopt-simple": [
+      "joptsimple",
+      "joptsimple.internal",
+      "joptsimple.util"
+    ],
+    "org.apache.commons:commons-imaging": [
+      "org.apache.commons.imaging",
+      "org.apache.commons.imaging.color",
+      "org.apache.commons.imaging.common",
+      "org.apache.commons.imaging.common.bytesource",
+      "org.apache.commons.imaging.common.itu_t4",
+      "org.apache.commons.imaging.common.mylzw",
+      "org.apache.commons.imaging.formats.bmp",
+      "org.apache.commons.imaging.formats.dcx",
+      "org.apache.commons.imaging.formats.gif",
+      "org.apache.commons.imaging.formats.icns",
+      "org.apache.commons.imaging.formats.ico",
+      "org.apache.commons.imaging.formats.jpeg",
+      "org.apache.commons.imaging.formats.jpeg.decoder",
+      "org.apache.commons.imaging.formats.jpeg.exif",
+      "org.apache.commons.imaging.formats.jpeg.iptc",
+      "org.apache.commons.imaging.formats.jpeg.segments",
+      "org.apache.commons.imaging.formats.jpeg.xmp",
+      "org.apache.commons.imaging.formats.pcx",
+      "org.apache.commons.imaging.formats.png",
+      "org.apache.commons.imaging.formats.png.chunks",
+      "org.apache.commons.imaging.formats.png.scanlinefilters",
+      "org.apache.commons.imaging.formats.png.transparencyfilters",
+      "org.apache.commons.imaging.formats.pnm",
+      "org.apache.commons.imaging.formats.psd",
+      "org.apache.commons.imaging.formats.psd.dataparsers",
+      "org.apache.commons.imaging.formats.psd.datareaders",
+      "org.apache.commons.imaging.formats.rgbe",
+      "org.apache.commons.imaging.formats.tiff",
+      "org.apache.commons.imaging.formats.tiff.constants",
+      "org.apache.commons.imaging.formats.tiff.datareaders",
+      "org.apache.commons.imaging.formats.tiff.fieldtypes",
+      "org.apache.commons.imaging.formats.tiff.photometricinterpreters",
+      "org.apache.commons.imaging.formats.tiff.photometricinterpreters.floatingpoint",
+      "org.apache.commons.imaging.formats.tiff.taginfos",
+      "org.apache.commons.imaging.formats.tiff.write",
+      "org.apache.commons.imaging.formats.wbmp",
+      "org.apache.commons.imaging.formats.xbm",
+      "org.apache.commons.imaging.formats.xpm",
+      "org.apache.commons.imaging.icc",
+      "org.apache.commons.imaging.internal",
+      "org.apache.commons.imaging.palette"
+    ],
+    "org.apache.commons:commons-lang3": [
+      "org.apache.commons.lang3",
+      "org.apache.commons.lang3.arch",
+      "org.apache.commons.lang3.builder",
+      "org.apache.commons.lang3.compare",
+      "org.apache.commons.lang3.concurrent",
+      "org.apache.commons.lang3.concurrent.locks",
+      "org.apache.commons.lang3.event",
+      "org.apache.commons.lang3.exception",
+      "org.apache.commons.lang3.function",
+      "org.apache.commons.lang3.math",
+      "org.apache.commons.lang3.mutable",
+      "org.apache.commons.lang3.reflect",
+      "org.apache.commons.lang3.stream",
+      "org.apache.commons.lang3.text",
+      "org.apache.commons.lang3.text.translate",
+      "org.apache.commons.lang3.time",
+      "org.apache.commons.lang3.tuple"
+    ],
+    "org.apache.commons:commons-math3": [
+      "org.apache.commons.math3",
+      "org.apache.commons.math3.analysis",
+      "org.apache.commons.math3.analysis.differentiation",
+      "org.apache.commons.math3.analysis.function",
+      "org.apache.commons.math3.analysis.integration",
+      "org.apache.commons.math3.analysis.integration.gauss",
+      "org.apache.commons.math3.analysis.interpolation",
+      "org.apache.commons.math3.analysis.polynomials",
+      "org.apache.commons.math3.analysis.solvers",
+      "org.apache.commons.math3.complex",
+      "org.apache.commons.math3.dfp",
+      "org.apache.commons.math3.distribution",
+      "org.apache.commons.math3.distribution.fitting",
+      "org.apache.commons.math3.exception",
+      "org.apache.commons.math3.exception.util",
+      "org.apache.commons.math3.filter",
+      "org.apache.commons.math3.fitting",
+      "org.apache.commons.math3.fraction",
+      "org.apache.commons.math3.genetics",
+      "org.apache.commons.math3.geometry",
+      "org.apache.commons.math3.geometry.euclidean.oned",
+      "org.apache.commons.math3.geometry.euclidean.threed",
+      "org.apache.commons.math3.geometry.euclidean.twod",
+      "org.apache.commons.math3.geometry.partitioning",
+      "org.apache.commons.math3.geometry.partitioning.utilities",
+      "org.apache.commons.math3.linear",
+      "org.apache.commons.math3.ml.clustering",
+      "org.apache.commons.math3.ml.distance",
+      "org.apache.commons.math3.ode",
+      "org.apache.commons.math3.ode.events",
+      "org.apache.commons.math3.ode.nonstiff",
+      "org.apache.commons.math3.ode.sampling",
+      "org.apache.commons.math3.optim",
+      "org.apache.commons.math3.optim.linear",
+      "org.apache.commons.math3.optim.nonlinear.scalar",
+      "org.apache.commons.math3.optim.nonlinear.scalar.gradient",
+      "org.apache.commons.math3.optim.nonlinear.scalar.noderiv",
+      "org.apache.commons.math3.optim.nonlinear.vector",
+      "org.apache.commons.math3.optim.nonlinear.vector.jacobian",
+      "org.apache.commons.math3.optim.univariate",
+      "org.apache.commons.math3.optimization",
+      "org.apache.commons.math3.optimization.direct",
+      "org.apache.commons.math3.optimization.fitting",
+      "org.apache.commons.math3.optimization.general",
+      "org.apache.commons.math3.optimization.linear",
+      "org.apache.commons.math3.optimization.univariate",
+      "org.apache.commons.math3.primes",
+      "org.apache.commons.math3.random",
+      "org.apache.commons.math3.special",
+      "org.apache.commons.math3.stat",
+      "org.apache.commons.math3.stat.clustering",
+      "org.apache.commons.math3.stat.correlation",
+      "org.apache.commons.math3.stat.descriptive",
+      "org.apache.commons.math3.stat.descriptive.moment",
+      "org.apache.commons.math3.stat.descriptive.rank",
+      "org.apache.commons.math3.stat.descriptive.summary",
+      "org.apache.commons.math3.stat.inference",
+      "org.apache.commons.math3.stat.ranking",
+      "org.apache.commons.math3.stat.regression",
+      "org.apache.commons.math3.transform",
+      "org.apache.commons.math3.util"
+    ],
+    "org.apache.commons:commons-text": [
+      "org.apache.commons.text",
+      "org.apache.commons.text.diff",
+      "org.apache.commons.text.io",
+      "org.apache.commons.text.lookup",
+      "org.apache.commons.text.matcher",
+      "org.apache.commons.text.similarity",
+      "org.apache.commons.text.translate"
+    ],
+    "org.apache.logging.log4j:log4j-api": [
+      "org.apache.logging.log4j",
+      "org.apache.logging.log4j.internal",
+      "org.apache.logging.log4j.message",
+      "org.apache.logging.log4j.simple",
+      "org.apache.logging.log4j.spi",
+      "org.apache.logging.log4j.status",
+      "org.apache.logging.log4j.util",
+      "org.apache.logging.log4j.util.internal"
+    ],
+    "org.apache.logging.log4j:log4j-core": [
+      "org.apache.logging.log4j.core",
+      "org.apache.logging.log4j.core.appender",
+      "org.apache.logging.log4j.core.appender.db",
+      "org.apache.logging.log4j.core.appender.db.jdbc",
+      "org.apache.logging.log4j.core.appender.mom",
+      "org.apache.logging.log4j.core.appender.mom.jeromq",
+      "org.apache.logging.log4j.core.appender.mom.kafka",
+      "org.apache.logging.log4j.core.appender.nosql",
+      "org.apache.logging.log4j.core.appender.rewrite",
+      "org.apache.logging.log4j.core.appender.rolling",
+      "org.apache.logging.log4j.core.appender.rolling.action",
+      "org.apache.logging.log4j.core.appender.routing",
+      "org.apache.logging.log4j.core.async",
+      "org.apache.logging.log4j.core.config",
+      "org.apache.logging.log4j.core.config.builder.api",
+      "org.apache.logging.log4j.core.config.builder.impl",
+      "org.apache.logging.log4j.core.config.composite",
+      "org.apache.logging.log4j.core.config.json",
+      "org.apache.logging.log4j.core.config.plugins",
+      "org.apache.logging.log4j.core.config.plugins.convert",
+      "org.apache.logging.log4j.core.config.plugins.processor",
+      "org.apache.logging.log4j.core.config.plugins.util",
+      "org.apache.logging.log4j.core.config.plugins.validation",
+      "org.apache.logging.log4j.core.config.plugins.validation.constraints",
+      "org.apache.logging.log4j.core.config.plugins.validation.validators",
+      "org.apache.logging.log4j.core.config.plugins.visitors",
+      "org.apache.logging.log4j.core.config.properties",
+      "org.apache.logging.log4j.core.config.status",
+      "org.apache.logging.log4j.core.config.xml",
+      "org.apache.logging.log4j.core.config.yaml",
+      "org.apache.logging.log4j.core.filter",
+      "org.apache.logging.log4j.core.impl",
+      "org.apache.logging.log4j.core.jackson",
+      "org.apache.logging.log4j.core.jmx",
+      "org.apache.logging.log4j.core.layout",
+      "org.apache.logging.log4j.core.layout.internal",
+      "org.apache.logging.log4j.core.lookup",
+      "org.apache.logging.log4j.core.message",
+      "org.apache.logging.log4j.core.net",
+      "org.apache.logging.log4j.core.net.ssl",
+      "org.apache.logging.log4j.core.osgi",
+      "org.apache.logging.log4j.core.parser",
+      "org.apache.logging.log4j.core.pattern",
+      "org.apache.logging.log4j.core.script",
+      "org.apache.logging.log4j.core.selector",
+      "org.apache.logging.log4j.core.time",
+      "org.apache.logging.log4j.core.time.internal",
+      "org.apache.logging.log4j.core.tools",
+      "org.apache.logging.log4j.core.tools.picocli",
+      "org.apache.logging.log4j.core.util",
+      "org.apache.logging.log4j.core.util.datetime"
+    ],
+    "org.apache.xmlgraphics:batik-anim": [
+      "org.apache.batik.anim",
+      "org.apache.batik.anim.dom",
+      "org.apache.batik.anim.timing",
+      "org.apache.batik.anim.values"
+    ],
+    "org.apache.xmlgraphics:batik-awt-util": [
+      "org.apache.batik.ext.awt",
+      "org.apache.batik.ext.awt.color",
+      "org.apache.batik.ext.awt.font",
+      "org.apache.batik.ext.awt.g2d",
+      "org.apache.batik.ext.awt.geom",
+      "org.apache.batik.ext.awt.image",
+      "org.apache.batik.ext.awt.image.renderable",
+      "org.apache.batik.ext.awt.image.rendered",
+      "org.apache.batik.ext.awt.image.spi",
+      "org.apache.batik.ext.swing"
+    ],
+    "org.apache.xmlgraphics:batik-bridge": [
+      "org.apache.batik.bridge",
+      "org.apache.batik.bridge.svg12"
+    ],
+    "org.apache.xmlgraphics:batik-constants": [
+      "org.apache.batik.constants"
+    ],
+    "org.apache.xmlgraphics:batik-css": [
+      "org.apache.batik.css.dom",
+      "org.apache.batik.css.engine",
+      "org.apache.batik.css.engine.sac",
+      "org.apache.batik.css.engine.value",
+      "org.apache.batik.css.engine.value.css2",
+      "org.apache.batik.css.engine.value.svg",
+      "org.apache.batik.css.engine.value.svg12",
+      "org.apache.batik.css.parser"
+    ],
+    "org.apache.xmlgraphics:batik-dom": [
+      "org.apache.batik.dom",
+      "org.apache.batik.dom.events",
+      "org.apache.batik.dom.traversal",
+      "org.apache.batik.dom.util",
+      "org.apache.batik.dom.xbl"
+    ],
+    "org.apache.xmlgraphics:batik-ext": [
+      "org.apache.batik.w3c.dom",
+      "org.apache.batik.w3c.dom.events"
+    ],
+    "org.apache.xmlgraphics:batik-gvt": [
+      "org.apache.batik.gvt",
+      "org.apache.batik.gvt.event",
+      "org.apache.batik.gvt.filter",
+      "org.apache.batik.gvt.flow",
+      "org.apache.batik.gvt.font",
+      "org.apache.batik.gvt.renderer",
+      "org.apache.batik.gvt.text"
+    ],
+    "org.apache.xmlgraphics:batik-i18n": [
+      "org.apache.batik.i18n"
+    ],
+    "org.apache.xmlgraphics:batik-parser": [
+      "org.apache.batik.parser"
+    ],
+    "org.apache.xmlgraphics:batik-script": [
+      "org.apache.batik.script",
+      "org.apache.batik.script.jpython",
+      "org.apache.batik.script.rhino"
+    ],
+    "org.apache.xmlgraphics:batik-svg-dom": [
+      "org.apache.batik.dom.svg",
+      "org.apache.batik.dom.svg12"
+    ],
+    "org.apache.xmlgraphics:batik-svggen": [
+      "org.apache.batik.svggen",
+      "org.apache.batik.svggen.font",
+      "org.apache.batik.svggen.font.table"
+    ],
+    "org.apache.xmlgraphics:batik-transcoder": [
+      "org.apache.batik.transcoder",
+      "org.apache.batik.transcoder.image",
+      "org.apache.batik.transcoder.image.resources",
+      "org.apache.batik.transcoder.keys",
+      "org.apache.batik.transcoder.print",
+      "org.apache.batik.transcoder.svg2svg",
+      "org.apache.batik.transcoder.wmf",
+      "org.apache.batik.transcoder.wmf.tosvg"
+    ],
+    "org.apache.xmlgraphics:batik-util": [
+      "org.apache.batik",
+      "org.apache.batik.util",
+      "org.apache.batik.util.io",
+      "org.apache.batik.util.resources"
+    ],
+    "org.apache.xmlgraphics:batik-xml": [
+      "org.apache.batik.xml"
+    ],
+    "org.apache.xmlgraphics:xmlgraphics-commons": [
+      "org.apache.xmlgraphics.fonts",
+      "org.apache.xmlgraphics.image",
+      "org.apache.xmlgraphics.image.codec.png",
+      "org.apache.xmlgraphics.image.codec.tiff",
+      "org.apache.xmlgraphics.image.codec.util",
+      "org.apache.xmlgraphics.image.loader",
+      "org.apache.xmlgraphics.image.loader.cache",
+      "org.apache.xmlgraphics.image.loader.impl",
+      "org.apache.xmlgraphics.image.loader.impl.imageio",
+      "org.apache.xmlgraphics.image.loader.pipeline",
+      "org.apache.xmlgraphics.image.loader.spi",
+      "org.apache.xmlgraphics.image.loader.util",
+      "org.apache.xmlgraphics.image.rendered",
+      "org.apache.xmlgraphics.image.writer",
+      "org.apache.xmlgraphics.image.writer.imageio",
+      "org.apache.xmlgraphics.image.writer.internal",
+      "org.apache.xmlgraphics.io",
+      "org.apache.xmlgraphics.java2d",
+      "org.apache.xmlgraphics.java2d.color",
+      "org.apache.xmlgraphics.java2d.color.profile",
+      "org.apache.xmlgraphics.java2d.ps",
+      "org.apache.xmlgraphics.ps",
+      "org.apache.xmlgraphics.ps.dsc",
+      "org.apache.xmlgraphics.ps.dsc.events",
+      "org.apache.xmlgraphics.ps.dsc.tools",
+      "org.apache.xmlgraphics.util",
+      "org.apache.xmlgraphics.util.dijkstra",
+      "org.apache.xmlgraphics.util.i18n",
+      "org.apache.xmlgraphics.util.io",
+      "org.apache.xmlgraphics.util.uri",
+      "org.apache.xmlgraphics.xmp",
+      "org.apache.xmlgraphics.xmp.merge",
+      "org.apache.xmlgraphics.xmp.schemas",
+      "org.apache.xmlgraphics.xmp.schemas.pdf"
+    ],
+    "org.apiguardian:apiguardian-api": [
+      "org.apiguardian.api"
+    ],
+    "org.assertj:assertj-core": [
+      "org.assertj.core.annotations",
+      "org.assertj.core.api",
+      "org.assertj.core.api.exception",
+      "org.assertj.core.api.filter",
+      "org.assertj.core.api.iterable",
+      "org.assertj.core.api.junit.jupiter",
+      "org.assertj.core.api.recursive.comparison",
+      "org.assertj.core.condition",
+      "org.assertj.core.configuration",
+      "org.assertj.core.data",
+      "org.assertj.core.description",
+      "org.assertj.core.error",
+      "org.assertj.core.error.array2d",
+      "org.assertj.core.error.future",
+      "org.assertj.core.error.uri",
+      "org.assertj.core.extractor",
+      "org.assertj.core.groups",
+      "org.assertj.core.internal",
+      "org.assertj.core.matcher",
+      "org.assertj.core.presentation",
+      "org.assertj.core.util",
+      "org.assertj.core.util.diff",
+      "org.assertj.core.util.diff.myers",
+      "org.assertj.core.util.introspection",
+      "org.assertj.core.util.xml"
+    ],
+    "org.checkerframework:checker-compat-qual": [
+      "org.checkerframework.checker.nullness.compatqual"
+    ],
+    "org.checkerframework:checker-qual": [
+      "org.checkerframework.checker.builder.qual",
+      "org.checkerframework.checker.calledmethods.qual",
+      "org.checkerframework.checker.compilermsgs.qual",
+      "org.checkerframework.checker.fenum.qual",
+      "org.checkerframework.checker.formatter.qual",
+      "org.checkerframework.checker.guieffect.qual",
+      "org.checkerframework.checker.i18n.qual",
+      "org.checkerframework.checker.i18nformatter.qual",
+      "org.checkerframework.checker.index.qual",
+      "org.checkerframework.checker.initialization.qual",
+      "org.checkerframework.checker.interning.qual",
+      "org.checkerframework.checker.lock.qual",
+      "org.checkerframework.checker.nullness.qual",
+      "org.checkerframework.checker.optional.qual",
+      "org.checkerframework.checker.propkey.qual",
+      "org.checkerframework.checker.regex.qual",
+      "org.checkerframework.checker.signature.qual",
+      "org.checkerframework.checker.signedness.qual",
+      "org.checkerframework.checker.tainting.qual",
+      "org.checkerframework.checker.units.qual",
+      "org.checkerframework.common.aliasing.qual",
+      "org.checkerframework.common.initializedfields.qual",
+      "org.checkerframework.common.reflection.qual",
+      "org.checkerframework.common.returnsreceiver.qual",
+      "org.checkerframework.common.subtyping.qual",
+      "org.checkerframework.common.util.report.qual",
+      "org.checkerframework.common.value.qual",
+      "org.checkerframework.dataflow.qual",
+      "org.checkerframework.framework.qual"
+    ],
+    "org.glassfish:javax.el": [
+      "com.sun.el",
+      "com.sun.el.lang",
+      "com.sun.el.parser",
+      "com.sun.el.stream",
+      "com.sun.el.util",
+      "javax.el"
+    ],
+    "org.hamcrest:hamcrest-core": [
+      "org.hamcrest",
+      "org.hamcrest.core",
+      "org.hamcrest.internal"
+    ],
+    "org.hibernate:hibernate-validator": [
+      "org.hibernate.validator",
+      "org.hibernate.validator.cfg",
+      "org.hibernate.validator.cfg.context",
+      "org.hibernate.validator.cfg.defs",
+      "org.hibernate.validator.constraints",
+      "org.hibernate.validator.constraints.br",
+      "org.hibernate.validator.constraintvalidation",
+      "org.hibernate.validator.constraintvalidators",
+      "org.hibernate.validator.group",
+      "org.hibernate.validator.internal.cfg.context",
+      "org.hibernate.validator.internal.constraintvalidators.bv",
+      "org.hibernate.validator.internal.constraintvalidators.bv.future",
+      "org.hibernate.validator.internal.constraintvalidators.bv.past",
+      "org.hibernate.validator.internal.constraintvalidators.bv.size",
+      "org.hibernate.validator.internal.constraintvalidators.hv",
+      "org.hibernate.validator.internal.constraintvalidators.hv.br",
+      "org.hibernate.validator.internal.engine",
+      "org.hibernate.validator.internal.engine.constraintdefinition",
+      "org.hibernate.validator.internal.engine.constraintvalidation",
+      "org.hibernate.validator.internal.engine.groups",
+      "org.hibernate.validator.internal.engine.messageinterpolation",
+      "org.hibernate.validator.internal.engine.messageinterpolation.el",
+      "org.hibernate.validator.internal.engine.messageinterpolation.parser",
+      "org.hibernate.validator.internal.engine.path",
+      "org.hibernate.validator.internal.engine.resolver",
+      "org.hibernate.validator.internal.engine.time",
+      "org.hibernate.validator.internal.engine.valuehandling",
+      "org.hibernate.validator.internal.metadata",
+      "org.hibernate.validator.internal.metadata.aggregated",
+      "org.hibernate.validator.internal.metadata.aggregated.rule",
+      "org.hibernate.validator.internal.metadata.core",
+      "org.hibernate.validator.internal.metadata.descriptor",
+      "org.hibernate.validator.internal.metadata.facets",
+      "org.hibernate.validator.internal.metadata.location",
+      "org.hibernate.validator.internal.metadata.provider",
+      "org.hibernate.validator.internal.metadata.raw",
+      "org.hibernate.validator.internal.util",
+      "org.hibernate.validator.internal.util.annotationfactory",
+      "org.hibernate.validator.internal.util.classhierarchy",
+      "org.hibernate.validator.internal.util.logging",
+      "org.hibernate.validator.internal.util.privilegedactions",
+      "org.hibernate.validator.internal.util.scriptengine",
+      "org.hibernate.validator.internal.xml",
+      "org.hibernate.validator.messageinterpolation",
+      "org.hibernate.validator.parameternameprovider",
+      "org.hibernate.validator.path",
+      "org.hibernate.validator.resourceloading",
+      "org.hibernate.validator.spi.cfg",
+      "org.hibernate.validator.spi.constraintdefinition",
+      "org.hibernate.validator.spi.group",
+      "org.hibernate.validator.spi.resourceloading",
+      "org.hibernate.validator.spi.time",
+      "org.hibernate.validator.spi.valuehandling",
+      "org.hibernate.validator.valuehandling"
+    ],
+    "org.jacoco:org.jacoco.core": [
+      "org.jacoco.core",
+      "org.jacoco.core.analysis",
+      "org.jacoco.core.data",
+      "org.jacoco.core.instr",
+      "org.jacoco.core.internal",
+      "org.jacoco.core.internal.analysis",
+      "org.jacoco.core.internal.analysis.filter",
+      "org.jacoco.core.internal.data",
+      "org.jacoco.core.internal.flow",
+      "org.jacoco.core.internal.instr",
+      "org.jacoco.core.runtime",
+      "org.jacoco.core.tools"
+    ],
+    "org.jboss.logging:jboss-logging": [
+      "org.jboss.logging"
+    ],
+    "org.jetbrains.kotlin:kotlin-reflect": [
+      "kotlin.reflect.full",
+      "kotlin.reflect.jvm",
+      "kotlin.reflect.jvm.internal",
+      "kotlin.reflect.jvm.internal.calls",
+      "kotlin.reflect.jvm.internal.impl",
+      "kotlin.reflect.jvm.internal.impl.builtins",
+      "kotlin.reflect.jvm.internal.impl.builtins.functions",
+      "kotlin.reflect.jvm.internal.impl.builtins.jvm",
+      "kotlin.reflect.jvm.internal.impl.descriptors",
+      "kotlin.reflect.jvm.internal.impl.descriptors.annotations",
+      "kotlin.reflect.jvm.internal.impl.descriptors.deserialization",
+      "kotlin.reflect.jvm.internal.impl.descriptors.impl",
+      "kotlin.reflect.jvm.internal.impl.descriptors.java",
+      "kotlin.reflect.jvm.internal.impl.descriptors.runtime.components",
+      "kotlin.reflect.jvm.internal.impl.descriptors.runtime.structure",
+      "kotlin.reflect.jvm.internal.impl.incremental",
+      "kotlin.reflect.jvm.internal.impl.incremental.components",
+      "kotlin.reflect.jvm.internal.impl.load.java",
+      "kotlin.reflect.jvm.internal.impl.load.java.components",
+      "kotlin.reflect.jvm.internal.impl.load.java.descriptors",
+      "kotlin.reflect.jvm.internal.impl.load.java.lazy",
+      "kotlin.reflect.jvm.internal.impl.load.java.lazy.descriptors",
+      "kotlin.reflect.jvm.internal.impl.load.java.lazy.types",
+      "kotlin.reflect.jvm.internal.impl.load.java.sources",
+      "kotlin.reflect.jvm.internal.impl.load.java.structure",
+      "kotlin.reflect.jvm.internal.impl.load.java.typeEnhancement",
+      "kotlin.reflect.jvm.internal.impl.load.kotlin",
+      "kotlin.reflect.jvm.internal.impl.load.kotlin.header",
+      "kotlin.reflect.jvm.internal.impl.metadata",
+      "kotlin.reflect.jvm.internal.impl.metadata.builtins",
+      "kotlin.reflect.jvm.internal.impl.metadata.deserialization",
+      "kotlin.reflect.jvm.internal.impl.metadata.jvm",
+      "kotlin.reflect.jvm.internal.impl.metadata.jvm.deserialization",
+      "kotlin.reflect.jvm.internal.impl.name",
+      "kotlin.reflect.jvm.internal.impl.platform",
+      "kotlin.reflect.jvm.internal.impl.protobuf",
+      "kotlin.reflect.jvm.internal.impl.renderer",
+      "kotlin.reflect.jvm.internal.impl.resolve",
+      "kotlin.reflect.jvm.internal.impl.resolve.calls.inference",
+      "kotlin.reflect.jvm.internal.impl.resolve.constants",
+      "kotlin.reflect.jvm.internal.impl.resolve.deprecation",
+      "kotlin.reflect.jvm.internal.impl.resolve.descriptorUtil",
+      "kotlin.reflect.jvm.internal.impl.resolve.jvm",
+      "kotlin.reflect.jvm.internal.impl.resolve.sam",
+      "kotlin.reflect.jvm.internal.impl.resolve.scopes",
+      "kotlin.reflect.jvm.internal.impl.resolve.scopes.receivers",
+      "kotlin.reflect.jvm.internal.impl.serialization",
+      "kotlin.reflect.jvm.internal.impl.serialization.deserialization",
+      "kotlin.reflect.jvm.internal.impl.serialization.deserialization.builtins",
+      "kotlin.reflect.jvm.internal.impl.serialization.deserialization.descriptors",
+      "kotlin.reflect.jvm.internal.impl.storage",
+      "kotlin.reflect.jvm.internal.impl.types",
+      "kotlin.reflect.jvm.internal.impl.types.checker",
+      "kotlin.reflect.jvm.internal.impl.types.error",
+      "kotlin.reflect.jvm.internal.impl.types.model",
+      "kotlin.reflect.jvm.internal.impl.types.typeUtil",
+      "kotlin.reflect.jvm.internal.impl.types.typesApproximation",
+      "kotlin.reflect.jvm.internal.impl.util",
+      "kotlin.reflect.jvm.internal.impl.util.capitalizeDecapitalize",
+      "kotlin.reflect.jvm.internal.impl.util.collectionUtils",
+      "kotlin.reflect.jvm.internal.impl.utils",
+      "kotlin.reflect.jvm.internal.pcollections"
+    ],
+    "org.jetbrains.kotlin:kotlin-stdlib": [
+      "kotlin",
+      "kotlin.annotation",
+      "kotlin.collections",
+      "kotlin.collections.builders",
+      "kotlin.collections.unsigned",
+      "kotlin.comparisons",
+      "kotlin.concurrent",
+      "kotlin.contracts",
+      "kotlin.coroutines",
+      "kotlin.coroutines.cancellation",
+      "kotlin.coroutines.intrinsics",
+      "kotlin.coroutines.jvm.internal",
+      "kotlin.experimental",
+      "kotlin.internal",
+      "kotlin.io",
+      "kotlin.js",
+      "kotlin.jvm",
+      "kotlin.jvm.functions",
+      "kotlin.jvm.internal",
+      "kotlin.jvm.internal.markers",
+      "kotlin.jvm.internal.unsafe",
+      "kotlin.math",
+      "kotlin.properties",
+      "kotlin.random",
+      "kotlin.ranges",
+      "kotlin.reflect",
+      "kotlin.sequences",
+      "kotlin.system",
+      "kotlin.text",
+      "kotlin.time"
+    ],
+    "org.jetbrains:annotations": [
+      "org.intellij.lang.annotations",
+      "org.jetbrains.annotations"
+    ],
+    "org.junit.jupiter:junit-jupiter-api": [
+      "org.junit.jupiter.api",
+      "org.junit.jupiter.api.condition",
+      "org.junit.jupiter.api.extension",
+      "org.junit.jupiter.api.extension.support",
+      "org.junit.jupiter.api.function",
+      "org.junit.jupiter.api.io",
+      "org.junit.jupiter.api.parallel"
+    ],
+    "org.junit.jupiter:junit-jupiter-engine": [
+      "org.junit.jupiter.engine",
+      "org.junit.jupiter.engine.config",
+      "org.junit.jupiter.engine.descriptor",
+      "org.junit.jupiter.engine.discovery",
+      "org.junit.jupiter.engine.discovery.predicates",
+      "org.junit.jupiter.engine.execution",
+      "org.junit.jupiter.engine.extension",
+      "org.junit.jupiter.engine.support"
+    ],
+    "org.junit.jupiter:junit-jupiter-params": [
+      "org.junit.jupiter.params",
+      "org.junit.jupiter.params.aggregator",
+      "org.junit.jupiter.params.converter",
+      "org.junit.jupiter.params.provider",
+      "org.junit.jupiter.params.shadow.com.univocity.parsers.annotations",
+      "org.junit.jupiter.params.shadow.com.univocity.parsers.annotations.helpers",
+      "org.junit.jupiter.params.shadow.com.univocity.parsers.common",
+      "org.junit.jupiter.params.shadow.com.univocity.parsers.common.beans",
+      "org.junit.jupiter.params.shadow.com.univocity.parsers.common.fields",
+      "org.junit.jupiter.params.shadow.com.univocity.parsers.common.input",
+      "org.junit.jupiter.params.shadow.com.univocity.parsers.common.input.concurrent",
+      "org.junit.jupiter.params.shadow.com.univocity.parsers.common.iterators",
+      "org.junit.jupiter.params.shadow.com.univocity.parsers.common.processor",
+      "org.junit.jupiter.params.shadow.com.univocity.parsers.common.processor.core",
+      "org.junit.jupiter.params.shadow.com.univocity.parsers.common.record",
+      "org.junit.jupiter.params.shadow.com.univocity.parsers.common.routine",
+      "org.junit.jupiter.params.shadow.com.univocity.parsers.conversions",
+      "org.junit.jupiter.params.shadow.com.univocity.parsers.csv",
+      "org.junit.jupiter.params.shadow.com.univocity.parsers.fixed",
+      "org.junit.jupiter.params.shadow.com.univocity.parsers.tsv",
+      "org.junit.jupiter.params.support"
+    ],
+    "org.junit.platform:junit-platform-commons": [
+      "org.junit.platform.commons",
+      "org.junit.platform.commons.annotation",
+      "org.junit.platform.commons.function",
+      "org.junit.platform.commons.logging",
+      "org.junit.platform.commons.support",
+      "org.junit.platform.commons.util"
+    ],
+    "org.junit.platform:junit-platform-engine": [
+      "org.junit.platform.engine",
+      "org.junit.platform.engine.discovery",
+      "org.junit.platform.engine.reporting",
+      "org.junit.platform.engine.support.config",
+      "org.junit.platform.engine.support.descriptor",
+      "org.junit.platform.engine.support.discovery",
+      "org.junit.platform.engine.support.filter",
+      "org.junit.platform.engine.support.hierarchical"
+    ],
+    "org.junit.platform:junit-platform-launcher": [
+      "org.junit.platform.launcher",
+      "org.junit.platform.launcher.core",
+      "org.junit.platform.launcher.listeners",
+      "org.junit.platform.launcher.listeners.discovery",
+      "org.junit.platform.launcher.listeners.session",
+      "org.junit.platform.launcher.tagexpression"
+    ],
+    "org.junit.platform:junit-platform-reporting": [
+      "org.junit.platform.reporting.legacy",
+      "org.junit.platform.reporting.legacy.xml",
+      "org.junit.platform.reporting.open.xml",
+      "org.junit.platform.reporting.shadow.org.opentest4j.reporting.events.api",
+      "org.junit.platform.reporting.shadow.org.opentest4j.reporting.events.core",
+      "org.junit.platform.reporting.shadow.org.opentest4j.reporting.events.java",
+      "org.junit.platform.reporting.shadow.org.opentest4j.reporting.events.root",
+      "org.junit.platform.reporting.shadow.org.opentest4j.reporting.schema"
+    ],
+    "org.junit.platform:junit-platform-testkit": [
+      "org.junit.platform.testkit.engine"
+    ],
+    "org.mockito:mockito-core": [
+      "org.mockito",
+      "org.mockito.codegen",
+      "org.mockito.configuration",
+      "org.mockito.creation.instance",
+      "org.mockito.exceptions.base",
+      "org.mockito.exceptions.misusing",
+      "org.mockito.exceptions.stacktrace",
+      "org.mockito.exceptions.verification",
+      "org.mockito.exceptions.verification.junit",
+      "org.mockito.exceptions.verification.opentest4j",
+      "org.mockito.hamcrest",
+      "org.mockito.internal",
+      "org.mockito.internal.configuration",
+      "org.mockito.internal.configuration.injection",
+      "org.mockito.internal.configuration.injection.filter",
+      "org.mockito.internal.configuration.injection.scanner",
+      "org.mockito.internal.configuration.plugins",
+      "org.mockito.internal.creation",
+      "org.mockito.internal.creation.bytebuddy",
+      "org.mockito.internal.creation.instance",
+      "org.mockito.internal.creation.proxy",
+      "org.mockito.internal.creation.settings",
+      "org.mockito.internal.creation.util",
+      "org.mockito.internal.debugging",
+      "org.mockito.internal.exceptions",
+      "org.mockito.internal.exceptions.stacktrace",
+      "org.mockito.internal.exceptions.util",
+      "org.mockito.internal.framework",
+      "org.mockito.internal.hamcrest",
+      "org.mockito.internal.handler",
+      "org.mockito.internal.invocation",
+      "org.mockito.internal.invocation.finder",
+      "org.mockito.internal.invocation.mockref",
+      "org.mockito.internal.junit",
+      "org.mockito.internal.listeners",
+      "org.mockito.internal.matchers",
+      "org.mockito.internal.matchers.apachecommons",
+      "org.mockito.internal.matchers.text",
+      "org.mockito.internal.progress",
+      "org.mockito.internal.reporting",
+      "org.mockito.internal.runners",
+      "org.mockito.internal.runners.util",
+      "org.mockito.internal.session",
+      "org.mockito.internal.stubbing",
+      "org.mockito.internal.stubbing.answers",
+      "org.mockito.internal.stubbing.defaultanswers",
+      "org.mockito.internal.util",
+      "org.mockito.internal.util.collections",
+      "org.mockito.internal.util.concurrent",
+      "org.mockito.internal.util.io",
+      "org.mockito.internal.util.reflection",
+      "org.mockito.internal.verification",
+      "org.mockito.internal.verification.api",
+      "org.mockito.internal.verification.argumentmatching",
+      "org.mockito.internal.verification.checkers",
+      "org.mockito.invocation",
+      "org.mockito.junit",
+      "org.mockito.listeners",
+      "org.mockito.mock",
+      "org.mockito.plugins",
+      "org.mockito.quality",
+      "org.mockito.session",
+      "org.mockito.stubbing",
+      "org.mockito.verification"
+    ],
+    "org.objenesis:objenesis": [
+      "org.objenesis",
+      "org.objenesis.instantiator",
+      "org.objenesis.instantiator.android",
+      "org.objenesis.instantiator.annotations",
+      "org.objenesis.instantiator.basic",
+      "org.objenesis.instantiator.gcj",
+      "org.objenesis.instantiator.perc",
+      "org.objenesis.instantiator.sun",
+      "org.objenesis.instantiator.util",
+      "org.objenesis.strategy"
+    ],
+    "org.openjdk.jmh:jmh-core": [
+      "org.openjdk.jmh",
+      "org.openjdk.jmh.annotations",
+      "org.openjdk.jmh.generators.core",
+      "org.openjdk.jmh.infra",
+      "org.openjdk.jmh.profile",
+      "org.openjdk.jmh.results",
+      "org.openjdk.jmh.results.format",
+      "org.openjdk.jmh.runner",
+      "org.openjdk.jmh.runner.format",
+      "org.openjdk.jmh.runner.link",
+      "org.openjdk.jmh.runner.options",
+      "org.openjdk.jmh.util",
+      "org.openjdk.jmh.util.lines"
+    ],
+    "org.openjdk.jmh:jmh-generator-annprocess": [
+      "org.openjdk.jmh.generators",
+      "org.openjdk.jmh.generators.annotations"
+    ],
+    "org.opentest4j:opentest4j": [
+      "org.opentest4j"
+    ],
+    "org.ow2.asm:asm": [
+      "org.objectweb.asm",
+      "org.objectweb.asm.signature"
+    ],
+    "org.ow2.asm:asm-analysis": [
+      "org.objectweb.asm.tree.analysis"
+    ],
+    "org.ow2.asm:asm-commons": [
+      "org.objectweb.asm.commons"
+    ],
+    "org.ow2.asm:asm-tree": [
+      "org.objectweb.asm.tree"
+    ],
+    "xalan:serializer": [
+      "org.apache.xml.serializer",
+      "org.apache.xml.serializer.dom3",
+      "org.apache.xml.serializer.utils"
+    ],
+    "xalan:xalan": [
+      "java_cup.runtime",
+      "org.apache.bcel",
+      "org.apache.bcel.classfile",
+      "org.apache.bcel.generic",
+      "org.apache.bcel.util",
+      "org.apache.bcel.verifier",
+      "org.apache.bcel.verifier.exc",
+      "org.apache.bcel.verifier.statics",
+      "org.apache.bcel.verifier.structurals",
+      "org.apache.regexp",
+      "org.apache.xalan",
+      "org.apache.xalan.client",
+      "org.apache.xalan.extensions",
+      "org.apache.xalan.lib",
+      "org.apache.xalan.lib.sql",
+      "org.apache.xalan.processor",
+      "org.apache.xalan.res",
+      "org.apache.xalan.serialize",
+      "org.apache.xalan.templates",
+      "org.apache.xalan.trace",
+      "org.apache.xalan.transformer",
+      "org.apache.xalan.xslt",
+      "org.apache.xalan.xsltc",
+      "org.apache.xalan.xsltc.cmdline",
+      "org.apache.xalan.xsltc.cmdline.getopt",
+      "org.apache.xalan.xsltc.compiler",
+      "org.apache.xalan.xsltc.compiler.util",
+      "org.apache.xalan.xsltc.dom",
+      "org.apache.xalan.xsltc.runtime",
+      "org.apache.xalan.xsltc.runtime.output",
+      "org.apache.xalan.xsltc.trax",
+      "org.apache.xalan.xsltc.util",
+      "org.apache.xml.dtm",
+      "org.apache.xml.dtm.ref",
+      "org.apache.xml.dtm.ref.dom2dtm",
+      "org.apache.xml.dtm.ref.sax2dtm",
+      "org.apache.xml.res",
+      "org.apache.xml.utils",
+      "org.apache.xml.utils.res",
+      "org.apache.xpath",
+      "org.apache.xpath.axes",
+      "org.apache.xpath.compiler",
+      "org.apache.xpath.domapi",
+      "org.apache.xpath.functions",
+      "org.apache.xpath.jaxp",
+      "org.apache.xpath.objects",
+      "org.apache.xpath.operations",
+      "org.apache.xpath.patterns",
+      "org.apache.xpath.res"
+    ],
+    "xml-apis:xml-apis": [
+      "javax.xml",
+      "javax.xml.datatype",
+      "javax.xml.namespace",
+      "javax.xml.parsers",
+      "javax.xml.stream",
+      "javax.xml.stream.events",
+      "javax.xml.stream.util",
+      "javax.xml.transform",
+      "javax.xml.transform.dom",
+      "javax.xml.transform.sax",
+      "javax.xml.transform.stax",
+      "javax.xml.transform.stream",
+      "javax.xml.validation",
+      "javax.xml.xpath",
+      "org.apache.xmlcommons",
+      "org.w3c.dom",
+      "org.w3c.dom.bootstrap",
+      "org.w3c.dom.css",
+      "org.w3c.dom.events",
+      "org.w3c.dom.html",
+      "org.w3c.dom.ls",
+      "org.w3c.dom.ranges",
+      "org.w3c.dom.stylesheets",
+      "org.w3c.dom.traversal",
+      "org.w3c.dom.views",
+      "org.w3c.dom.xpath",
+      "org.xml.sax",
+      "org.xml.sax.ext",
+      "org.xml.sax.helpers"
+    ],
+    "xml-apis:xml-apis-ext": [
+      "org.w3c.css.sac",
+      "org.w3c.css.sac.helpers",
+      "org.w3c.dom.smil",
+      "org.w3c.dom.svg"
+    ]
+  },
+  "repositories": {
+    "https://repo1.maven.org/maven2/": [
+      "com.alibaba:fastjson",
+      "com.beust:klaxon",
+      "com.fasterxml.jackson.core:jackson-annotations",
+      "com.fasterxml.jackson.core:jackson-core",
+      "com.fasterxml.jackson.core:jackson-databind",
+      "com.fasterxml.jackson.dataformat:jackson-dataformat-cbor",
+      "com.fasterxml:classmate",
+      "com.google.auto.value:auto-value-annotations",
+      "com.google.code.findbugs:jsr305",
+      "com.google.code.gson:gson",
+      "com.google.errorprone:error_prone_annotations",
+      "com.google.guava:failureaccess",
+      "com.google.guava:guava",
+      "com.google.guava:listenablefuture",
+      "com.google.j2objc:j2objc-annotations",
+      "com.google.protobuf:protobuf-java",
+      "com.google.truth.extensions:truth-java8-extension",
+      "com.google.truth.extensions:truth-liteproto-extension",
+      "com.google.truth.extensions:truth-proto-extension",
+      "com.google.truth:truth",
+      "com.h2database:h2",
+      "com.mikesamuel:json-sanitizer",
+      "com.unboundid:unboundid-ldapsdk",
+      "commons-io:commons-io",
+      "commons-logging:commons-logging",
+      "javax.activation:javax.activation-api",
+      "javax.el:javax.el-api",
+      "javax.validation:validation-api",
+      "javax.xml.bind:jaxb-api",
+      "junit:junit",
+      "net.bytebuddy:byte-buddy",
+      "net.bytebuddy:byte-buddy-agent",
+      "net.sf.jopt-simple:jopt-simple",
+      "org.apache.commons:commons-imaging",
+      "org.apache.commons:commons-lang3",
+      "org.apache.commons:commons-math3",
+      "org.apache.commons:commons-text",
+      "org.apache.logging.log4j:log4j-api",
+      "org.apache.logging.log4j:log4j-core",
+      "org.apache.xmlgraphics:batik-anim",
+      "org.apache.xmlgraphics:batik-awt-util",
+      "org.apache.xmlgraphics:batik-bridge",
+      "org.apache.xmlgraphics:batik-constants",
+      "org.apache.xmlgraphics:batik-css",
+      "org.apache.xmlgraphics:batik-dom",
+      "org.apache.xmlgraphics:batik-ext",
+      "org.apache.xmlgraphics:batik-gvt",
+      "org.apache.xmlgraphics:batik-i18n",
+      "org.apache.xmlgraphics:batik-parser",
+      "org.apache.xmlgraphics:batik-script",
+      "org.apache.xmlgraphics:batik-shared-resources",
+      "org.apache.xmlgraphics:batik-svg-dom",
+      "org.apache.xmlgraphics:batik-svggen",
+      "org.apache.xmlgraphics:batik-transcoder",
+      "org.apache.xmlgraphics:batik-util",
+      "org.apache.xmlgraphics:batik-xml",
+      "org.apache.xmlgraphics:xmlgraphics-commons",
+      "org.apiguardian:apiguardian-api",
+      "org.assertj:assertj-core",
+      "org.checkerframework:checker-compat-qual",
+      "org.checkerframework:checker-qual",
+      "org.glassfish:javax.el",
+      "org.hamcrest:hamcrest-core",
+      "org.hibernate:hibernate-validator",
+      "org.jacoco:org.jacoco.core",
+      "org.jboss.logging:jboss-logging",
+      "org.jetbrains.kotlin:kotlin-reflect",
+      "org.jetbrains.kotlin:kotlin-stdlib",
+      "org.jetbrains.kotlin:kotlin-stdlib-common",
+      "org.jetbrains:annotations",
+      "org.junit.jupiter:junit-jupiter-api",
+      "org.junit.jupiter:junit-jupiter-engine",
+      "org.junit.jupiter:junit-jupiter-params",
+      "org.junit.platform:junit-platform-commons",
+      "org.junit.platform:junit-platform-engine",
+      "org.junit.platform:junit-platform-launcher",
+      "org.junit.platform:junit-platform-reporting",
+      "org.junit.platform:junit-platform-testkit",
+      "org.mockito:mockito-core",
+      "org.objenesis:objenesis",
+      "org.openjdk.jmh:jmh-core",
+      "org.openjdk.jmh:jmh-generator-annprocess",
+      "org.opentest4j:opentest4j",
+      "org.ow2.asm:asm",
+      "org.ow2.asm:asm-analysis",
+      "org.ow2.asm:asm-commons",
+      "org.ow2.asm:asm-tree",
+      "xalan:serializer",
+      "xalan:xalan",
+      "xml-apis:xml-apis",
+      "xml-apis:xml-apis-ext"
+    ]
+  },
+  "version": "2"
 }
diff --git a/platform_mappings b/platform_mappings
new file mode 100644
index 0000000..c88398c
--- /dev/null
+++ b/platform_mappings
@@ -0,0 +1,8 @@
+# Required for compatibility with apple_support's universal_binary, which
+# doesn't support toolchain resolution yet and instead transitions on --cpu.
+flags:
+  --cpu=darwin_x86_64
+    //bazel/platforms:macos_x86_64
+
+  --cpu=darwin_arm64
+    //bazel/platforms:macos_arm64
diff --git a/repositories.bzl b/repositories.bzl
index 7abffd8..df1338f 100644
--- a/repositories.bzl
+++ b/repositories.bzl
@@ -16,49 +16,66 @@
 
 load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_jar")
 load("@bazel_tools//tools/build_defs/repo:utils.bzl", "maybe")
+load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")
 
-def jazzer_dependencies():
+def jazzer_dependencies(android = False):
     maybe(
         http_archive,
         name = "platforms",
-        sha256 = "379113459b0feaf6bfbb584a91874c065078aa673222846ac765f86661c27407",
+        sha256 = "5308fc1d8865406a49427ba24a9ab53087f17f5266a7aabbfc28823f3916e1ca",
         urls = [
-            "https://mirror.bazel.build/github.com/bazelbuild/platforms/releases/download/0.0.5/platforms-0.0.5.tar.gz",
-            "https://github.com/bazelbuild/platforms/releases/download/0.0.5/platforms-0.0.5.tar.gz",
+            "https://mirror.bazel.build/github.com/bazelbuild/platforms/releases/download/0.0.6/platforms-0.0.6.tar.gz",
+            "https://github.com/bazelbuild/platforms/releases/download/0.0.6/platforms-0.0.6.tar.gz",
         ],
     )
 
     maybe(
         http_archive,
         name = "bazel_skylib",
-        sha256 = "f7be3474d42aae265405a592bb7da8e171919d74c16f082a5457840f06054728",
+        sha256 = "b8a1527901774180afc798aeb28c4634bdccf19c4d98e7bdd1ce79d1fe9aaad7",
         urls = [
-            "https://mirror.bazel.build/github.com/bazelbuild/bazel-skylib/releases/download/1.2.1/bazel-skylib-1.2.1.tar.gz",
-            "https://github.com/bazelbuild/bazel-skylib/releases/download/1.2.1/bazel-skylib-1.2.1.tar.gz",
+            "https://mirror.bazel.build/github.com/bazelbuild/bazel-skylib/releases/download/1.4.1/bazel-skylib-1.4.1.tar.gz",
+            "https://github.com/bazelbuild/bazel-skylib/releases/download/1.4.1/bazel-skylib-1.4.1.tar.gz",
         ],
     )
 
     maybe(
         http_archive,
         name = "io_bazel_rules_kotlin",
-        sha256 = "a57591404423a52bd6b18ebba7979e8cd2243534736c5c94d35c89718ea38f94",
-        url = "https://github.com/bazelbuild/rules_kotlin/releases/download/v1.6.0/rules_kotlin_release.tgz",
+        sha256 = "01293740a16e474669aba5b5a1fe3d368de5832442f164e4fbfc566815a8bc3a",
+        url = "https://github.com/bazelbuild/rules_kotlin/releases/download/v1.8/rules_kotlin_release.tgz",
+    )
+
+    maybe(
+        http_archive,
+        name = "rules_jvm_external",
+        sha256 = "f86fd42a809e1871ca0aabe89db0d440451219c3ce46c58da240c7dcdc00125f",
+        strip_prefix = "rules_jvm_external-5.2",
+        url = "https://github.com/bazelbuild/rules_jvm_external/releases/download/5.2/rules_jvm_external-5.2.tar.gz",
+    )
+
+    maybe(
+        http_archive,
+        name = "build_bazel_apple_support",
+        sha256 = "ce80afe548fd71ef27b48cb48a283ca21256a0900caec3c7ed9416241e000bfe",
+        strip_prefix = "apple_support-dab92884a6f031e63ac263e5de8a02f13ac42508",
+        url = "https://github.com/bazelbuild/apple_support/archive/dab92884a6f031e63ac263e5de8a02f13ac42508.tar.gz",
     )
 
     maybe(
         http_archive,
         name = "com_google_absl",
-        sha256 = "4208129b49006089ba1d6710845a45e31c59b0ab6bff9e5788a87f55c5abd602",
-        strip_prefix = "abseil-cpp-20220623.0",
-        url = "https://github.com/abseil/abseil-cpp/archive/refs/tags/20220623.0.tar.gz",
+        sha256 = "5366d7e7fa7ba0d915014d387b66d0d002c03236448e1ba9ef98122c13b35c36",
+        strip_prefix = "abseil-cpp-20230125.3",
+        url = "https://github.com/abseil/abseil-cpp/archive/refs/tags/20230125.3.tar.gz",
     )
 
     maybe(
         http_archive,
         name = "com_github_johnynek_bazel_jar_jar",
-        sha256 = "138a33a5c6ed9355e4411caa22f2fe45460b7e1e4468cbc29f7955367d7a001a",
-        strip_prefix = "bazel_jar_jar-commit-d97cfd22d47cba9a20708fa092f20348b72fb5ed",
-        url = "https://github.com/CodeIntelligenceTesting/bazel_jar_jar/archive/refs/tags/commit-d97cfd22d47cba9a20708fa092f20348b72fb5ed.tar.gz",
+        sha256 = "85260ebdaf86cf0ce6d0d0f0a3268a09f628c815513141a6b99a023116523f96",
+        strip_prefix = "bazel_jar_jar-78c8c13ff437e8397ffe80c9a4c905376720a339",
+        url = "https://github.com/bazeltools/bazel_jar_jar/archive/78c8c13ff437e8397ffe80c9a4c905376720a339.tar.gz",
     )
 
     maybe(
@@ -74,56 +91,74 @@
         http_archive,
         build_file = Label("//third_party:classgraph.BUILD"),
         name = "com_github_classgraph_classgraph",
-        sha256 = "535159d80c163d5b4d025c402b4562c92ed2d6d963db8c6c5255c0eb2c4e9f39",
-        strip_prefix = "classgraph-classgraph-4.8.128",
-        url = "https://github.com/classgraph/classgraph/archive/refs/tags/classgraph-4.8.128.tar.gz",
+        sha256 = "39a594834ec24ef8f604485e4ee54b8b6dfe0b0ee5020e70601a9dab538d5c9e",
+        strip_prefix = "classgraph-classgraph-4.8.160",
+        url = "https://github.com/classgraph/classgraph/archive/refs/tags/classgraph-4.8.160.tar.gz",
     )
 
     maybe(
         http_archive,
         name = "fmeum_rules_jni",
-        sha256 = "47f0c566ef93fbca2fe94ae8b964d9bf2cb5b31be0efa66e9684b096e54042c1",
-        strip_prefix = "rules_jni-0.5.2",
-        url = "https://github.com/fmeum/rules_jni/archive/refs/tags/v0.5.2.tar.gz",
+        sha256 = "530a02c4d86f7bcfabd61e7de830f8c78fcb2ea70943eab8f2bfdad96620f1f5",
+        strip_prefix = "rules_jni-0.7.0",
+        url = "https://github.com/fmeum/rules_jni/archive/refs/tags/v0.7.0.tar.gz",
     )
 
     maybe(
         http_jar,
         name = "net_bytebuddy_byte_buddy_agent",
-        sha256 = "25eed4301bbde3724a4bac0e7fe4a0b371c64b5fb40160b29480de3afd04efd5",
-        url = "https://repo1.maven.org/maven2/net/bytebuddy/byte-buddy-agent/1.12.13/byte-buddy-agent-1.12.13.jar",
+        sha256 = "55f19862b870f5d85890ba5386b1b45e9bbc88d5fe1f819abe0c788b4929fa6b",
+        url = "https://repo1.maven.org/maven2/net/bytebuddy/byte-buddy-agent/1.14.5/byte-buddy-agent-1.14.5.jar",
     )
 
     maybe(
         http_jar,
         name = "org_ow2_asm_asm",
-        sha256 = "1263369b59e29c943918de11d6d6152e2ec6085ce63e5710516f8c67d368e4bc",
-        url = "https://repo1.maven.org/maven2/org/ow2/asm/asm/9.3/asm-9.3.jar",
+        sha256 = "b62e84b5980729751b0458c534cf1366f727542bb8d158621335682a460f0353",
+        url = "https://repo1.maven.org/maven2/org/ow2/asm/asm/9.5/asm-9.5.jar",
     )
 
     maybe(
         http_jar,
         name = "org_ow2_asm_asm_commons",
-        sha256 = "a347c24732db2aead106b6e5996a015b06a3ef86e790a4f75b61761f0d2f7f39",
-        url = "https://repo1.maven.org/maven2/org/ow2/asm/asm-commons/9.3/asm-commons-9.3.jar",
+        sha256 = "72eee9fbafb9de8d9463f20dd584a48ceeb7e5152ad4c987bfbe17dd4811c9ae",
+        url = "https://repo1.maven.org/maven2/org/ow2/asm/asm-commons/9.5/asm-commons-9.5.jar",
     )
 
     maybe(
         http_jar,
         name = "org_ow2_asm_asm_tree",
-        sha256 = "ae629c2609f39681ef8d140a42a23800464a94f2d23e36d8f25cd10d5e4caff4",
-        url = "https://repo1.maven.org/maven2/org/ow2/asm/asm-tree/9.3/asm-tree-9.3.jar",
+        sha256 = "3c33a648191079aeaeaeb7c19a49b153952f9e40fe86fbac5205554ddd9acd94",
+        url = "https://repo1.maven.org/maven2/org/ow2/asm/asm-tree/9.5/asm-tree-9.5.jar",
     )
 
     maybe(
-        http_archive,
-        name = "jazzer_com_github_gflags_gflags",
-        patches = [
-            Label("//third_party:gflags-use-double-dash-args.patch"),
-        ],
-        sha256 = "34af2f15cf7367513b352bdcd2493ab14ce43692d2dcd9dfc499492966c64dcf",
-        strip_prefix = "gflags-2.2.2",
-        url = "https://github.com/gflags/gflags/archive/refs/tags/v2.2.2.tar.gz",
+        http_jar,
+        name = "com_github_jsqlparser_jsqlparser",
+        sha256 = "61b02b8520fda987b7bc12878833b223234450e505de83c36e78abe6d69c0184",
+        url = "https://repo1.maven.org/maven2/com/github/jsqlparser/jsqlparser/4.6/jsqlparser-4.6.jar",
+    )
+
+    maybe(
+        http_jar,
+        name = "com_google_errorprone_error_prone_annotations",
+        sha256 = "9e6814cb71816988a4fd1b07a993a8f21bb7058d522c162b1de849e19bea54ae",
+        url = "https://repo1.maven.org/maven2/com/google/errorprone/error_prone_annotations/2.18.0/error_prone_annotations-2.18.0.jar",
+    )
+
+    maybe(
+        http_jar,
+        name = "com_google_errorprone_error_prone_type_annotations",
+        sha256 = "d2ab73bc6807277f7463391504313e47bc3465ab1916339c8e82be633a9ab375",
+        url = "https://repo1.maven.org/maven2/com/google/errorprone/error_prone_type_annotations/2.18.0/error_prone_type_annotations-2.18.0.jar",
+    )
+
+    maybe(
+        http_jar,
+        name = "com_google_protobuf_protobuf_java",
+        sha256 = "18a057f5e0f828daa92b71c19df91f6bcc2aad067ca2cdd6b5698055ca7bcece",
+        # Keep in sync with com_google_protobuf in WORKSPACE.
+        url = "https://repo1.maven.org/maven2/com/google/protobuf/protobuf-java/3.23.2/protobuf-java-3.23.2.jar",
     )
 
     maybe(
@@ -133,17 +168,27 @@
         patches = [
             Label("//third_party:jacoco-make-probe-adapter-subclassable.patch"),
             Label("//third_party:jacoco-make-probe-inserter-subclassable.patch"),
+            Label("//third_party:jacoco-ignore-offline-instrumentation.patch"),
         ],
-        sha256 = "c603cfcc5f3d95ecda46fb369dc54c82a453bb6b640a605c3970607d10896725",
-        strip_prefix = "jacoco-0.8.8",
-        url = "https://github.com/jacoco/jacoco/archive/refs/tags/v0.8.8.tar.gz",
+        sha256 = "b6b90469db034dff01a8577d8e91da51bc40f328a988359028652771f20abf1d",
+        strip_prefix = "jacoco-0.8.9",
+        url = "https://github.com/jacoco/jacoco/archive/refs/tags/v0.8.9.tar.gz",
     )
 
     maybe(
         http_archive,
         name = "jazzer_libfuzzer",
         build_file = Label("//third_party:libFuzzer.BUILD"),
-        sha256 = "3732ff706e5d049dbc76c2078d9e3ad265c6ccbe1b9ed749ae199df0f3118aac",
-        strip_prefix = "llvm-project-jazzer-2022-08-12/compiler-rt/lib/fuzzer",
-        url = "https://github.com/CodeIntelligenceTesting/llvm-project-jazzer/archive/refs/tags/2022-08-12.tar.gz",
+        sha256 = "200b32c897b1171824462706f577d7f1d6175da602eccfe570d2dceeac11d490",
+        strip_prefix = "llvm-project-jazzer-2023-04-25/compiler-rt/lib/fuzzer",
+        url = "https://github.com/CodeIntelligenceTesting/llvm-project-jazzer/archive/refs/tags/2023-04-25.tar.gz",
     )
+
+    if android:
+        maybe(
+            git_repository,
+            name = "jazzer_slicer",
+            remote = "https://android.googlesource.com/platform/tools/dexter",
+            build_file = "//third_party:slicer.BUILD",
+            commit = "0fe35538da107ff48da6e9f9b92b55b014973bf8",
+        )
diff --git a/sanitizers/BUILD.bazel b/sanitizers/BUILD.bazel
index fdc616a..6d08be7 100644
--- a/sanitizers/BUILD.bazel
+++ b/sanitizers/BUILD.bazel
@@ -1,7 +1,15 @@
 java_library(
     name = "sanitizers",
-    visibility = ["//visibility:public"],
+    visibility = ["//src/main/java/com/code_intelligence/jazzer/runtime:__pkg__"],
     runtime_deps = [
         "//sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers",
     ],
 )
+
+java_library(
+    name = "offline_only_sanitizers",
+    visibility = ["//visibility:public"],
+    runtime_deps = [
+        ":sanitizers",
+    ],
+)
diff --git a/sanitizers/sanitizers.bzl b/sanitizers/sanitizers.bzl
index cef4cf4..f4894f6 100644
--- a/sanitizers/sanitizers.bzl
+++ b/sanitizers/sanitizers.bzl
@@ -15,6 +15,7 @@
 _sanitizer_package_prefix = "com.code_intelligence.jazzer.sanitizers."
 
 _sanitizer_class_names = [
+    # keep sorted
     "Deserialization",
     "ExpressionLanguageInjection",
     "LdapInjection",
@@ -23,7 +24,10 @@
     "ReflectiveCall",
     "RegexInjection",
     "RegexRoadblocks",
+    "ScriptEngineInjection",
+    "ServerSideRequestForgery",
     "SqlInjection",
+    "XPathInjection",
 ]
 
 SANITIZER_CLASSES = [_sanitizer_package_prefix + class_name for class_name in _sanitizer_class_names]
diff --git a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/BUILD.bazel b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/BUILD.bazel
index 1b156f9..c2521b8 100644
--- a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/BUILD.bazel
+++ b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/BUILD.bazel
@@ -1,12 +1,38 @@
+load("@bazel_skylib//rules:write_file.bzl", "write_file")
 load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library")
+load("//bazel:kotlin.bzl", "ktlint")
+load("//sanitizers:sanitizers.bzl", "SANITIZER_CLASSES")
 
 java_library(
     name = "regex_roadblocks",
     srcs = ["RegexRoadblocks.java"],
     deps = [
-        "//agent:jazzer_api_compile_only",
-        "//agent/src/main/java/com/code_intelligence/jazzer/runtime:unsafe_provider",
         "//sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/utils:reflection_utils",
+        "//src/main/java/com/code_intelligence/jazzer/api:hooks",
+        "//src/main/java/com/code_intelligence/jazzer/utils:unsafe_provider",
+    ],
+)
+
+java_library(
+    name = "server_side_request_forgery",
+    srcs = ["ServerSideRequestForgery.java"],
+    deps = ["//src/main/java/com/code_intelligence/jazzer/api:hooks"],
+)
+
+java_library(
+    name = "sql_injection",
+    srcs = ["SqlInjection.java"],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/api:hooks",
+        "@com_github_jsqlparser_jsqlparser//jar",
+    ],
+)
+
+java_library(
+    name = "script_engine_injection",
+    srcs = ["ScriptEngineInjection.java"],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/api:hooks",
     ],
 )
 
@@ -20,15 +46,38 @@
         "OsCommandInjection.kt",
         "ReflectiveCall.kt",
         "RegexInjection.kt",
-        "SqlInjection.kt",
         "Utils.kt",
+        "XPathInjection.kt",
     ],
     visibility = ["//sanitizers:__pkg__"],
     runtime_deps = [
         ":regex_roadblocks",
+        ":script_engine_injection",
+        ":server_side_request_forgery",
+        ":sql_injection",
     ],
     deps = [
-        "//agent:jazzer_api_compile_only",
-        "@maven//:com_github_jsqlparser_jsqlparser",
+        "//src/main/java/com/code_intelligence/jazzer/api:hooks",
     ],
 )
+
+java_library(
+    name = "constants",
+    srcs = [":constants_java"],
+    visibility = ["//visibility:public"],
+)
+
+write_file(
+    name = "constants_java",
+    out = "Constants.java",
+    content = [
+        "package com.code_intelligence.jazzer.sanitizers;",
+        "import java.util.Arrays;",
+        "import java.util.List;",
+        "public final class Constants {",
+        "  public static final List<String> SANITIZER_HOOK_NAMES = Arrays.asList(%s);" % ", ".join(["\"%s\"" % name for name in SANITIZER_CLASSES]),
+        "}",
+    ],
+)
+
+ktlint()
diff --git a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/Deserialization.kt b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/Deserialization.kt
index 55691c1..0ecbbf9 100644
--- a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/Deserialization.kt
+++ b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/Deserialization.kt
@@ -35,6 +35,12 @@
     private val OBJECT_INPUT_STREAM_HEADER =
         ObjectStreamConstants.STREAM_MAGIC.toBytes() + ObjectStreamConstants.STREAM_VERSION.toBytes()
 
+    init {
+        require(OBJECT_INPUT_STREAM_HEADER.size <= 64) {
+            "Object input stream header must fit in a table of recent compares entry (64 bytes)"
+        }
+    }
+
     /**
      * Used to memoize the [InputStream] used to construct a given [ObjectInputStream].
      * [ThreadLocal] is required because the map is not synchronized (and likely cheaper than
@@ -57,13 +63,19 @@
         // We can't instantiate jaz.Zer directly, so we instantiate and serialize jaz.Ter and then
         // patch the class name.
         val baos = ByteArrayOutputStream()
-        ObjectOutputStream(baos).writeObject(jaz.Ter())
+        ObjectOutputStream(baos).writeObject(jaz.Ter(jaz.Ter.EXPRESSION_LANGUAGE_SANITIZER_ID))
         val serializedJazTerInstance = baos.toByteArray()
         val posToPatch = serializedJazTerInstance.indexOf("jaz.Ter".toByteArray())
         serializedJazTerInstance[posToPatch + "jaz.".length] = 'Z'.code.toByte()
         serializedJazTerInstance
     }
 
+    init {
+        require(SERIALIZED_JAZ_ZER_INSTANCE.size <= 64) {
+            "Serialized jaz.Zer instance must fit in a table of recent compares entry (64 bytes)"
+        }
+    }
+
     /**
      * Guides the fuzzer towards producing a valid header for an ObjectInputStream.
      */
@@ -71,15 +83,16 @@
         type = HookType.BEFORE,
         targetClassName = "java.io.ObjectInputStream",
         targetMethod = "<init>",
-        targetMethodDescriptor = "(Ljava/io/InputStream;)V"
+        targetMethodDescriptor = "(Ljava/io/InputStream;)V",
     )
     @JvmStatic
     fun objectInputStreamInitBeforeHook(method: MethodHandle?, alwaysNull: Any?, args: Array<Any?>, hookId: Int) {
         val originalInputStream = args[0] as? InputStream ?: return
-        val fixedInputStream = if (originalInputStream.markSupported())
+        val fixedInputStream = if (originalInputStream.markSupported()) {
             originalInputStream
-        else
+        } else {
             BufferedInputStream(originalInputStream)
+        }
         args[0] = fixedInputStream
         guideMarkableInputStreamTowardsEquality(fixedInputStream, OBJECT_INPUT_STREAM_HEADER, hookId)
     }
@@ -91,7 +104,7 @@
         type = HookType.AFTER,
         targetClassName = "java.io.ObjectInputStream",
         targetMethod = "<init>",
-        targetMethodDescriptor = "(Ljava/io/InputStream;)V"
+        targetMethodDescriptor = "(Ljava/io/InputStream;)V",
     )
     @JvmStatic
     fun objectInputStreamInitAfterHook(
@@ -115,17 +128,17 @@
         MethodHook(
             type = HookType.BEFORE,
             targetClassName = "java.io.ObjectInputStream",
-            targetMethod = "readObject"
+            targetMethod = "readObject",
         ),
         MethodHook(
             type = HookType.BEFORE,
             targetClassName = "java.io.ObjectInputStream",
-            targetMethod = "readObjectOverride"
+            targetMethod = "readObjectOverride",
         ),
         MethodHook(
             type = HookType.BEFORE,
             targetClassName = "java.io.ObjectInputStream",
-            targetMethod = "readUnshared"
+            targetMethod = "readUnshared",
         ),
     )
     @JvmStatic
@@ -139,30 +152,4 @@
         if (inputStream?.markSupported() != true) return
         guideMarkableInputStreamTowardsEquality(inputStream, SERIALIZED_JAZ_ZER_INSTANCE, hookId)
     }
-
-    /**
-     * Calls [Object.finalize] early if the returned object is [jaz.Zer]. A call to finalize is
-     * guaranteed to happen at some point, but calling it early means that we can accurately report
-     * the input that lead to its execution.
-     */
-    @MethodHooks(
-        MethodHook(type = HookType.AFTER, targetClassName = "java.io.ObjectInputStream", targetMethod = "readObject"),
-        MethodHook(type = HookType.AFTER, targetClassName = "java.io.ObjectInputStream", targetMethod = "readObjectOverride"),
-        MethodHook(type = HookType.AFTER, targetClassName = "java.io.ObjectInputStream", targetMethod = "readUnshared"),
-    )
-    @JvmStatic
-    fun readObjectAfterHook(
-        method: MethodHandle?,
-        objectInputStream: ObjectInputStream?,
-        args: Array<Any?>,
-        hookId: Int,
-        deserializedObject: Any?,
-    ) {
-        if (deserializedObject?.javaClass?.name == HONEYPOT_CLASS_NAME) {
-            deserializedObject.javaClass.getDeclaredMethod("finalize").run {
-                isAccessible = true
-                invoke(deserializedObject)
-            }
-        }
-    }
 }
diff --git a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/ExpressionLanguageInjection.kt b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/ExpressionLanguageInjection.kt
index 1dc1d5f..a60c088 100644
--- a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/ExpressionLanguageInjection.kt
+++ b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/ExpressionLanguageInjection.kt
@@ -31,7 +31,13 @@
      * Try to call the default constructor of the honeypot class.
      */
     private const val EXPRESSION_LANGUAGE_ATTACK =
-        "\${\"\".getClass().forName(\"$HONEYPOT_CLASS_NAME\").newInstance()}"
+        "\${Byte.class.forName(\"$HONEYPOT_CLASS_NAME\").getMethod(\"el\").invoke(null)}"
+
+    init {
+        require(EXPRESSION_LANGUAGE_ATTACK.length <= 64) {
+            "Expression language exploit must fit in a table of recent compares entry (64 bytes)"
+        }
+    }
 
     @MethodHooks(
         MethodHook(
@@ -60,8 +66,10 @@
         method: MethodHandle?,
         thisObject: Any?,
         arguments: Array<Any>,
-        hookId: Int
+        hookId: Int,
     ) {
+        // The overloads taking a second string argument have either three or four arguments
+        if (arguments.size < 3) { return }
         val expression = arguments[1] as? String ?: return
         Jazzer.guideTowardsContainment(expression, EXPRESSION_LANGUAGE_ATTACK, hookId)
     }
@@ -76,15 +84,16 @@
     @MethodHook(
         type = HookType.BEFORE,
         targetClassName = "javax.validation.ConstraintValidatorContext",
-        targetMethod = "buildConstraintViolationWithTemplate"
+        targetMethod = "buildConstraintViolationWithTemplate",
     )
     @JvmStatic
     fun hookBuildConstraintViolationWithTemplate(
         method: MethodHandle?,
         thisObject: Any?,
         arguments: Array<Any>,
-        hookId: Int
+        hookId: Int,
     ) {
+        if (arguments.size != 1) { return }
         val message = arguments[0] as String
         Jazzer.guideTowardsContainment(message, EXPRESSION_LANGUAGE_ATTACK, hookId)
     }
diff --git a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/LdapInjection.kt b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/LdapInjection.kt
index 1afd614..76553e1 100644
--- a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/LdapInjection.kt
+++ b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/LdapInjection.kt
@@ -56,14 +56,14 @@
             targetClassName = "javax.naming.directory.DirContext",
             targetMethod = "search",
             targetMethodDescriptor = "(Ljava/lang/String;Ljavax/naming.directory/Attributes;)Ljavax/naming/NamingEnumeration;",
-            additionalClassesToHook = ["javax.naming.directory.InitialDirContext"]
+            additionalClassesToHook = ["javax.naming.directory.InitialDirContext"],
         ),
         MethodHook(
             type = HookType.REPLACE,
             targetClassName = "javax.naming.directory.DirContext",
             targetMethod = "search",
             targetMethodDescriptor = "(Ljava/lang/String;Ljavax/naming.directory/Attributes;[Ljava/lang/Sting;)Ljavax/naming/NamingEnumeration;",
-            additionalClassesToHook = ["javax.naming.directory.InitialDirContext"]
+            additionalClassesToHook = ["javax.naming.directory.InitialDirContext"],
         ),
 
         // Object search, possible DN and search filter injection
@@ -72,22 +72,22 @@
             targetClassName = "javax.naming.directory.DirContext",
             targetMethod = "search",
             targetMethodDescriptor = "(Ljava/lang/String;Ljava/lang/String;Ljavax/naming/directory/SearchControls;)Ljavax/naming/NamingEnumeration;",
-            additionalClassesToHook = ["javax.naming.directory.InitialDirContext"]
+            additionalClassesToHook = ["javax.naming.directory.InitialDirContext"],
         ),
         MethodHook(
             type = HookType.REPLACE,
             targetClassName = "javax.naming.directory.DirContext",
             targetMethod = "search",
             targetMethodDescriptor = "(Ljavax/naming/Name;Ljava/lang/String;[Ljava.lang.Object;Ljavax/naming/directory/SearchControls;)Ljavax/naming/NamingEnumeration;",
-            additionalClassesToHook = ["javax.naming.directory.InitialDirContext"]
+            additionalClassesToHook = ["javax.naming.directory.InitialDirContext"],
         ),
         MethodHook(
             type = HookType.REPLACE,
             targetClassName = "javax.naming.directory.DirContext",
             targetMethod = "search",
             targetMethodDescriptor = "(Ljava/lang/String;Ljava/lang/String;[Ljava/lang/Object;Ljavax/naming/directory/SearchControls;)Ljavax/naming/NamingEnumeration;",
-            additionalClassesToHook = ["javax.naming.directory.InitialDirContext"]
-        )
+            additionalClassesToHook = ["javax.naming.directory.InitialDirContext"],
+        ),
     )
     @JvmStatic
     fun searchLdapContext(method: MethodHandle, thisObject: Any?, args: Array<Any>, hookId: Int): Any? {
@@ -106,15 +106,15 @@
                     Jazzer.reportFindingFromHook(
                         FuzzerSecurityIssueCritical(
                             """LDAP Injection
-Search filters based on untrusted data must be escape as specified in RFC 4515."""
-                        )
+Search filters based on untrusted data must be escaped as specified in RFC 4515.""",
+                        ),
                     )
                 is NamingException ->
                     Jazzer.reportFindingFromHook(
                         FuzzerSecurityIssueCritical(
                             """LDAP Injection 
-Distinguished Names based on untrusted data must be escaped as specified in RFC 2253."""
-                        )
+Distinguished Names based on untrusted data must be escaped as specified in RFC 2253.""",
+                        ),
                     )
             }
             throw e
diff --git a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/NamingContextLookup.kt b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/NamingContextLookup.kt
index 56e12f0..51cf645 100644
--- a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/NamingContextLookup.kt
+++ b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/NamingContextLookup.kt
@@ -49,14 +49,14 @@
     )
     @JvmStatic
     fun lookupHook(method: MethodHandle?, thisObject: Any?, args: Array<Any?>, hookId: Int): Any {
-        val name = args[0] as String
+        val name = args[0] as? String ?: throw CommunicationException()
         if (name.startsWith(RMI_MARKER) || name.startsWith(LDAP_MARKER)) {
             Jazzer.reportFindingFromHook(
                 FuzzerSecurityIssueCritical(
                     """Remote JNDI Lookup
 JNDI lookups with attacker-controlled remote URLs can, depending on the JDK
-version, lead to remote code execution or the exfiltration of information."""
-                )
+version, lead to remote code execution or the exfiltration of information.""",
+                ),
             )
         }
         Jazzer.guideTowardsEquality(name, RMI_MARKER, hookId)
diff --git a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/OsCommandInjection.kt b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/OsCommandInjection.kt
index d3adc20..87de35c 100644
--- a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/OsCommandInjection.kt
+++ b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/OsCommandInjection.kt
@@ -39,10 +39,11 @@
         type = HookType.BEFORE,
         targetClassName = "java.lang.ProcessImpl",
         targetMethod = "start",
-        additionalClassesToHook = ["java.lang.ProcessBuilder"]
+        additionalClassesToHook = ["java.lang.ProcessBuilder"],
     )
     @JvmStatic
     fun processImplStartHook(method: MethodHandle?, alwaysNull: Any?, args: Array<Any?>, hookId: Int) {
+        if (args.isEmpty()) { return }
         // Calling ProcessBuilder already checks if command array is empty
         @Suppress("UNCHECKED_CAST")
         (args[0] as? Array<String>)?.first().let { cmd ->
@@ -50,8 +51,8 @@
                 Jazzer.reportFindingFromHook(
                     FuzzerSecurityIssueCritical(
                         """OS Command Injection
-Executing OS commands with attacker-controlled data can lead to remote code execution."""
-                    )
+Executing OS commands with attacker-controlled data can lead to remote code execution.""",
+                    ),
                 )
             } else {
                 Jazzer.guideTowardsEquality(cmd, COMMAND, hookId)
diff --git a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/ReflectiveCall.kt b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/ReflectiveCall.kt
index 0fcabe3..62d5815 100644
--- a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/ReflectiveCall.kt
+++ b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/ReflectiveCall.kt
@@ -61,10 +61,11 @@
     )
     @JvmStatic
     fun loadLibraryHook(method: MethodHandle?, alwaysNull: Any?, args: Array<Any?>, hookId: Int) {
+        if (args.isEmpty()) { return }
         val libraryName = args[0] as? String ?: return
         if (libraryName == HONEYPOT_LIBRARY_NAME) {
             Jazzer.reportFindingFromHook(
-                FuzzerSecurityIssueHigh("load arbitrary library")
+                FuzzerSecurityIssueHigh("load arbitrary library"),
             )
         }
         Jazzer.guideTowardsEquality(libraryName, HONEYPOT_LIBRARY_NAME, hookId)
diff --git a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/RegexInjection.kt b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/RegexInjection.kt
index def5f6e..5770f0c 100644
--- a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/RegexInjection.kt
+++ b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/RegexInjection.kt
@@ -23,6 +23,9 @@
 import java.util.regex.Pattern
 import java.util.regex.PatternSyntaxException
 
+// message introduced in JDK14 and ported back to previous versions
+private const val STACK_OVERFLOW_ERROR_MESSAGE = "Stack overflow during pattern compilation"
+
 @Suppress("unused_parameter", "unused")
 object RegexInjection {
     /**
@@ -43,7 +46,7 @@
         type = HookType.REPLACE,
         targetClassName = "java.util.regex.Pattern",
         targetMethod = "compile",
-        targetMethodDescriptor = "(Ljava/lang/String;I)Ljava/util/regex/Pattern;"
+        targetMethodDescriptor = "(Ljava/lang/String;I)Ljava/util/regex/Pattern;",
     )
     @JvmStatic
     fun compileWithFlagsHook(method: MethodHandle, alwaysNull: Any?, args: Array<Any?>, hookId: Int): Any? {
@@ -57,13 +60,13 @@
             type = HookType.REPLACE,
             targetClassName = "java.util.regex.Pattern",
             targetMethod = "compile",
-            targetMethodDescriptor = "(Ljava/lang/String;)Ljava/util/regex/Pattern;"
+            targetMethodDescriptor = "(Ljava/lang/String;)Ljava/util/regex/Pattern;",
         ),
         MethodHook(
             type = HookType.REPLACE,
             targetClassName = "java.util.regex.Pattern",
             targetMethod = "matches",
-            targetMethodDescriptor = "(Ljava/lang/String;Ljava/lang/CharSequence;)Z"
+            targetMethodDescriptor = "(Ljava/lang/String;Ljava/lang/CharSequence;)Z",
         ),
     )
     @JvmStatic
@@ -113,7 +116,7 @@
         pattern: String?,
         hasCanonEqFlag: Boolean,
         hookId: Int,
-        vararg args: Any?
+        vararg args: Any?,
     ): Any? {
         if (hasCanonEqFlag && pattern != null) {
             // With CANON_EQ enabled, Pattern.compile allocates an array with a size that is
@@ -128,8 +131,8 @@
                         """Regular Expression Injection with CANON_EQ
 When java.util.regex.Pattern.compile is used with the Pattern.CANON_EQ flag,
 every injection into the regular expression pattern can cause arbitrarily large
-memory allocations, even when wrapped with Pattern.quote(...)."""
-                    )
+memory allocations, even when wrapped with Pattern.quote(...).""",
+                    ),
                 )
             } else {
                 Jazzer.guideTowardsContainment(pattern, CANON_EQ_ALMOST_EXPLOIT, hookId)
@@ -143,15 +146,15 @@
                 }
             }
         } catch (e: Exception) {
-            if (e is PatternSyntaxException) {
+            if (e is PatternSyntaxException && !(e.message ?: "").startsWith(STACK_OVERFLOW_ERROR_MESSAGE)) {
                 Jazzer.reportFindingFromHook(
                     FuzzerSecurityIssueLow(
                         """Regular Expression Injection
 Regular expression patterns that contain unescaped untrusted input can consume
 arbitrary amounts of CPU time. To properly escape the input, wrap it with
 Pattern.quote(...).""",
-                        e
-                    )
+                        e,
+                    ),
                 )
             }
             throw e
diff --git a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/RegexRoadblocks.java b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/RegexRoadblocks.java
index 1043ac0..76c499b 100644
--- a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/RegexRoadblocks.java
+++ b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/RegexRoadblocks.java
@@ -22,7 +22,7 @@
 import com.code_intelligence.jazzer.api.HookType;
 import com.code_intelligence.jazzer.api.Jazzer;
 import com.code_intelligence.jazzer.api.MethodHook;
-import com.code_intelligence.jazzer.runtime.UnsafeProvider;
+import com.code_intelligence.jazzer.utils.UnsafeProvider;
 import java.lang.invoke.MethodHandle;
 import java.util.WeakHashMap;
 import java.util.regex.Matcher;
@@ -65,13 +65,6 @@
   private static final ThreadLocal<WeakHashMap<Object, Character>> PREDICATE_SOLUTIONS =
       ThreadLocal.withInitial(WeakHashMap::new);
 
-  // Do not act on instrumented regexes used by Jazzer internally, e.g. by ClassGraph.
-  private static boolean HOOK_DISABLED = true;
-
-  static {
-    Jazzer.onFuzzTargetReady(() -> HOOK_DISABLED = UNSAFE == null);
-  }
-
   @MethodHook(type = HookType.AFTER, targetClassName = "java.util.regex.Pattern$Node",
       targetMethod = "match",
       targetMethodDescriptor = "(Ljava/util/regex/Matcher;ILjava/lang/CharSequence;)Z",
@@ -122,7 +115,7 @@
           })
   public static void
   nodeMatchHook(MethodHandle method, Object node, Object[] args, int hookId, Boolean matched) {
-    if (HOOK_DISABLED || matched || node == null)
+    if (matched || node == null)
       return;
     Matcher matcher = (Matcher) args[0];
     if (matcher == null)
@@ -211,7 +204,7 @@
       additionalClassesToHook = {"java.util.regex.Pattern"})
   public static void
   singleHook(MethodHandle method, Object node, Object[] args, int hookId, Object predicate) {
-    if (HOOK_DISABLED || predicate == null)
+    if (predicate == null)
       return;
     PREDICATE_SOLUTIONS.get().put(predicate, (char) (int) args[0]);
   }
@@ -229,7 +222,7 @@
   public static void
   java8SingleHook(
       MethodHandle method, Object property, Object[] args, int hookId, Object alwaysNull) {
-    if (HOOK_DISABLED || property == null)
+    if (property == null)
       return;
     PREDICATE_SOLUTIONS.get().put(property, (char) (int) args[0]);
   }
@@ -258,7 +251,7 @@
       additionalClassesToHook = {"java.util.regex.Pattern"})
   public static void
   rangeHook(MethodHandle method, Object node, Object[] args, int hookId, Object predicate) {
-    if (HOOK_DISABLED || predicate == null)
+    if (predicate == null)
       return;
     PREDICATE_SOLUTIONS.get().put(predicate, (char) (int) args[0]);
   }
@@ -280,7 +273,7 @@
   public static void
   unionHook(
       MethodHandle method, Object thisObject, Object[] args, int hookId, Object unionPredicate) {
-    if (HOOK_DISABLED || unionPredicate == null)
+    if (unionPredicate == null)
       return;
     Character solution = predicateSolution(thisObject);
     if (solution == null)
@@ -298,7 +291,6 @@
         boolean[] bits = (boolean[]) UNSAFE.getObject(charPredicate, BIT_CLASS_BITS_OFFSET);
         for (int i = 0; i < bits.length; i++) {
           if (bits[i]) {
-            PREDICATE_SOLUTIONS.get().put(charPredicate, (char) i);
             return (char) i;
           }
         }
diff --git a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/ScriptEngineInjection.java b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/ScriptEngineInjection.java
new file mode 100644
index 0000000..6f084bf
--- /dev/null
+++ b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/ScriptEngineInjection.java
@@ -0,0 +1,108 @@
+// Copyright 2023 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.sanitizers;
+
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueCritical;
+import com.code_intelligence.jazzer.api.HookType;
+import com.code_intelligence.jazzer.api.Jazzer;
+import com.code_intelligence.jazzer.api.MethodHook;
+import java.io.IOException;
+import java.io.Reader;
+import java.io.StringReader;
+import java.lang.invoke.MethodHandle;
+
+/**
+ * Detects Script Engine injections.
+ *
+ * <p>
+ * The hooks in this class attempt to detect user input flowing into
+ * {@link javax.script.ScriptEngine#eval(String)} and the like that might lead
+ * to remote code executions depending on the scripting engine's capabilities.
+ * Before JDK 15, the Nashorn Engine was registered by default with
+ * ScriptEngineManager under several aliases, including "js". Nashorn allows
+ * access to JVM classes, for example {@link java.lang.Runtime} allowing the
+ * execution of arbitrary OS commands. Several other scripting engines can be
+ * embedded to the JVM (they must follow the
+ * <a href="https://www.jcp.org/en/jsr/detail?id=223">JSR-223 </a>
+ * specification).
+ **/
+@SuppressWarnings("unused")
+public final class ScriptEngineInjection {
+  private static final String PAYLOAD = "\"jaz\"+\"zer\"";
+
+  /**
+   * String variants of eval can be intercepted by before hooks, as the script
+   * content can directly be checked for the presence of the payload.
+   */
+  @MethodHook(type = HookType.BEFORE, targetClassName = "javax.script.ScriptEngine",
+      targetMethod = "eval", targetMethodDescriptor = "(Ljava/lang/String;)Ljava/lang/Object;")
+  @MethodHook(type = HookType.BEFORE, targetClassName = "javax.script.ScriptEngine",
+      targetMethod = "eval",
+      targetMethodDescriptor = "(Ljava/lang/String;Ljavax/script/ScriptContext;)Ljava/lang/Object;")
+  @MethodHook(type = HookType.BEFORE, targetClassName = "javax.script.ScriptEngine",
+      targetMethod = "eval",
+      targetMethodDescriptor = "(Ljava/lang/String;Ljavax/script/Bindings;)Ljava/lang/Object;")
+  public static void
+  checkScriptEngineExecuteString(
+      MethodHandle method, Object thisObject, Object[] arguments, int hookId) {
+    checkScriptContent((String) arguments[0], hookId);
+  }
+
+  /**
+   * Reader variants of eval must be intercepted by replace hooks, as their
+   * contents are converted to strings, for the payload check, and back to readers
+   * for the actual method invocation.
+   */
+  @MethodHook(type = HookType.REPLACE, targetClassName = "javax.script.ScriptEngine",
+      targetMethod = "eval", targetMethodDescriptor = "(Ljava/io/Reader;)Ljava/lang/Object;")
+  @MethodHook(type = HookType.REPLACE, targetClassName = "javax.script.ScriptEngine",
+      targetMethod = "eval",
+      targetMethodDescriptor = "(Ljava/io/Reader;Ljavax/script/ScriptContext;)Ljava/lang/Object;")
+  @MethodHook(type = HookType.REPLACE, targetClassName = "javax.script.ScriptEngine",
+      targetMethod = "eval",
+      targetMethodDescriptor = "(Ljava/io/Reader;Ljavax/script/Bindings;)Ljava/lang/Object;")
+  public static Object
+  checkScriptEngineExecute(MethodHandle method, Object thisObject, Object[] arguments, int hookId)
+      throws Throwable {
+    if (arguments[0] != null) {
+      String content = readAll((Reader) arguments[0]);
+      checkScriptContent(content, hookId);
+      arguments[0] = new StringReader(content);
+    }
+    return method.invokeWithArguments(thisObject, arguments);
+  }
+
+  private static void checkScriptContent(String content, int hookId) {
+    if (content != null) {
+      if (content.contains(PAYLOAD)) {
+        Jazzer.reportFindingFromHook(new FuzzerSecurityIssueCritical(
+            "Script Engine Injection: Insecure user input was used in script engine invocation.\n"
+            + "Depending on the script engine's capabilities this could lead to sandbox escape and remote code execution."));
+      } else {
+        Jazzer.guideTowardsContainment(content, PAYLOAD, hookId);
+      }
+    }
+  }
+
+  private static String readAll(Reader reader) throws IOException {
+    StringBuilder content = new StringBuilder();
+    char[] buffer = new char[4096];
+    int numChars;
+    while ((numChars = reader.read(buffer)) >= 0) {
+      content.append(buffer, 0, numChars);
+    }
+    return content.toString();
+  }
+}
diff --git a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/ServerSideRequestForgery.java b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/ServerSideRequestForgery.java
new file mode 100644
index 0000000..3ff48e3
--- /dev/null
+++ b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/ServerSideRequestForgery.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.sanitizers;
+
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueHigh;
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium;
+import com.code_intelligence.jazzer.api.HookType;
+import com.code_intelligence.jazzer.api.Jazzer;
+import com.code_intelligence.jazzer.api.MethodHook;
+import java.lang.invoke.MethodHandle;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BiPredicate;
+
+public class ServerSideRequestForgery {
+  // Set via reflection by Jazzer's BugDetectors API.
+  public static final AtomicReference<BiPredicate<String, Integer>> connectionPermitted =
+      new AtomicReference<>((host, port) -> false);
+
+  /**
+   * {@link java.net.Socket} is used in many JDK classes to open network connections. Internally it
+   * delegates to {@link java.net.SocketImpl}, hence, for most situations it's sufficient to hook
+   * the call site {@link java.net.Socket} itself. As {@link java.net.SocketImpl} is an abstract
+   * class all call sites invoking "connect" on concrete implementations get hooked. As JKD internal
+   * classes are normally ignored, they have to be marked for hooking explicitly. In this case, all
+   * internal classes calling "connect" on {@link java.net.SocketImpl} should be listed below.
+   * Internal classes using {@link java.net.SocketImpl#connect(String, int)}:
+   * <ul>
+   *   <li>java.net.Socket (hook required)
+   *   <li>java.net.AbstractPlainSocketImpl (no direct usage, no hook required)
+   *   <li>java.net.PlainSocketImpl (no direct usage, no hook required)
+   *   <li>java.net.HttpConnectSocketImpl (only used in Socket, which is already listed)
+   *   <li>java.net.SocksSocketImpl (used in Socket, but also invoking super.connect directly,
+   *       hook required)
+   *   <li>java.net.ServerSocket (security check, no hook required)
+   * </ul>
+   */
+  @MethodHook(type = HookType.BEFORE, targetClassName = "java.net.SocketImpl",
+      targetMethod = "connect",
+      additionalClassesToHook =
+          {
+              "java.net.Socket",
+              "java.net.SocksSocketImpl",
+          })
+  public static void
+  checkSsrfSocket(MethodHandle method, Object thisObject, Object[] arguments, int hookId) {
+    checkSsrf(arguments);
+  }
+
+  /**
+   * {@link java.nio.channels.SocketChannel} is used in many JDK classes to open (non-blocking)
+   * network connections, e.g. {@link java.net.http.HttpClient} uses it internally. The actual
+   * connection is established in the abstract "connect" method. Hooking that also hooks invocations
+   * of all concrete implementations, from which only one exists in {@link
+   * sun.nio.ch.SocketChannelImpl}. "connect" is only called in {@link
+   * java.nio.channels.SocketChannel} itself and the two mentioned classes below.
+   */
+  @MethodHook(type = HookType.BEFORE, targetClassName = "java.nio.channels.SocketChannel",
+      targetMethod = "connect",
+      additionalClassesToHook =
+          {
+              "sun.nio.ch.SocketAdaptor",
+              "jdk.internal.net.http.PlainHttpConnection",
+          })
+  public static void
+  checkSsrfHttpConnection(MethodHandle method, Object thisObject, Object[] arguments, int hookId) {
+    checkSsrf(arguments);
+  }
+
+  private static void checkSsrf(Object[] arguments) {
+    if (arguments.length == 0) {
+      return;
+    }
+
+    String host;
+    int port;
+    if (arguments[0] instanceof InetSocketAddress) {
+      // Only implementation of java.net.SocketAddress.
+      InetSocketAddress address = (InetSocketAddress) arguments[0];
+      host = address.getHostName();
+      port = address.getPort();
+    } else if (arguments.length >= 2 && arguments[1] instanceof Integer) {
+      if (arguments[0] instanceof InetAddress) {
+        host = ((InetAddress) arguments[0]).getHostName();
+      } else if (arguments[0] instanceof String) {
+        host = (String) arguments[0];
+      } else {
+        return;
+      }
+      port = (int) arguments[1];
+    } else {
+      return;
+    }
+
+    if (port < 0 || port > 65535) {
+      return;
+    }
+
+    if (!connectionPermitted.get().test(host, port)) {
+      Jazzer.reportFindingFromHook(new FuzzerSecurityIssueMedium(String.format(
+          "Server Side Request Forgery (SSRF)\n"
+              + "Attempted connection to: %s:%d\n"
+              + "Requests to destinations based on untrusted data could lead to exfiltration of "
+              + "sensitive data or exposure of internal services.\n\n"
+              + "If the fuzz test is expected to perform network connections, call "
+              + "com.code_intelligence.jazzer.api.BugDetectors#allowNetworkConnections at the "
+              + "beginning of your fuzz test and optionally provide a predicate matching the "
+              + "expected hosts.",
+          host, port)));
+    }
+  }
+}
diff --git a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/SqlInjection.java b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/SqlInjection.java
new file mode 100644
index 0000000..da5beaa
--- /dev/null
+++ b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/SqlInjection.java
@@ -0,0 +1,119 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.sanitizers;
+
+import static java.util.Collections.unmodifiableSet;
+import static java.util.stream.Collectors.toSet;
+
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueHigh;
+import com.code_intelligence.jazzer.api.HookType;
+import com.code_intelligence.jazzer.api.Jazzer;
+import com.code_intelligence.jazzer.api.MethodHook;
+import java.lang.invoke.MethodHandle;
+import java.util.Arrays;
+import java.util.Set;
+import java.util.stream.Stream;
+import net.sf.jsqlparser.JSQLParserException;
+import net.sf.jsqlparser.parser.CCJSqlParserUtil;
+
+/**
+ * Detects SQL injections.
+ *
+ * <p>Untrusted input has to be escaped in such a way that queries remain valid otherwise an
+ * injection could be possible. This sanitizer guides the fuzzer to inject insecure characters. If
+ * an exception is raised during execution the fuzzer was able to inject an invalid pattern,
+ * otherwise all input was escaped correctly.
+ *
+ * <p>Two types of methods are hooked:
+ * <ol>
+ * <li>Methods that take an SQL query as the first argument (e.g. {@link
+ * java.sql.Statement#execute}]).</li>
+ * <li>Methods that don't take any arguments and execute an already prepared statement (e.g. {@link
+ * java.sql.PreparedStatement#execute}).</li>
+ * </ol>
+ *
+ * For 1. we validate the syntax of the query using <a
+ * href="https://github.com/JSQLParser/JSqlParser">jsqlparser</a> and if both the syntax is invalid
+ * and the query execution throws an exception we report an SQL injection. Since we can't reliably
+ * validate SQL queries in arbitrary dialects this hook is expected to produce some amount of false
+ * positives. For 2. we can't validate the query syntax and therefore only rethrow any exceptions.
+ */
+@SuppressWarnings("unused")
+public class SqlInjection {
+  // Characters that should be escaped in user input.
+  // See https://dev.mysql.com/doc/refman/8.0/en/string-literals.html
+  private static final String CHARACTERS_TO_ESCAPE = "'\"\b\n\r\t\\%_";
+
+  private static final Set<String> SQL_SYNTAX_ERROR_EXCEPTIONS = unmodifiableSet(
+      Stream
+          .of("java.sql.SQLException", "java.sql.SQLNonTransientException",
+              "java.sql.SQLSyntaxErrorException", "org.h2.jdbc.JdbcSQLSyntaxErrorException",
+              "org.h2.jdbc.JdbcSQLFeatureNotSupportedException")
+          .collect(toSet()));
+
+  @MethodHook(
+      type = HookType.REPLACE, targetClassName = "java.sql.Statement", targetMethod = "execute")
+  @MethodHook(type = HookType.REPLACE, targetClassName = "java.sql.Statement",
+      targetMethod = "executeBatch")
+  @MethodHook(type = HookType.REPLACE, targetClassName = "java.sql.Statement",
+      targetMethod = "executeLargeBatch")
+  @MethodHook(type = HookType.REPLACE, targetClassName = "java.sql.Statement",
+      targetMethod = "executeLargeUpdate")
+  @MethodHook(type = HookType.REPLACE, targetClassName = "java.sql.Statement",
+      targetMethod = "executeQuery")
+  @MethodHook(type = HookType.REPLACE, targetClassName = "java.sql.Statement",
+      targetMethod = "executeUpdate")
+  @MethodHook(type = HookType.REPLACE, targetClassName = "javax.persistence.EntityManager",
+      targetMethod = "createNativeQuery")
+  public static Object
+  checkSqlExecute(MethodHandle method, Object thisObject, Object[] arguments, int hookId)
+      throws Throwable {
+    boolean hasValidSqlQuery = false;
+
+    if (arguments.length > 0 && arguments[0] instanceof String) {
+      String query = (String) arguments[0];
+      hasValidSqlQuery = isValidSql(query);
+      Jazzer.guideTowardsContainment(query, CHARACTERS_TO_ESCAPE, hookId);
+    }
+    try {
+      return method.invokeWithArguments(
+          Stream.concat(Stream.of(thisObject), Arrays.stream(arguments)).toArray());
+    } catch (Throwable throwable) {
+      // If we already validated the query string and know it's correct,
+      // The exception is likely thrown by a non-existent table or something
+      // that we don't want to report.
+      if (!hasValidSqlQuery
+          && SQL_SYNTAX_ERROR_EXCEPTIONS.contains(throwable.getClass().getName())) {
+        Jazzer.reportFindingFromHook(new FuzzerSecurityIssueHigh(
+            String.format("SQL Injection%nInjected query: %s%n", arguments[0])));
+      }
+      throw throwable;
+    }
+  }
+
+  private static boolean isValidSql(String sql) {
+    try {
+      CCJSqlParserUtil.parseStatements(sql);
+      return true;
+    } catch (JSQLParserException e) {
+      return false;
+    } catch (Throwable t) {
+      // Catch any unexpected exceptions so that we don't disturb the
+      // instrumented application.
+      t.printStackTrace();
+      return true;
+    }
+  }
+}
diff --git a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/SqlInjection.kt b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/SqlInjection.kt
deleted file mode 100644
index f317bcc..0000000
--- a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/SqlInjection.kt
+++ /dev/null
@@ -1,113 +0,0 @@
-// Copyright 2022 Code Intelligence GmbH
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.code_intelligence.jazzer.sanitizers
-
-import com.code_intelligence.jazzer.api.FuzzerSecurityIssueHigh
-import com.code_intelligence.jazzer.api.HookType
-import com.code_intelligence.jazzer.api.Jazzer
-import com.code_intelligence.jazzer.api.MethodHook
-import com.code_intelligence.jazzer.api.MethodHooks
-import net.sf.jsqlparser.JSQLParserException
-import net.sf.jsqlparser.parser.CCJSqlParserUtil
-import java.lang.invoke.MethodHandle
-
-/**
- * Detects SQL injections.
- *
- * Untrusted input has to be escaped in such a way that queries remain valid otherwise an injection
- * could be possible. This sanitizer guides the fuzzer to inject insecure characters. If an exception
- * is raised during execution the fuzzer was able to inject an invalid pattern, otherwise all input
- * was escaped correctly.
- *
- * Two types of methods are hooked:
- *   1. Methods that take an SQL query as the first argument (e.g. [java.sql.Statement.execute]).
- *   2. Methods that don't take any arguments and execute an already prepared statement
- *      (e.g. [java.sql.PreparedStatement.execute]).
- * For 1. we validate the syntax of the query using <a href="https://github.com/JSQLParser/JSqlParser">jsqlparser</a>
- * and if both the syntax is invalid and the query execution throws an exception we report an SQL injection.
- * Since we can't reliably validate SQL queries in arbitrary dialects this hook is expected to produce some
- * amount of false positives.
- * For 2. we can't validate the query syntax and therefore only rethrow any exceptions.
- */
-@Suppress("unused_parameter", "unused")
-object SqlInjection {
-
-    // Characters that should be escaped in user input.
-    // See https://dev.mysql.com/doc/refman/8.0/en/string-literals.html
-    private const val CHARACTERS_TO_ESCAPE = "'\"\b\n\r\t\\%_"
-
-    private val SQL_SYNTAX_ERROR_EXCEPTIONS = listOf(
-        "java.sql.SQLException",
-        "java.sql.SQLNonTransientException",
-        "java.sql.SQLSyntaxErrorException",
-        "org.h2.jdbc.JdbcSQLSyntaxErrorException",
-    )
-
-    @MethodHooks(
-        MethodHook(type = HookType.REPLACE, targetClassName = "java.sql.Statement", targetMethod = "execute"),
-        MethodHook(type = HookType.REPLACE, targetClassName = "java.sql.Statement", targetMethod = "executeBatch"),
-        MethodHook(type = HookType.REPLACE, targetClassName = "java.sql.Statement", targetMethod = "executeLargeBatch"),
-        MethodHook(type = HookType.REPLACE, targetClassName = "java.sql.Statement", targetMethod = "executeLargeUpdate"),
-        MethodHook(type = HookType.REPLACE, targetClassName = "java.sql.Statement", targetMethod = "executeQuery"),
-        MethodHook(type = HookType.REPLACE, targetClassName = "java.sql.Statement", targetMethod = "executeUpdate"),
-        MethodHook(
-            type = HookType.REPLACE,
-            targetClassName = "javax.persistence.EntityManager",
-            targetMethod = "createNativeQuery"
-        )
-    )
-    @JvmStatic
-    fun checkSqlExecute(method: MethodHandle, thisObject: Any?, arguments: Array<Any>, hookId: Int): Any {
-        var hasValidSqlQuery = false
-
-        if (arguments.isNotEmpty() && arguments[0] is String) {
-            val query = arguments[0] as String
-            hasValidSqlQuery = isValidSql(query)
-            Jazzer.guideTowardsContainment(query, CHARACTERS_TO_ESCAPE, hookId)
-        }
-        return try {
-            method.invokeWithArguments(thisObject, *arguments)
-        } catch (throwable: Throwable) {
-            // If we already validated the query string and know it's correct,
-            // The exception is likely thrown by a non-existent table or something
-            // that we don't want to report.
-            if (!hasValidSqlQuery && SQL_SYNTAX_ERROR_EXCEPTIONS.contains(throwable.javaClass.name)) {
-                Jazzer.reportFindingFromHook(
-                    FuzzerSecurityIssueHigh(
-                        """
-                    SQL Injection
-                    Injected query: ${arguments[0]}
-                        """.trimIndent(),
-                        throwable
-                    )
-                )
-            }
-            throw throwable
-        }
-    }
-
-    private fun isValidSql(sql: String): Boolean =
-        try {
-            CCJSqlParserUtil.parseStatements(sql)
-            true
-        } catch (e: JSQLParserException) {
-            false
-        } catch (t: Throwable) {
-            // Catch any unexpected exceptions so that we don't disturb the
-            // instrumented application.
-            t.printStackTrace()
-            true
-        }
-}
diff --git a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/XPathInjection.kt b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/XPathInjection.kt
new file mode 100644
index 0000000..b54d083
--- /dev/null
+++ b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/XPathInjection.kt
@@ -0,0 +1,78 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.sanitizers
+
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueHigh
+import com.code_intelligence.jazzer.api.HookType
+import com.code_intelligence.jazzer.api.Jazzer
+import com.code_intelligence.jazzer.api.MethodHook
+import com.code_intelligence.jazzer.api.MethodHooks
+import java.lang.invoke.MethodHandle
+import javax.xml.xpath.XPathExpressionException
+
+/**
+ * Detects XPath injections.
+ *
+ * Untrusted input has to be escaped in such a way that queries remain valid, otherwise an injection
+ * could be possible. This sanitizer guides the fuzzer to inject insecure characters. If an exception
+ * is raised during execution the fuzzer was able to inject an invalid pattern, otherwise all input
+ * was escaped correctly.
+ * Checking if the innermost cause of XPathExpressionException is a TransformerException should
+ * indicate injection instead of a false positive.
+ */
+@Suppress("unused_parameter", "unused")
+object XPathInjection {
+
+    // Characters that should be escaped in user input.
+    // https://owasp.org/www-community/attacks/XPATH_Injection
+    private const val CHARACTERS_TO_ESCAPE = "'\""
+
+    private val XPATH_SYNTAX_ERROR_EXCEPTIONS = "javax.xml.transform.TransformerException"
+
+    @MethodHooks(
+        MethodHook(type = HookType.REPLACE, targetClassName = "javax.xml.xpath.XPath", targetMethod = "compile"),
+        MethodHook(type = HookType.REPLACE, targetClassName = "javax.xml.xpath.XPath", targetMethod = "evaluate"),
+        MethodHook(type = HookType.REPLACE, targetClassName = "javax.xml.xpath.XPath", targetMethod = "evaluateExpression"),
+    )
+    @JvmStatic
+    fun checkXpathExecute(method: MethodHandle, thisObject: Any?, arguments: Array<Any>, hookId: Int): Any {
+        if (arguments.isNotEmpty() && arguments[0] is String) {
+            val query = arguments[0] as String
+            Jazzer.guideTowardsContainment(query, CHARACTERS_TO_ESCAPE, hookId)
+        }
+        return try {
+            method.invokeWithArguments(thisObject, *arguments)
+        } catch (exception: XPathExpressionException) {
+            // find innermost cause
+            var innerCause = exception.cause
+            while (innerCause?.cause != null && innerCause.cause != innerCause) {
+                innerCause = innerCause.cause
+            }
+
+            if (innerCause != null && XPATH_SYNTAX_ERROR_EXCEPTIONS.equals(innerCause.javaClass.name)) {
+                Jazzer.reportFindingFromHook(
+                    FuzzerSecurityIssueHigh(
+                        """
+                    XPath Injection
+                    Injected query: ${arguments[0]}
+                        """.trimIndent(),
+                        exception,
+                    ),
+                )
+            }
+            throw exception
+        }
+    }
+}
diff --git a/sanitizers/src/test/java/com/example/BUILD.bazel b/sanitizers/src/test/java/com/example/BUILD.bazel
index 5d2e1ca..ea0a7f8 100644
--- a/sanitizers/src/test/java/com/example/BUILD.bazel
+++ b/sanitizers/src/test/java/com/example/BUILD.bazel
@@ -6,7 +6,10 @@
     srcs = [
         "ObjectInputStreamDeserialization.java",
     ],
-    expected_findings = ["java.lang.ExceptionInInitializerError"],
+    allowed_findings = [
+        "com.code_intelligence.jazzer.api.FuzzerSecurityIssueHigh",
+        "java.lang.ExceptionInInitializerError",
+    ],
     target_class = "com.example.ObjectInputStreamDeserialization",
 )
 
@@ -15,7 +18,10 @@
     srcs = [
         "ReflectiveCall.java",
     ],
-    expected_findings = ["java.lang.ExceptionInInitializerError"],
+    allowed_findings = [
+        "com.code_intelligence.jazzer.api.FuzzerSecurityIssueHigh",
+        "java.lang.ExceptionInInitializerError",
+    ],
     target_class = "com.example.ReflectiveCall",
 )
 
@@ -24,25 +30,30 @@
     srcs = [
         "LibraryLoad.java",
     ],
+    allowed_findings = [
+        "com.code_intelligence.jazzer.api.FuzzerSecurityIssueHigh",
+    ],
     target_class = "com.example.LibraryLoad",
     # loading of native libraries is very slow on macos,
     # especially using Java 17
     target_compatible_with = SKIP_ON_MACOS,
+    # The reproducer doesn't contain the sanitizer and thus runs into an ordinary ignored
+    # UnsatisfiedLinkError.
+    verify_crash_reproducer = False,
 )
 
 java_fuzz_target_test(
     name = "ExpressionLanguageInjection",
     srcs = [
         "ExpressionLanguageInjection.java",
-        "InsecureEmailValidator.java",
     ],
+    allowed_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueHigh"],
     target_class = "com.example.ExpressionLanguageInjection",
+    # The reproducer can't find jaz.Zer and thus doesn't crash.
+    verify_crash_reproducer = False,
     deps = [
-        "@maven//:javax_el_javax_el_api",
+        "//sanitizers/src/test/java/com/example/el:ExpressionLanguageExample",
         "@maven//:javax_validation_validation_api",
-        "@maven//:javax_xml_bind_jaxb_api",
-        "@maven//:org_glassfish_javax_el",
-        "@maven//:org_hibernate_hibernate_validator",
     ],
 )
 
@@ -51,7 +62,9 @@
     srcs = [
         "OsCommandInjectionProcessBuilder.java",
     ],
+    allowed_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueCritical"],
     target_class = "com.example.OsCommandInjectionProcessBuilder",
+    verify_crash_reproducer = False,
 )
 
 java_fuzz_target_test(
@@ -59,17 +72,22 @@
     srcs = [
         "OsCommandInjectionRuntimeExec.java",
     ],
+    allowed_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueCritical"],
     target_class = "com.example.OsCommandInjectionRuntimeExec",
+    verify_crash_reproducer = False,
 )
 
 java_fuzz_target_test(
     name = "LdapSearchInjection",
     srcs = [
         "LdapSearchInjection.java",
-        "ldap/MockInitialContextFactory.java",
         "ldap/MockLdapContext.java",
     ],
-    expected_findings = ["javax.naming.directory.InvalidSearchFilterException"],
+    allowed_findings = [
+        "com.code_intelligence.jazzer.api.FuzzerSecurityIssueCritical",
+        # The crashing input encoded by the replayer does not have valid syntax, but no hook.
+        "javax.naming.directory.InvalidSearchFilterException",
+    ],
     target_class = "com.example.LdapSearchInjection",
     deps = [
         "@maven//:com_unboundid_unboundid_ldapsdk",
@@ -80,10 +98,13 @@
     name = "LdapDnInjection",
     srcs = [
         "LdapDnInjection.java",
-        "ldap/MockInitialContextFactory.java",
         "ldap/MockLdapContext.java",
     ],
-    expected_findings = ["javax.naming.NamingException"],
+    allowed_findings = [
+        "com.code_intelligence.jazzer.api.FuzzerSecurityIssueCritical",
+        # The crashing input encoded by the reproducer does not have valid syntax, but no hook.
+        "javax.naming.NamingException",
+    ],
     target_class = "com.example.LdapDnInjection",
     deps = [
         "@maven//:com_unboundid_unboundid_ldapsdk",
@@ -93,7 +114,9 @@
 java_fuzz_target_test(
     name = "RegexInsecureQuoteInjection",
     srcs = ["RegexInsecureQuoteInjection.java"],
+    allowed_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow"],
     target_class = "com.example.RegexInsecureQuoteInjection",
+    verify_crash_reproducer = False,
 )
 
 java_fuzz_target_test(
@@ -101,7 +124,9 @@
     srcs = [
         "RegexCanonEqInjection.java",
     ],
+    allowed_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow"],
     target_class = "com.example.RegexCanonEqInjection",
+    verify_crash_reproducer = False,
 )
 
 java_fuzz_target_test(
@@ -109,19 +134,40 @@
     srcs = [
         "ClassLoaderLoadClass.java",
     ],
-    expected_findings = ["java.lang.ExceptionInInitializerError"],
+    allowed_findings = [
+        "com.code_intelligence.jazzer.api.FuzzerSecurityIssueHigh",
+        # Reproducer does not find the honeypot library and doesn't have the hook.
+        "java.lang.ExceptionInInitializerError",
+    ],
     target_class = "com.example.ClassLoaderLoadClass",
 )
 
 java_fuzz_target_test(
     name = "RegexRoadblocks",
     srcs = ["RegexRoadblocks.java"],
+    allowed_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow"],
     fuzzer_args = [
         # Limit the number of runs to verify that the regex roadblocks are
         # cleared quickly.
         "-runs=22000",
     ],
     target_class = "com.example.RegexRoadblocks",
+    verify_crash_reproducer = False,
+)
+
+# Catching StackOverflowErrors doesn't work reliably across all systems and JDK versions.
+# It may lead to a native crash before we can handle the exception in Java, therefore the
+# test is set to manual execution.
+java_fuzz_target_test(
+    name = "StackOverflowRegexInjection",
+    srcs = ["StackOverflowRegexInjection.java"],
+    allowed_findings = ["java.util.regex.PatternSyntaxException"],
+    fuzzer_args = [
+        "-runs=1",
+    ],
+    tags = ["manual"],
+    target_class = "com.example.StackOverflowRegexInjection",
+    verify_crash_reproducer = False,
 )
 
 java_fuzz_target_test(
@@ -129,7 +175,7 @@
     srcs = [
         "SqlInjection.java",
     ],
-    expected_findings = [
+    allowed_findings = [
         "com.code_intelligence.jazzer.api.FuzzerSecurityIssueHigh",
         "org.h2.jdbc.JdbcSQLSyntaxErrorException",
     ],
@@ -138,3 +184,90 @@
         "@maven//:com_h2database_h2",
     ],
 )
+
+java_test(
+    name = "DisabledHooksTest",
+    size = "small",
+    srcs = [
+        "DisabledHooksTest.java",
+    ],
+    test_class = "com.example.DisabledHooksTest",
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/api",
+        "//src/main/java/com/code_intelligence/jazzer/api:hooks",
+    ],
+)
+
+java_fuzz_target_test(
+    name = "XPathInjection",
+    srcs = [
+        "XPathInjection.java",
+    ],
+    allowed_findings = [
+        "com.code_intelligence.jazzer.api.FuzzerSecurityIssueHigh",
+    ],
+    target_class = "com.example.XPathInjection",
+    # Fuzz target catches the syntax exception triggered by the reproducer without the sanitizer.
+    verify_crash_reproducer = False,
+)
+
+java_fuzz_target_test(
+    name = "SsrfSocketConnect",
+    srcs = [
+        "SsrfSocketConnect.java",
+    ],
+    allowed_findings = [
+        "com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium",
+    ],
+    target_class = "com.example.SsrfSocketConnect",
+    verify_crash_reproducer = False,
+)
+
+java_fuzz_target_test(
+    name = "SsrfSocketConnectToHost",
+    srcs = [
+        "SsrfSocketConnectToHost.java",
+    ],
+    allowed_findings = [
+        "com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium",
+    ],
+    target_class = "com.example.SsrfSocketConnectToHost",
+    verify_crash_reproducer = False,
+)
+
+java_fuzz_target_test(
+    name = "SsrfUrlConnection",
+    srcs = [
+        "SsrfUrlConnection.java",
+    ],
+    allowed_findings = [
+        "com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium",
+    ],
+    target_class = "com.example.SsrfUrlConnection",
+    verify_crash_reproducer = False,
+)
+
+java_fuzz_target_test(
+    name = "SsrfHttpClient",
+    srcs = [
+        "SsrfHttpClient.java",
+    ],
+    allowed_findings = [
+        "com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium",
+    ],
+    tags = ["no-jdk8"],
+    target_class = "com.example.SsrfHttpClient",
+    verify_crash_reproducer = False,
+)
+
+java_fuzz_target_test(
+    name = "ScriptEngineInjection",
+    srcs = [
+        "ScriptEngineInjection.java",
+    ],
+    allowed_findings = [
+        "com.code_intelligence.jazzer.api.FuzzerSecurityIssueCritical",
+    ],
+    target_class = "com.example.ScriptEngineInjection",
+    verify_crash_reproducer = False,
+)
diff --git a/sanitizers/src/test/java/com/example/ClassLoaderLoadClass.java b/sanitizers/src/test/java/com/example/ClassLoaderLoadClass.java
index c3fa47a..207f29c 100644
--- a/sanitizers/src/test/java/com/example/ClassLoaderLoadClass.java
+++ b/sanitizers/src/test/java/com/example/ClassLoaderLoadClass.java
@@ -22,9 +22,11 @@
     String input = data.consumeRemainingAsAsciiString();
     try {
       // create an instance to trigger class initialization
-      ClassLoaderLoadClass.class.getClassLoader().loadClass(input).getConstructor().newInstance();
-    } catch (ClassNotFoundException | InvocationTargetException | InstantiationException
-        | IllegalAccessException | NoSuchMethodException ignored) {
+      ClassLoaderLoadClass.class.getClassLoader().loadClass(input).newInstance();
+      // TODO(khaled): this fails to reproduce the finding. It seems that this is related to not
+      // throwing a hard-to-catch error when not running in the fuzzing mode.
+      // ClassLoaderLoadClass.class.getClassLoader().loadClass(input).getConstructor().newInstance();
+    } catch (ClassNotFoundException | InstantiationException | IllegalAccessException ignored) {
     }
   }
 }
diff --git a/sanitizers/src/test/java/com/example/DisabledHooksTest.java b/sanitizers/src/test/java/com/example/DisabledHooksTest.java
new file mode 100644
index 0000000..763cd63
--- /dev/null
+++ b/sanitizers/src/test/java/com/example/DisabledHooksTest.java
@@ -0,0 +1,110 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.example;
+
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueHigh;
+import java.io.*;
+import java.lang.reflect.InvocationTargetException;
+import java.util.Base64;
+import org.junit.After;
+import org.junit.Test;
+
+public class DisabledHooksTest {
+  public static void triggerReflectiveCallSanitizer() {
+    try {
+      Class.forName("jaz.Zer").newInstance();
+    } catch (ClassNotFoundException | IllegalAccessException | InstantiationException ignored) {
+    }
+  }
+
+  public static void triggerExpressionLanguageInjectionSanitizer() throws Throwable {
+    try {
+      Class.forName("jaz.Zer").getMethod("el").invoke(null);
+    } catch (InvocationTargetException e) {
+      throw e.getCause();
+    } catch (IllegalAccessException | ClassNotFoundException | NoSuchMethodException ignore) {
+    }
+  }
+
+  public static void triggerDeserializationSanitizer() {
+    byte[] data =
+        Base64.getDecoder().decode("rO0ABXNyAAdqYXouWmVyAAAAAAAAACoCAAFCAAlzYW5pdGl6ZXJ4cAEK");
+    try {
+      ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data));
+      System.out.println(ois.readObject());
+    } catch (IOException | ClassNotFoundException ignore) {
+    }
+  }
+
+  @After
+  public void resetDisabledHooksProperty() {
+    System.clearProperty("jazzer.disabled_hooks");
+  }
+
+  @Test(expected = FuzzerSecurityIssueHigh.class)
+  public void enableReflectiveCallSanitizer() {
+    triggerReflectiveCallSanitizer();
+  }
+
+  @Test(expected = FuzzerSecurityIssueHigh.class)
+  public void enableDeserializationSanitizer() {
+    triggerDeserializationSanitizer();
+  }
+
+  @Test(expected = FuzzerSecurityIssueHigh.class)
+  public void enableExpressionLanguageInjectionSanitizer() throws Throwable {
+    triggerExpressionLanguageInjectionSanitizer();
+  }
+
+  @Test
+  public void disableReflectiveCallSanitizer() {
+    System.setProperty(
+        "jazzer.disabled_hooks", "com.code_intelligence.jazzer.sanitizers.ReflectiveCall");
+    triggerReflectiveCallSanitizer();
+  }
+
+  @Test
+  public void disableDeserializationSanitizer() {
+    System.setProperty(
+        "jazzer.disabled_hooks", "com.code_intelligence.jazzer.sanitizers.Deserialization");
+    triggerDeserializationSanitizer();
+  }
+
+  @Test
+  public void disableExpressionLanguageSanitizer() throws Throwable {
+    System.setProperty("jazzer.disabled_hooks",
+        "com.code_intelligence.jazzer.sanitizers.ExpressionLanguageInjection");
+    triggerExpressionLanguageInjectionSanitizer();
+  }
+
+  @Test(expected = FuzzerSecurityIssueHigh.class)
+  public void disableReflectiveCallAndEnableDeserialization() {
+    System.setProperty(
+        "jazzer.disabled_hooks", "com.code_intelligence.jazzer.sanitizers.ReflectiveCall");
+    triggerReflectiveCallSanitizer();
+    triggerDeserializationSanitizer();
+  }
+
+  @Test
+  public void disableAllSanitizers() throws Throwable {
+    System.setProperty("jazzer.disabled_hooks",
+        "com.code_intelligence.jazzer.sanitizers.ReflectiveCall,"
+            + "com.code_intelligence.jazzer.sanitizers.Deserialization,"
+            + "com.code_intelligence.jazzer.sanitizers.ExpressionLanguageInjection");
+    triggerReflectiveCallSanitizer();
+    triggerExpressionLanguageInjectionSanitizer();
+    triggerDeserializationSanitizer();
+  }
+}
diff --git a/sanitizers/src/test/java/com/example/ExpressionLanguageInjection.java b/sanitizers/src/test/java/com/example/ExpressionLanguageInjection.java
index e26a911..7d0192a 100644
--- a/sanitizers/src/test/java/com/example/ExpressionLanguageInjection.java
+++ b/sanitizers/src/test/java/com/example/ExpressionLanguageInjection.java
@@ -15,33 +15,20 @@
 package com.example;
 
 import com.code_intelligence.jazzer.api.FuzzedDataProvider;
-import java.lang.annotation.ElementType;
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-import java.lang.annotation.Target;
-import javax.validation.*;
-
-class UserData {
-  public UserData(String email) {
-    this.email = email;
-  }
-
-  @ValidEmailConstraint private String email;
-}
-
-@Constraint(validatedBy = InsecureEmailValidator.class)
-@Target({ElementType.METHOD, ElementType.FIELD})
-@Retention(RetentionPolicy.RUNTIME)
-@interface ValidEmailConstraint {
-  String message() default "Invalid email address";
-  Class<?>[] groups() default {};
-  Class<? extends Payload>[] payload() default {};
-}
+import com.example.el.UserData;
+import java.util.logging.Level;
+import java.util.logging.LogManager;
+import javax.validation.Validation;
+import javax.validation.Validator;
 
 public class ExpressionLanguageInjection {
   final private static Validator validator =
       Validation.buildDefaultValidatorFactory().getValidator();
 
+  public static void fuzzerInitialize() {
+    LogManager.getLogManager().getLogger("").setLevel(Level.SEVERE);
+  }
+
   public static void fuzzerTestOneInput(FuzzedDataProvider data) {
     UserData uncheckedUserData = new UserData(data.consumeRemainingAsString());
     validator.validate(uncheckedUserData);
diff --git a/sanitizers/src/test/java/com/example/LdapDnInjection.java b/sanitizers/src/test/java/com/example/LdapDnInjection.java
index 911db1d..2fdf4a0 100644
--- a/sanitizers/src/test/java/com/example/LdapDnInjection.java
+++ b/sanitizers/src/test/java/com/example/LdapDnInjection.java
@@ -15,20 +15,13 @@
 package com.example;
 
 import com.code_intelligence.jazzer.api.FuzzedDataProvider;
-import java.util.Hashtable;
-import javax.naming.Context;
-import javax.naming.NamingException;
-import javax.naming.directory.InitialDirContext;
+import com.example.ldap.MockLdapContext;
+import javax.naming.directory.DirContext;
 import javax.naming.directory.SearchControls;
 
+@SuppressWarnings("BanJNDI")
 public class LdapDnInjection {
-  private static InitialDirContext ctx;
-
-  public static void fuzzerInitialize() throws NamingException {
-    Hashtable<String, String> env = new Hashtable<>();
-    env.put(Context.INITIAL_CONTEXT_FACTORY, "com.example.ldap.MockInitialContextFactory");
-    ctx = new InitialDirContext(env);
-  }
+  private static final DirContext ctx = new MockLdapContext();
 
   public static void fuzzerTestOneInput(FuzzedDataProvider fuzzedDataProvider) throws Exception {
     // Externally provided DN input needs to be escaped properly
diff --git a/sanitizers/src/test/java/com/example/LdapSearchInjection.java b/sanitizers/src/test/java/com/example/LdapSearchInjection.java
index b3dfee7..4ac8493 100644
--- a/sanitizers/src/test/java/com/example/LdapSearchInjection.java
+++ b/sanitizers/src/test/java/com/example/LdapSearchInjection.java
@@ -15,20 +15,13 @@
 package com.example;
 
 import com.code_intelligence.jazzer.api.FuzzedDataProvider;
-import java.util.Hashtable;
-import javax.naming.Context;
-import javax.naming.NamingException;
+import com.example.ldap.MockLdapContext;
 import javax.naming.directory.SearchControls;
-import javax.naming.ldap.InitialLdapContext;
+import javax.naming.ldap.LdapContext;
 
+@SuppressWarnings("BanJNDI")
 public class LdapSearchInjection {
-  private static InitialLdapContext ctx;
-
-  public static void fuzzerInitialize() throws NamingException {
-    Hashtable<String, String> env = new Hashtable<>();
-    env.put(Context.INITIAL_CONTEXT_FACTORY, "com.example.ldap.MockInitialContextFactory");
-    ctx = new InitialLdapContext(env, null);
-  }
+  private static final LdapContext ctx = new MockLdapContext();
 
   public static void fuzzerTestOneInput(FuzzedDataProvider fuzzedDataProvider) throws Exception {
     // Externally provided LDAP query input needs to be escaped properly
diff --git a/sanitizers/src/test/java/com/example/ReflectiveCall.java b/sanitizers/src/test/java/com/example/ReflectiveCall.java
index e6b62b4..d7b3e46 100644
--- a/sanitizers/src/test/java/com/example/ReflectiveCall.java
+++ b/sanitizers/src/test/java/com/example/ReflectiveCall.java
@@ -22,8 +22,8 @@
     if (input.startsWith("@")) {
       String className = input.substring(1);
       try {
-        Class.forName(className);
-      } catch (ClassNotFoundException ignored) {
+        Class.forName(className).newInstance();
+      } catch (ClassNotFoundException | InstantiationException | IllegalAccessException ignored) {
       }
     }
   }
diff --git a/sanitizers/src/test/java/com/example/ScriptEngineInjection.java b/sanitizers/src/test/java/com/example/ScriptEngineInjection.java
new file mode 100644
index 0000000..631b7ab
--- /dev/null
+++ b/sanitizers/src/test/java/com/example/ScriptEngineInjection.java
@@ -0,0 +1,171 @@
+// Copyright 2023 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.example;
+
+import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import java.io.Reader;
+import java.io.StringReader;
+import java.io.Writer;
+import java.util.List;
+import javax.script.Bindings;
+import javax.script.ScriptContext;
+import javax.script.ScriptEngine;
+import javax.script.ScriptEngineFactory;
+
+public class ScriptEngineInjection {
+  private final static ScriptEngine engine = new DummyScriptEngine();
+  private final static ScriptContext context = new DummyScriptContext();
+
+  private static void insecureScriptEval(String input) throws Exception {
+    engine.eval(new StringReader(input), context);
+  }
+
+  public static void fuzzerTestOneInput(FuzzedDataProvider data) throws Exception {
+    try {
+      insecureScriptEval(data.consumeRemainingAsAsciiString());
+    } catch (Exception ignored) {
+    }
+  }
+
+  private static class DummyScriptEngine implements ScriptEngine {
+    @Override
+    public Bindings createBindings() {
+      return null;
+    }
+
+    @Override
+    public Object eval(String script) {
+      return null;
+    }
+
+    @Override
+    public Object eval(Reader reader) {
+      return null;
+    }
+
+    @Override
+    public Object eval(String script, ScriptContext context) {
+      return null;
+    }
+
+    @Override
+    public Object eval(Reader reader, ScriptContext context) {
+      return null;
+    }
+
+    @Override
+    public Object eval(String script, Bindings n) {
+      return null;
+    }
+
+    @Override
+    public Object eval(Reader reader, Bindings n) {
+      return null;
+    }
+
+    @Override
+    public Object get(String key) {
+      return null;
+    }
+
+    @Override
+    public Bindings getBindings(int scope) {
+      return null;
+    }
+
+    @Override
+    public ScriptContext getContext() {
+      return null;
+    }
+
+    @Override
+    public ScriptEngineFactory getFactory() {
+      return null;
+    }
+
+    @Override
+    public void put(String key, Object value) {}
+
+    @Override
+    public void setBindings(Bindings bindings, int scope) {}
+
+    @Override
+    public void setContext(ScriptContext context) {}
+
+    public DummyScriptEngine() {}
+  }
+
+  private static class DummyScriptContext implements ScriptContext {
+    @Override
+    public void setBindings(Bindings bindings, int scope) {}
+
+    @Override
+    public Bindings getBindings(int scope) {
+      return null;
+    }
+
+    @Override
+    public void setAttribute(String name, Object value, int scope) {}
+
+    @Override
+    public Object getAttribute(String name, int scope) {
+      return null;
+    }
+
+    @Override
+    public Object removeAttribute(String name, int scope) {
+      return null;
+    }
+
+    @Override
+    public Object getAttribute(String name) {
+      return null;
+    }
+
+    @Override
+    public int getAttributesScope(String name) {
+      return 0;
+    }
+
+    @Override
+    public Writer getWriter() {
+      return null;
+    }
+
+    @Override
+    public Writer getErrorWriter() {
+      return null;
+    }
+
+    @Override
+    public void setWriter(Writer writer) {}
+
+    @Override
+    public void setErrorWriter(Writer writer) {}
+
+    @Override
+    public Reader getReader() {
+      return null;
+    }
+
+    @Override
+    public void setReader(Reader reader) {}
+
+    @Override
+    public List<Integer> getScopes() {
+      return null;
+    }
+  }
+}
diff --git a/sanitizers/src/test/java/com/example/SsrfHttpClient.java b/sanitizers/src/test/java/com/example/SsrfHttpClient.java
new file mode 100644
index 0000000..6da561a
--- /dev/null
+++ b/sanitizers/src/test/java/com/example/SsrfHttpClient.java
@@ -0,0 +1,39 @@
+// Copyright 2023 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.example;
+
+import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import java.io.IOException;
+import java.net.*;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+
+public class SsrfHttpClient {
+  private static final HttpClient CLIENT =
+      HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build();
+
+  public static void fuzzerTestOneInput(FuzzedDataProvider data)
+      throws IOException, InterruptedException {
+    String hostname = data.consumeString(15);
+    URI uri;
+    try {
+      uri = URI.create("https://" + hostname);
+      HttpRequest request = HttpRequest.newBuilder().uri(uri).GET().build();
+      CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
+    } catch (IllegalArgumentException ignored) {
+    }
+  }
+}
diff --git a/sanitizers/src/test/java/com/example/SsrfSocketConnect.java b/sanitizers/src/test/java/com/example/SsrfSocketConnect.java
new file mode 100644
index 0000000..f1d7a59
--- /dev/null
+++ b/sanitizers/src/test/java/com/example/SsrfSocketConnect.java
@@ -0,0 +1,27 @@
+// Copyright 2023 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.example;
+
+import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import java.net.Socket;
+
+public class SsrfSocketConnect {
+  public static void fuzzerTestOneInput(FuzzedDataProvider data) throws Exception {
+    String hostname = data.consumeString(15);
+    try (Socket s = new Socket(hostname, 80)) {
+      s.getInetAddress();
+    }
+  }
+}
diff --git a/sanitizers/src/test/java/com/example/SsrfSocketConnectToHost.java b/sanitizers/src/test/java/com/example/SsrfSocketConnectToHost.java
new file mode 100644
index 0000000..3e60e50
--- /dev/null
+++ b/sanitizers/src/test/java/com/example/SsrfSocketConnectToHost.java
@@ -0,0 +1,46 @@
+// Copyright 2023 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.example;
+
+import com.code_intelligence.jazzer.api.BugDetectors;
+import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import com.code_intelligence.jazzer.api.Jazzer;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+
+public class SsrfSocketConnectToHost {
+  // We don't actually care about establishing a connection and thus choose the lowest possible
+  // timeout.
+  private static final int CONNECTION_TIMEOUT_MS = 1;
+
+  public static void fuzzerTestOneInput(FuzzedDataProvider data) throws Exception {
+    String host = data.consumeAsciiString(15);
+    int port = data.consumeInt(1, 65535);
+
+    try (AutoCloseable ignored = BugDetectors.allowNetworkConnections()) {
+      // Verify that policies nest properly.
+      try (AutoCloseable ignored1 = BugDetectors.allowNetworkConnections(
+               (String h, Integer p) -> h.equals("localhost"))) {
+        try (AutoCloseable ignored2 = BugDetectors.allowNetworkConnections()) {
+        }
+        try (Socket s = new Socket()) {
+          s.connect(new InetSocketAddress(host, port), CONNECTION_TIMEOUT_MS);
+        } catch (IOException ignored3) {
+        }
+      }
+    }
+  }
+}
diff --git a/sanitizers/src/test/java/com/example/SsrfUrlConnection.java b/sanitizers/src/test/java/com/example/SsrfUrlConnection.java
new file mode 100644
index 0000000..8ea940a
--- /dev/null
+++ b/sanitizers/src/test/java/com/example/SsrfUrlConnection.java
@@ -0,0 +1,33 @@
+// Copyright 2023 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.example;
+
+import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.URL;
+
+public class SsrfUrlConnection {
+  public static void fuzzerTestOneInput(FuzzedDataProvider data) throws Exception {
+    String hostname = data.consumeString(15);
+    try {
+      URL url = new URL("https://" + hostname);
+      HttpURLConnection con = (HttpURLConnection) url.openConnection();
+      con.setRequestMethod("GET");
+      con.getInputStream();
+    } catch (IOException | IllegalArgumentException ignored) {
+    }
+  }
+}
diff --git a/sanitizers/src/test/java/com/example/StackOverflowRegexInjection.java b/sanitizers/src/test/java/com/example/StackOverflowRegexInjection.java
new file mode 100644
index 0000000..92dfcf3
--- /dev/null
+++ b/sanitizers/src/test/java/com/example/StackOverflowRegexInjection.java
@@ -0,0 +1,51 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.example;
+
+import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import java.util.regex.Pattern;
+
+/**
+ * Compiling a regex pattern can lead to stack overflows and thus is caught
+ * in the constructor of {@link java.util.regex.Pattern} and rethrown as a
+ * {@link java.util.regex.PatternSyntaxException}.
+ * The {@link com.code_intelligence.jazzer.sanitizers.RegexInjection} sanitizer
+ * uses this exception to detect injections and would incorrectly report a
+ * finding. Exceptions caused by stack overflows should not be handled in the
+ * hook as it's very unlikely that the fuzzer generates a pattern causing a
+ * stack overflow before it generates an invalid one.
+ */
+@SuppressWarnings({"ReplaceOnLiteralHasNoEffect", "ResultOfMethodCallIgnored"})
+public class StackOverflowRegexInjection {
+  public static void fuzzerTestOneInput(FuzzedDataProvider ignored) {
+    // load regex classes by using them beforehand,
+    // otherwise initialization would cause other issues.
+    Pattern.compile("\n").matcher("some string").replaceAll("\\\\n");
+
+    generatePatternSyntaxException();
+  }
+
+  @SuppressWarnings("InfiniteRecursion")
+  private static void generatePatternSyntaxException() {
+    // try-catch on every level to not unwind the stack
+    try {
+      // generate stack overflow
+      generatePatternSyntaxException();
+    } catch (StackOverflowError e) {
+      // invoke regex injection hook
+      "some sting".replaceAll("\n", "\\\\n");
+    }
+  }
+}
diff --git a/sanitizers/src/test/java/com/example/XPathInjection.java b/sanitizers/src/test/java/com/example/XPathInjection.java
new file mode 100644
index 0000000..e8fe22a
--- /dev/null
+++ b/sanitizers/src/test/java/com/example/XPathInjection.java
@@ -0,0 +1,53 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.example;
+
+import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import java.io.*;
+import javax.xml.parsers.*;
+import javax.xml.xpath.*;
+import org.w3c.dom.Document;
+import org.xml.sax.*;
+
+public class XPathInjection {
+  static Document doc = null;
+  static XPath xpath = null;
+
+  public static void fuzzerInitialize() throws Exception {
+    String xmlFile = "<user name=\"user\" pass=\"pass\"></user>";
+
+    DocumentBuilderFactory domFactory = DocumentBuilderFactory.newInstance();
+    domFactory.setNamespaceAware(true);
+    DocumentBuilder builder = domFactory.newDocumentBuilder();
+    doc = builder.parse(new InputSource(new StringReader(xmlFile)));
+
+    XPathFactory xpathFactory = XPathFactory.newInstance();
+    xpath = xpathFactory.newXPath();
+  }
+
+  public static void unsafeEval(String user, String pass) {
+    if (user != null && pass != null) {
+      String expression = "/user[@name='" + user + "' and @pass='" + pass + "']";
+      try {
+        xpath.evaluate(expression, doc, XPathConstants.BOOLEAN);
+      } catch (XPathExpressionException e) {
+      }
+    }
+  }
+
+  public static void fuzzerTestOneInput(FuzzedDataProvider data) {
+    unsafeEval(data.consumeString(20), data.consumeRemainingAsString());
+  }
+}
diff --git a/sanitizers/src/test/java/com/example/el/BUILD.bazel b/sanitizers/src/test/java/com/example/el/BUILD.bazel
new file mode 100644
index 0000000..bf12a48
--- /dev/null
+++ b/sanitizers/src/test/java/com/example/el/BUILD.bazel
@@ -0,0 +1,15 @@
+java_library(
+    name = "ExpressionLanguageExample",
+    srcs = [
+        "InsecureEmailValidator.java",
+        "UserData.java",
+    ],
+    visibility = ["//sanitizers/src/test/java/com/example:__pkg__"],
+    deps = [
+        "@maven//:javax_el_javax_el_api",
+        "@maven//:javax_validation_validation_api",
+        "@maven//:javax_xml_bind_jaxb_api",
+        "@maven//:org_glassfish_javax_el",
+        "@maven//:org_hibernate_hibernate_validator",
+    ],
+)
diff --git a/sanitizers/src/test/java/com/example/InsecureEmailValidator.java b/sanitizers/src/test/java/com/example/el/InsecureEmailValidator.java
similarity index 97%
rename from sanitizers/src/test/java/com/example/InsecureEmailValidator.java
rename to sanitizers/src/test/java/com/example/el/InsecureEmailValidator.java
index d61e888..e10b082 100644
--- a/sanitizers/src/test/java/com/example/InsecureEmailValidator.java
+++ b/sanitizers/src/test/java/com/example/el/InsecureEmailValidator.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.example;
+package com.example.el;
 
 import static java.lang.String.format;
 
diff --git a/sanitizers/src/test/java/com/example/el/UserData.java b/sanitizers/src/test/java/com/example/el/UserData.java
new file mode 100644
index 0000000..305e78e
--- /dev/null
+++ b/sanitizers/src/test/java/com/example/el/UserData.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.el;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import javax.validation.Constraint;
+import javax.validation.Payload;
+
+public class UserData {
+  public UserData(String email) {
+    this.email = email;
+  }
+
+  @ValidEmailConstraint private String email;
+}
+
+@Constraint(validatedBy = InsecureEmailValidator.class)
+@Target({ElementType.METHOD, ElementType.FIELD})
+@Retention(RetentionPolicy.RUNTIME)
+@interface ValidEmailConstraint {
+  String message() default "Invalid email address";
+  Class<?>[] groups() default {};
+  Class<? extends Payload>[] payload() default {};
+}
diff --git a/sanitizers/src/test/java/com/example/ldap/MockInitialContextFactory.java b/sanitizers/src/test/java/com/example/ldap/MockInitialContextFactory.java
deleted file mode 100644
index b674f5c..0000000
--- a/sanitizers/src/test/java/com/example/ldap/MockInitialContextFactory.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright 2021 Code Intelligence GmbH
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.example.ldap;
-
-import java.util.Hashtable;
-import javax.naming.Context;
-import javax.naming.NamingException;
-import javax.naming.spi.InitialContextFactory;
-
-public class MockInitialContextFactory implements InitialContextFactory {
-  public Context getInitialContext(Hashtable environment) {
-    return new MockLdapContext();
-  }
-}
diff --git a/agent/src/jmh/java/com/code_intelligence/jazzer/BUILD.bazel b/src/jmh/java/com/code_intelligence/jazzer/BUILD.bazel
similarity index 76%
rename from agent/src/jmh/java/com/code_intelligence/jazzer/BUILD.bazel
rename to src/jmh/java/com/code_intelligence/jazzer/BUILD.bazel
index cf6acfb..0a88ca0 100644
--- a/agent/src/jmh/java/com/code_intelligence/jazzer/BUILD.bazel
+++ b/src/jmh/java/com/code_intelligence/jazzer/BUILD.bazel
@@ -1,6 +1,6 @@
 java_plugin(
     name = "JmhGeneratorAnnotationProcessor",
     processor_class = "org.openjdk.jmh.generators.BenchmarkProcessor",
-    visibility = ["//agent/src/jmh/java:__subpackages__"],
+    visibility = ["//src/jmh/java:__subpackages__"],
     deps = ["@maven//:org_openjdk_jmh_jmh_generator_annprocess"],
 )
diff --git a/agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel
similarity index 70%
rename from agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel
rename to src/jmh/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel
index fe68f90..1dbcdbf 100644
--- a/agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel
+++ b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel
@@ -1,6 +1,7 @@
-load("@fmeum_rules_jni//jni:defs.bzl", "cc_jni_library", "java_jni_library")
+load("@fmeum_rules_jni//jni:defs.bzl", "java_jni_library")
 load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library")
-load("//agent/src/jmh/java/com/code_intelligence/jazzer:jmh.bzl", "JMH_TEST_ARGS")
+load("//bazel:kotlin.bzl", "ktlint")
+load("//src/jmh/java/com/code_intelligence/jazzer:jmh.bzl", "JMH_TEST_ARGS")
 
 java_binary(
     name = "CoverageInstrumentationBenchmark",
@@ -27,14 +28,14 @@
 java_library(
     name = "coverage_instrumentation_benchmark",
     srcs = ["CoverageInstrumentationBenchmark.java"],
-    plugins = ["//agent/src/jmh/java/com/code_intelligence/jazzer:JmhGeneratorAnnotationProcessor"],
+    plugins = ["//src/jmh/java/com/code_intelligence/jazzer:JmhGeneratorAnnotationProcessor"],
     runtime_deps = [
         "@maven//:com_mikesamuel_json_sanitizer",
     ],
     deps = [
         ":kotlin_strategies",
         ":strategies",
-        "//agent/src/main/java/com/code_intelligence/jazzer/instrumentor",
+        "//src/main/java/com/code_intelligence/jazzer/instrumentor",
         "@maven//:org_openjdk_jmh_jmh_core",
     ],
 )
@@ -50,7 +51,7 @@
         "UnsafeSimpleIncrementCoverageMap.java",
     ],
     deps = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/instrumentor",
+        "//src/main/java/com/code_intelligence/jazzer/instrumentor",
         "@jazzer_jacoco//:jacoco_internal",
         "@org_ow2_asm_asm//jar",
     ],
@@ -60,7 +61,7 @@
     name = "kotlin_strategies",
     srcs = ["DirectByteBufferStrategy.kt"],
     deps = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/instrumentor",
+        "//src/main/java/com/code_intelligence/jazzer/instrumentor",
         "@jazzer_jacoco//:jacoco_internal",
         "@org_ow2_asm_asm//jar",
     ],
@@ -91,12 +92,14 @@
         "EdgeCoverageInstrumentation.java",
         "EdgeCoverageTarget.java",
     ],
-    native_libs = ["//driver/src/main/native/com/code_intelligence/jazzer/driver:jazzer_driver"],
-    plugins = ["//agent/src/jmh/java/com/code_intelligence/jazzer:JmhGeneratorAnnotationProcessor"],
+    native_libs = ["//src/main/native/com/code_intelligence/jazzer/driver:jazzer_driver"],
+    plugins = ["//src/jmh/java/com/code_intelligence/jazzer:JmhGeneratorAnnotationProcessor"],
     deps = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/instrumentor",
-        "//agent/src/main/java/com/code_intelligence/jazzer/runtime:coverage_map",
-        "//agent/src/test/java/com/code_intelligence/jazzer/instrumentor:patch_test_utils",
+        "//src/main/java/com/code_intelligence/jazzer/instrumentor",
+        "//src/main/java/com/code_intelligence/jazzer/runtime:coverage_map",
+        "//src/test/java/com/code_intelligence/jazzer/instrumentor:patch_test_utils",
         "@maven//:org_openjdk_jmh_jmh_core",
     ],
 )
+
+ktlint()
diff --git a/agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/CoverageInstrumentationBenchmark.java b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/CoverageInstrumentationBenchmark.java
similarity index 98%
rename from agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/CoverageInstrumentationBenchmark.java
rename to src/jmh/java/com/code_intelligence/jazzer/instrumentor/CoverageInstrumentationBenchmark.java
index f388c4c..4401da7 100644
--- a/agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/CoverageInstrumentationBenchmark.java
+++ b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/CoverageInstrumentationBenchmark.java
@@ -159,7 +159,7 @@
         throw new ClassNotFoundException(String.format("Failed to find class file for %s", name));
       }
       byte[] bytecode = readAllBytes(stream);
-      byte[] instrumentedBytecode = instrumentor.instrument(bytecode);
+      byte[] instrumentedBytecode = instrumentor.instrument(name.replace('.', '/'), bytecode);
       return defineClass(name, instrumentedBytecode, 0, instrumentedBytecode.length);
     } catch (IOException e) {
       throw new ClassNotFoundException(String.format("Failed to read class file for %s", name), e);
diff --git a/agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/DirectByteBuffer2CoverageMap.java b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/DirectByteBuffer2CoverageMap.java
similarity index 100%
rename from agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/DirectByteBuffer2CoverageMap.java
rename to src/jmh/java/com/code_intelligence/jazzer/instrumentor/DirectByteBuffer2CoverageMap.java
diff --git a/agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/DirectByteBufferCoverageMap.java b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/DirectByteBufferCoverageMap.java
similarity index 100%
rename from agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/DirectByteBufferCoverageMap.java
rename to src/jmh/java/com/code_intelligence/jazzer/instrumentor/DirectByteBufferCoverageMap.java
diff --git a/agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/DirectByteBufferStrategy.kt b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/DirectByteBufferStrategy.kt
similarity index 98%
rename from agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/DirectByteBufferStrategy.kt
rename to src/jmh/java/com/code_intelligence/jazzer/instrumentor/DirectByteBufferStrategy.kt
index 4909018..14f5041 100644
--- a/agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/DirectByteBufferStrategy.kt
+++ b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/DirectByteBufferStrategy.kt
@@ -23,7 +23,7 @@
         mv: MethodVisitor,
         edgeId: Int,
         variable: Int,
-        coverageMapInternalClassName: String
+        coverageMapInternalClassName: String,
     ) {
         mv.apply {
             visitVarInsn(Opcodes.ALOAD, variable)
diff --git a/agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/EdgeCoverageInstrumentation.java b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/EdgeCoverageInstrumentation.java
similarity index 96%
rename from agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/EdgeCoverageInstrumentation.java
rename to src/jmh/java/com/code_intelligence/jazzer/instrumentor/EdgeCoverageInstrumentation.java
index e2eeadd..97f6b43 100644
--- a/agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/EdgeCoverageInstrumentation.java
+++ b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/EdgeCoverageInstrumentation.java
@@ -56,7 +56,7 @@
 
   private byte[] applyInstrumentation(byte[] bytecode) {
     return new EdgeCoverageInstrumentor(new StaticMethodStrategy(), CoverageMap.class, 0)
-        .instrument(bytecode);
+        .instrument(EdgeCoverageTarget.class.getName().replace('.', '/'), bytecode);
   }
 
   @Benchmark
diff --git a/agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/EdgeCoverageTarget.java b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/EdgeCoverageTarget.java
similarity index 100%
rename from agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/EdgeCoverageTarget.java
rename to src/jmh/java/com/code_intelligence/jazzer/instrumentor/EdgeCoverageTarget.java
diff --git a/agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/Unsafe2CoverageMap.java b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/Unsafe2CoverageMap.java
similarity index 100%
rename from agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/Unsafe2CoverageMap.java
rename to src/jmh/java/com/code_intelligence/jazzer/instrumentor/Unsafe2CoverageMap.java
diff --git a/agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/UnsafeBranchfreeCoverageMap.java b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/UnsafeBranchfreeCoverageMap.java
similarity index 100%
rename from agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/UnsafeBranchfreeCoverageMap.java
rename to src/jmh/java/com/code_intelligence/jazzer/instrumentor/UnsafeBranchfreeCoverageMap.java
diff --git a/agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/UnsafeCoverageMap.java b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/UnsafeCoverageMap.java
similarity index 100%
rename from agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/UnsafeCoverageMap.java
rename to src/jmh/java/com/code_intelligence/jazzer/instrumentor/UnsafeCoverageMap.java
diff --git a/agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/UnsafeSimpleIncrementCoverageMap.java b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/UnsafeSimpleIncrementCoverageMap.java
similarity index 100%
rename from agent/src/jmh/java/com/code_intelligence/jazzer/instrumentor/UnsafeSimpleIncrementCoverageMap.java
rename to src/jmh/java/com/code_intelligence/jazzer/instrumentor/UnsafeSimpleIncrementCoverageMap.java
diff --git a/agent/src/jmh/java/com/code_intelligence/jazzer/jmh.bzl b/src/jmh/java/com/code_intelligence/jazzer/jmh.bzl
similarity index 100%
rename from agent/src/jmh/java/com/code_intelligence/jazzer/jmh.bzl
rename to src/jmh/java/com/code_intelligence/jazzer/jmh.bzl
diff --git a/agent/src/jmh/java/com/code_intelligence/jazzer/runtime/BUILD.bazel b/src/jmh/java/com/code_intelligence/jazzer/runtime/BUILD.bazel
similarity index 72%
rename from agent/src/jmh/java/com/code_intelligence/jazzer/runtime/BUILD.bazel
rename to src/jmh/java/com/code_intelligence/jazzer/runtime/BUILD.bazel
index 96fd8e1..7595d66 100644
--- a/agent/src/jmh/java/com/code_intelligence/jazzer/runtime/BUILD.bazel
+++ b/src/jmh/java/com/code_intelligence/jazzer/runtime/BUILD.bazel
@@ -1,5 +1,5 @@
 load("@fmeum_rules_jni//jni:defs.bzl", "java_jni_library")
-load("//agent/src/jmh/java/com/code_intelligence/jazzer:jmh.bzl", "JMH_TEST_ARGS")
+load("//src/jmh/java/com/code_intelligence/jazzer:jmh.bzl", "JMH_TEST_ARGS")
 
 java_binary(
     name = "FuzzerCallbacksBenchmark",
@@ -13,6 +13,11 @@
     name = "FuzzerCallbacksBenchmarkTest",
     args = JMH_TEST_ARGS,
     main_class = "org.openjdk.jmh.Main",
+    # CriticalJNINatives have been removed in Java 18.
+    tags = [
+        "exclusive-if-local",
+        "no-linux-jdk19",
+    ],
     # Directly invoke JMH's main without using a testrunner.
     use_testrunner = False,
     runtime_deps = [
@@ -23,7 +28,7 @@
 java_library(
     name = "fuzzer_callbacks_benchmark",
     srcs = ["FuzzerCallbacksBenchmark.java"],
-    plugins = ["//agent/src/jmh/java/com/code_intelligence/jazzer:JmhGeneratorAnnotationProcessor"],
+    plugins = ["//src/jmh/java/com/code_intelligence/jazzer:JmhGeneratorAnnotationProcessor"],
     deps = [
         ":fuzzer_callbacks",
         "@maven//:org_openjdk_jmh_jmh_core",
@@ -45,6 +50,6 @@
         #        "--add-modules",
         #        "jdk.incubator.foreign",
     ],
-    native_libs = ["//agent/src/jmh/native/com/code_intelligence/jazzer/runtime:fuzzer_callbacks"],
-    visibility = ["//agent/src/jmh/native/com/code_intelligence/jazzer/runtime:__pkg__"],
+    native_libs = ["//src/jmh/native/com/code_intelligence/jazzer/runtime:fuzzer_callbacks"],
+    visibility = ["//src/jmh/native/com/code_intelligence/jazzer/runtime:__pkg__"],
 )
diff --git a/agent/src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacks.java b/src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacks.java
similarity index 100%
rename from agent/src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacks.java
rename to src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacks.java
diff --git a/agent/src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacksBenchmark.java b/src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacksBenchmark.java
similarity index 94%
rename from agent/src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacksBenchmark.java
rename to src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacksBenchmark.java
index b55a993..81a8f56 100644
--- a/agent/src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacksBenchmark.java
+++ b/src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacksBenchmark.java
@@ -55,7 +55,7 @@
   }
 
   @Benchmark
-  @Fork(jvmArgsAppend = {"-XX:+CriticalJNINatives"})
+  @Fork(jvmArgsAppend = {"-XX:+IgnoreUnrecognizedVMOptions", "-XX:+CriticalJNINatives"})
   public void traceCmpIntOptimizedCritical(TraceCmpIntState state) {
     FuzzerCallbacksOptimizedCritical.traceCmpInt(state.arg1, state.arg2, state.pc);
   }
@@ -107,7 +107,7 @@
   }
 
   @Benchmark
-  @Fork(jvmArgsAppend = {"-XX:+CriticalJNINatives"})
+  @Fork(jvmArgsAppend = {"-XX:+IgnoreUnrecognizedVMOptions", "-XX:+CriticalJNINatives"})
   public void traceSwitchOptimizedCritical(TraceSwitchState state) {
     FuzzerCallbacksOptimizedCritical.traceSwitch(state.val, state.cases, state.pc);
   }
@@ -154,7 +154,7 @@
   }
 
   @Benchmark
-  @Fork(jvmArgsAppend = {"-XX:+CriticalJNINatives"})
+  @Fork(jvmArgsAppend = {"-XX:+IgnoreUnrecognizedVMOptions", "-XX:+CriticalJNINatives"})
   public void traceMemcmpOptimizedCritical(TraceMemcmpState state) {
     FuzzerCallbacksOptimizedCritical.traceMemcmp(state.array1, state.array2, 1, state.pc);
   }
@@ -205,7 +205,7 @@
   }
 
   @Benchmark
-  @Fork(jvmArgsAppend = {"-XX:+CriticalJNINatives"})
+  @Fork(jvmArgsAppend = {"-XX:+IgnoreUnrecognizedVMOptions", "-XX:+CriticalJNINatives"})
   public void traceStrstrOptimizedJavaCritical(TraceStrstrState state)
       throws UnsupportedEncodingException {
     FuzzerCallbacksOptimizedCritical.traceStrstrJava(state.haystack, state.needle, state.pc);
diff --git a/agent/src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacksOptimizedCritical.java b/src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacksOptimizedCritical.java
similarity index 100%
rename from agent/src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacksOptimizedCritical.java
rename to src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacksOptimizedCritical.java
diff --git a/agent/src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacksOptimizedNonCritical.java b/src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacksOptimizedNonCritical.java
similarity index 100%
rename from agent/src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacksOptimizedNonCritical.java
rename to src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacksOptimizedNonCritical.java
diff --git a/agent/src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacksPanama.java b/src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacksPanama.java
similarity index 100%
rename from agent/src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacksPanama.java
rename to src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacksPanama.java
diff --git a/agent/src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacksWithPc.java b/src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacksWithPc.java
similarity index 100%
rename from agent/src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacksWithPc.java
rename to src/jmh/java/com/code_intelligence/jazzer/runtime/FuzzerCallbacksWithPc.java
diff --git a/src/jmh/native/com/code_intelligence/jazzer/runtime/BUILD.bazel b/src/jmh/native/com/code_intelligence/jazzer/runtime/BUILD.bazel
new file mode 100644
index 0000000..06323a4
--- /dev/null
+++ b/src/jmh/native/com/code_intelligence/jazzer/runtime/BUILD.bazel
@@ -0,0 +1,12 @@
+load("@fmeum_rules_jni//jni:defs.bzl", "cc_jni_library")
+
+cc_jni_library(
+    name = "fuzzer_callbacks",
+    srcs = ["fuzzer_callbacks.cpp"],
+    visibility = ["//src/jmh/java/com/code_intelligence/jazzer/runtime:__pkg__"],
+    deps = [
+        "//src/jmh/java/com/code_intelligence/jazzer/runtime:fuzzer_callbacks.hdrs",
+        "//src/main/native/com/code_intelligence/jazzer/driver:sanitizer_hooks_with_pc",
+        "@jazzer_libfuzzer//:libfuzzer_no_main",
+    ],
+)
diff --git a/agent/src/jmh/native/com/code_intelligence/jazzer/runtime/fuzzer_callbacks.cpp b/src/jmh/native/com/code_intelligence/jazzer/runtime/fuzzer_callbacks.cpp
similarity index 98%
rename from agent/src/jmh/native/com/code_intelligence/jazzer/runtime/fuzzer_callbacks.cpp
rename to src/jmh/native/com/code_intelligence/jazzer/runtime/fuzzer_callbacks.cpp
index 2562db1..ec35761 100644
--- a/agent/src/jmh/native/com/code_intelligence/jazzer/runtime/fuzzer_callbacks.cpp
+++ b/src/jmh/native/com/code_intelligence/jazzer/runtime/fuzzer_callbacks.cpp
@@ -21,7 +21,7 @@
 #include "com_code_intelligence_jazzer_runtime_FuzzerCallbacksOptimizedCritical.h"
 #include "com_code_intelligence_jazzer_runtime_FuzzerCallbacksOptimizedNonCritical.h"
 #include "com_code_intelligence_jazzer_runtime_FuzzerCallbacksWithPc.h"
-#include "driver/src/main/native/com/code_intelligence/jazzer/driver/sanitizer_hooks_with_pc.h"
+#include "src/main/native/com/code_intelligence/jazzer/driver/sanitizer_hooks_with_pc.h"
 
 extern "C" {
 void __sanitizer_weak_hook_compare_bytes(void *caller_pc, const void *s1,
diff --git a/src/main/java/com/code_intelligence/jazzer/BUILD.bazel b/src/main/java/com/code_intelligence/jazzer/BUILD.bazel
new file mode 100644
index 0000000..aed6769
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/BUILD.bazel
@@ -0,0 +1,137 @@
+load("@bazel_skylib//rules:write_file.bzl", "write_file")
+load("@com_github_johnynek_bazel_jar_jar//:jar_jar.bzl", "jar_jar")
+load("@rules_jvm_external//:defs.bzl", "javadoc")
+load("//:maven.bzl", "JAZZER_VERSION")
+load("//bazel:jar.bzl", "strip_jar")
+load("//sanitizers:sanitizers.bzl", "SANITIZER_CLASSES")
+
+java_binary(
+    name = "jazzer_standalone",
+    main_class = "com.code_intelligence.jazzer.Jazzer",
+    visibility = [
+        "//:__pkg__",
+        "//launcher:__pkg__",
+    ],
+    runtime_deps = [
+        ":jazzer_import",
+        "//deploy:jazzer-api",
+    ],
+)
+
+strip_jar(
+    name = "jazzer",
+    out = "jazzer.jar",
+    jar = ":jazzer_shaded",
+    paths_to_keep = [
+        "com/code_intelligence/jazzer/**",
+        "jaz/**",
+        "META-INF/MANIFEST.MF",
+        "win32-x86/**",
+        "win32-x86-64/**",
+    ],
+    visibility = ["//visibility:public"],
+)
+
+java_library(
+    name = "constants",
+    srcs = [":constants_java"],
+    visibility = ["//visibility:public"],
+)
+
+java_import(
+    name = "jazzer_import",
+    jars = [":jazzer"],
+    visibility = ["//:__subpackages__"],
+    deps = ["//deploy:jazzer-api"],
+)
+
+jar_jar(
+    name = "jazzer_shaded",
+    input_jar = "jazzer_unshaded_deploy.jar",
+    rules = "jazzer_shade_rules.jarjar",
+)
+
+java_binary(
+    name = "jazzer_unshaded",
+    # Note: We can't add
+    # //src/main/java/com/code_intelligence/jazzer/runtime:java_bootstrap_unshaded itself as
+    # that would also strip out external dependencies common between Jazzer and its bootstrap jar,
+    # such as e.g. RulesJni, which should be shaded into distinct classes.
+    deploy_env = [
+        "//src/main/java/com/code_intelligence/jazzer/api:api_deploy_env",
+        "//src/main/java/com/code_intelligence/jazzer/runtime:jazzer_bootstrap_env",
+    ],
+    main_class = "com.code_intelligence.jazzer.Jazzer",
+    runtime_deps = [":jazzer_lib"],
+)
+
+# Docs are only generated for the com.code_intelligence.jazzer package. Everything else is not
+# considered a public interface.
+javadoc(
+    name = "jazzer-docs",
+    javadocopts = select({
+        "//deploy:emit_linked_javadoc": [
+            "-link",
+            "https://docs.oracle.com/en/java/javase/17/docs/api/",
+            "-link",
+            "https://codeintelligencetesting.github.io/jazzer-docs/jazzer-api/",
+        ],
+        "//conditions:default": [],
+    }),
+    visibility = ["//deploy:__pkg__"],
+    deps = [":jazzer_lib"],
+)
+
+strip_jar(
+    name = "jazzer-sources",
+    jar = ":jazzer_transitive_sources_deploy-src.jar",
+    paths_to_keep = [
+        "com/code_intelligence/jazzer/**",
+        "jaz/**",
+        "META-INF/MANIFEST.MF",
+    ],
+    visibility = ["//deploy:__pkg__"],
+)
+
+# The _deploy-src.jar for this target includes the sources for the jazzer_bootstrap library.
+java_binary(
+    name = "jazzer_transitive_sources",
+    main_class = "com.code_intelligence.jazzer.Jazzer",
+    runtime_deps = [
+        ":jazzer_lib",
+        "//src/main/java/com/code_intelligence/jazzer/runtime:jazzer_bootstrap_lib",
+    ],
+)
+
+java_library(
+    name = "jazzer_lib",
+    srcs = ["Jazzer.java"],
+    visibility = ["//deploy:__pkg__"],
+    runtime_deps = select({
+        "@platforms//os:windows": [],
+        "//conditions:default": ["//src/main/native/com/code_intelligence/jazzer:jazzer_preload"],
+    }) + [
+        # Only used by JUnit, but including it here means we don't need to shade ASM in
+        # jazzer-junit.
+        "//src/main/java/com/code_intelligence/jazzer/utils:unsafe_utils",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/android:android_runtime",
+        "//src/main/java/com/code_intelligence/jazzer/driver",
+        "//src/main/java/com/code_intelligence/jazzer/runtime:constants",
+        "//src/main/java/com/code_intelligence/jazzer/utils:log",
+        "//src/main/java/com/code_intelligence/jazzer/utils:zip_utils",
+        "@fmeum_rules_jni//jni/tools/native_loader",
+    ],
+)
+
+write_file(
+    name = "constants_java",
+    out = "Constants.java",
+    content = [
+        "package com.code_intelligence.jazzer;",
+        "public final class Constants {",
+        "  public static final String JAZZER_VERSION = \"%s\";" % JAZZER_VERSION,
+        "}",
+    ],
+)
diff --git a/src/main/java/com/code_intelligence/jazzer/Jazzer.java b/src/main/java/com/code_intelligence/jazzer/Jazzer.java
new file mode 100644
index 0000000..3eb316d
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/Jazzer.java
@@ -0,0 +1,515 @@
+/*
+ * Copyright 2022 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer;
+
+import static com.code_intelligence.jazzer.runtime.Constants.IS_ANDROID;
+import static java.lang.System.exit;
+import static java.util.Arrays.asList;
+import static java.util.Collections.singletonList;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+
+import com.code_intelligence.jazzer.android.AndroidRuntime;
+import com.code_intelligence.jazzer.driver.Driver;
+import com.code_intelligence.jazzer.utils.Log;
+import com.code_intelligence.jazzer.utils.ZipUtils;
+import com.github.fmeum.rules_jni.RulesJni;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.lang.management.ManagementFactory;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.FileAttribute;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.util.AbstractMap.SimpleEntry;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Stream;
+
+/**
+ * The libFuzzer-compatible CLI entrypoint for Jazzer.
+ *
+ * <p>Arguments to Jazzer are passed as command-line arguments or {@code jazzer.*} system
+ * properties. For example, setting the property {@code jazzer.target_class} to
+ * {@code com.example.FuzzTest} is equivalent to passing the argument
+ * {@code --target_class=com.example.FuzzTest}.
+ *
+ * <p>Arguments to libFuzzer are passed as command-line arguments.
+ */
+public class Jazzer {
+  public static void main(String[] args) throws IOException, InterruptedException {
+    start(Arrays.stream(args).collect(toList()));
+  }
+
+  // Accessed by jazzer_main.cpp.
+  @SuppressWarnings("unused")
+  private static void main(byte[][] nativeArgs) throws IOException, InterruptedException {
+    start(Arrays.stream(nativeArgs)
+              .map(bytes -> new String(bytes, StandardCharsets.UTF_8))
+              .collect(toList()));
+  }
+
+  private static void start(List<String> args) throws IOException, InterruptedException {
+    // Lock in the output PrintStreams so that Jazzer can still emit output even if the fuzz target
+    // itself is "silenced" by redirecting System.out and/or System.err.
+    Log.fixOutErr(System.out, System.err);
+
+    parseJazzerArgsToProperties(args);
+
+    // --asan and --ubsan imply --native by default, but --native can also be used by itself to fuzz
+    // native libraries without sanitizers (e.g. to quickly grow a corpus).
+    final boolean loadASan = Boolean.parseBoolean(System.getProperty("jazzer.asan", "false"));
+    final boolean loadUBSan = Boolean.parseBoolean(System.getProperty("jazzer.ubsan", "false"));
+    final boolean loadHWASan = Boolean.parseBoolean(System.getProperty("jazzer.hwasan", "false"));
+    final boolean fuzzNative = Boolean.parseBoolean(
+        System.getProperty("jazzer.native", Boolean.toString(loadASan || loadUBSan || loadHWASan)));
+    if ((loadASan || loadUBSan || loadHWASan) && !fuzzNative) {
+      Log.error("--asan, --hwasan and --ubsan cannot be used without --native");
+      exit(1);
+    }
+    // No native fuzzing has been requested, fuzz in the current process.
+    if (!fuzzNative) {
+      if (IS_ANDROID) {
+        final String initOptions = getAndroidRuntimeOptions();
+        AndroidRuntime.initialize(initOptions);
+      }
+      // We only create a wrapper script if libFuzzer runs in a mode that creates subprocesses.
+      // In LibFuzzer's fork mode, the subprocesses created continuously by the main libFuzzer
+      // process do not create further subprocesses. Creating a wrapper script for each subprocess
+      // is an unnecessary overhead.
+      final boolean spawnsSubprocesses = args.stream().anyMatch(arg
+          -> (arg.startsWith("-fork=") && !arg.equals("-fork=0"))
+              || (arg.startsWith("-jobs=") && !arg.equals("-jobs=0"))
+              || (arg.startsWith("-merge=") && !arg.equals("-merge=0")));
+      // argv0 is printed by libFuzzer during reproduction, so have it contain "jazzer".
+      String arg0 = spawnsSubprocesses ? prepareArgv0(new HashMap<>()) : "jazzer";
+      args = Stream.concat(Stream.of(arg0), args.stream()).collect(toList());
+      exit(Driver.start(args, spawnsSubprocesses));
+    }
+
+    if (!isLinux() && !isMacOs()) {
+      Log.error("--asan, --ubsan, and --native are only supported on Linux and macOS");
+      exit(1);
+    }
+
+    // Run ourselves as a subprocess with `jazzer_preload` and (optionally) native sanitizers
+    // preloaded. By inheriting IO, this wrapping should become invisible for the user.
+    Set<String> argsToFilter =
+        Stream.of("--asan", "--ubsan", "--hwasan", "--native").collect(toSet());
+    ProcessBuilder processBuilder = new ProcessBuilder();
+    List<Path> preloadLibs = new ArrayList<>();
+    // We have to load jazzer_preload before we load ASan since the ASan includes no-op definitions
+    // of the fuzzer callbacks as weak symbols, but the dynamic linker doesn't distinguish between
+    // strong and weak symbols.
+    preloadLibs.add(RulesJni.extractLibrary("jazzer_preload", Jazzer.class));
+    if (loadASan) {
+      processBuilder.environment().compute("ASAN_OPTIONS",
+          (name, currentValue)
+              -> appendWithPathListSeparator(name,
+                  // The JVM produces an extremely large number of false positive leaks, which makes
+                  // it impossible to use LeakSanitizer.
+                  // TODO: Investigate whether we can hook malloc/free only for JNI shared
+                  // libraries, not the JVM itself.
+                  "detect_leaks=0",
+                  // We load jazzer_preload first.
+                  "verify_asan_link_order=0"));
+      Log.warn("Jazzer is not compatible with LeakSanitizer. Leaks are not reported.");
+      preloadLibs.add(findLibrary(asanLibNames()));
+    }
+    if (loadHWASan) {
+      processBuilder.environment().compute("HWASAN_OPTIONS",
+          (name, currentValue)
+              -> appendWithPathListSeparator(name,
+                  // The JVM produces an extremely large number of false positive leaks, which makes
+                  // it impossible to use LeakSanitizer.
+                  // TODO: Investigate whether we can hook malloc/free only for JNI shared
+                  // libraries, not the JVM itself.
+                  "detect_leaks=0",
+                  // We load jazzer_preload first.
+                  "verify_asan_link_order=0"));
+      Log.warn("Jazzer is not compatible with LeakSanitizer. Leaks are not reported.");
+      preloadLibs.add(findLibrary(hwasanLibNames()));
+    }
+    if (loadUBSan) {
+      preloadLibs.add(findLibrary(ubsanLibNames()));
+    }
+    // The launcher script we generate is executed by /bin/sh on macOS, which is codesigned without
+    // the allow-dyld-environment-variables entitlement. The dynamic linker would thus remove all
+    // DYLD_* variables. Instead, we pass these variables directly to the java executable by
+    // emitting them into the wrapper. The java binary has both the allow-dyld-environment-variables
+    // and the disable-library-validation entitlement, which allows any codesigned library to be
+    // preloaded.
+    processBuilder.environment().remove(preloadVariable());
+    Map<String, String> additionalEnvironment = new HashMap<>();
+    additionalEnvironment.put(preloadVariable(),
+        appendWithPathListSeparator(
+            preloadVariable(), preloadLibs.stream().map(Path::toString).toArray(String[] ::new)));
+    List<String> subProcessArgs =
+        Stream
+            .concat(Stream.of(prepareArgv0(additionalEnvironment)),
+                // Prevent a "fork bomb" by stripping all args that trigger this code path.
+                args.stream().filter(arg -> !argsToFilter.contains(arg.split("=")[0])))
+            .collect(toList());
+    processBuilder.command(subProcessArgs);
+    processBuilder.inheritIO();
+
+    exit(processBuilder.start().waitFor());
+  }
+
+  private static void parseJazzerArgsToProperties(List<String> args) {
+    args.stream()
+        .filter(arg -> arg.startsWith("--"))
+        .map(arg -> arg.substring("--".length()))
+        // Filter out "--", which can be used to declare that all further arguments aren't libFuzzer
+        // arguments.
+        .filter(arg -> !arg.isEmpty())
+        .map(Jazzer::parseSingleArg)
+        .forEach(e -> System.setProperty("jazzer." + e.getKey(), e.getValue()));
+  }
+
+  private static SimpleEntry<String, String> parseSingleArg(String arg) {
+    String[] nameAndValue = arg.split("=", 2);
+    if (nameAndValue.length == 2) {
+      // Example: --keep_going=10 --> (keep_going, 10)
+      return new SimpleEntry<>(nameAndValue[0], nameAndValue[1]);
+    } else if (nameAndValue[0].startsWith("no")) {
+      // Example: --nohooks --> (hooks, "false")
+      return new SimpleEntry<>(nameAndValue[0].substring("no".length()), "false");
+    } else {
+      // Example: --dedup --> (dedup, "true")
+      return new SimpleEntry<>(nameAndValue[0], "true");
+    }
+  }
+
+  // Create a wrapper script that faithfully recreates the current JVM. By using this script as
+  // libFuzzer's argv[0], libFuzzer modes that rely on subprocesses can work with the Java driver.
+  // This trick is also used to allow native sanitizers to be preloaded.
+  private static String prepareArgv0(Map<String, String> additionalEnvironment) throws IOException {
+    if (!isPosixOrAndroid() && !additionalEnvironment.isEmpty()) {
+      throw new IllegalArgumentException(
+          "Setting environment variables in the wrapper is only supported on POSIX systems and Android");
+    }
+    char shellQuote = isPosixOrAndroid() ? '\'' : '"';
+    String launcherTemplate;
+    if (IS_ANDROID) {
+      launcherTemplate = "#!/system/bin/env sh\n%s LD_LIBRARY_PATH=%s \n%s $@\n";
+    } else if (isPosix()) {
+      launcherTemplate = "#!/usr/bin/env sh\n%s $@\n";
+    } else {
+      launcherTemplate = "@echo off\r\n%s %%*\r\n";
+    }
+
+    String launcherExtension = isPosix() ? ".sh" : ".bat";
+    FileAttribute<?>[] launcherScriptAttributes = isPosixOrAndroid()
+        ? new FileAttribute[] {PosixFilePermissions.asFileAttribute(
+            PosixFilePermissions.fromString("rwx------"))}
+        : new FileAttribute[] {};
+    String env = additionalEnvironment.entrySet()
+                     .stream()
+                     .map(e -> e.getKey() + "='" + e.getValue() + "'")
+                     .collect(joining(" "));
+    String command =
+        Stream
+            .concat(Stream.of(IS_ANDROID ? "exec" : javaBinary().toString()), javaBinaryArgs())
+            // Escape individual arguments for the shell.
+            .map(str -> shellQuote + str + shellQuote)
+            .collect(joining(" "));
+
+    String invocation = env.isEmpty() ? command : env + " " + command;
+
+    // argv0 is printed by libFuzzer during reproduction, so have the launcher basename contain
+    // "jazzer".
+    Path launcher;
+    String launcherContent;
+    if (IS_ANDROID) {
+      String exportCommand = AndroidRuntime.getClassPathsCommand();
+      String ldLibraryPath = AndroidRuntime.getLdLibraryPath();
+      launcherContent = String.format(launcherTemplate, exportCommand, ldLibraryPath, invocation);
+      launcher = Files.createTempFile(
+          Paths.get("/data/local/tmp/"), "jazzer-", launcherExtension, launcherScriptAttributes);
+    } else {
+      launcherContent = String.format(launcherTemplate, invocation);
+      launcher = Files.createTempFile("jazzer-", launcherExtension, launcherScriptAttributes);
+    }
+
+    launcher.toFile().deleteOnExit();
+    Files.write(launcher, launcherContent.getBytes(StandardCharsets.UTF_8));
+    return launcher.toAbsolutePath().toString();
+  }
+
+  private static Path javaBinary() {
+    String javaBinaryName;
+    if (isPosix()) {
+      javaBinaryName = "java";
+    } else {
+      javaBinaryName = "java.exe";
+    }
+
+    return Paths.get(System.getProperty("java.home"), "bin", javaBinaryName);
+  }
+
+  private static Stream<String> javaBinaryArgs() throws IOException {
+    if (IS_ANDROID) {
+      // Add Android specific args
+      Path agentPath =
+          RulesJni.extractLibrary("android_native_agent", "/com/code_intelligence/jazzer/android");
+
+      String jazzerAgentPath = System.getProperty("jazzer.agent_path");
+      String bootclassClassOverrides =
+          System.getProperty("jazzer.android_bootpath_classes_overrides");
+
+      String jazzerBootstrapJarPath =
+          "com/code_intelligence/jazzer/android/jazzer_bootstrap_android.jar";
+      String jazzerBootstrapJarOut = "/data/local/tmp/jazzer_bootstrap_android.jar";
+
+      try {
+        ZipUtils.extractFile(jazzerAgentPath, jazzerBootstrapJarPath, jazzerBootstrapJarOut);
+      } catch (IOException ioe) {
+        Log.error(
+            "Could not extract jazzer_bootstrap_android.jar from Jazzer standalone agent", ioe);
+        exit(1);
+      }
+
+      String nativeAgentOptions = "injectJars=" + jazzerBootstrapJarOut;
+      if (bootclassClassOverrides != null && !bootclassClassOverrides.isEmpty()) {
+        nativeAgentOptions += ",bootstrapClassOverrides=" + bootclassClassOverrides;
+      }
+
+      // ManagementFactory wont work with Android
+      Stream<String> stream = Stream.of("app_process", "-Djdk.attach.allowAttachSelf=true",
+          "-Xplugin:libopenjdkjvmti.so",
+          "-agentpath:" + agentPath.toString() + "=" + nativeAgentOptions, "-Xcompiler-option",
+          "--debuggable", "/system/bin", Jazzer.class.getName());
+
+      return stream;
+    }
+
+    Stream<String> stream = Stream.of("-cp", System.getProperty("java.class.path"),
+        // Make ByteBuddyAgent's job simpler by allowing it to attach directly to the JVM
+        // rather than relying on an external helper. The latter fails on macOS 12 with JDK 11+
+        // (but not 8) and UBSan preloaded with:
+        // Caused by: java.io.IOException: Cannot run program
+        // "/Users/runner/hostedtoolcache/Java_Zulu_jdk/17.0.4-8/x64/bin/java": error=0, Failed
+        // to exec spawn helper: pid: 8227, signal: 9
+        // Presumably, this issue is caused by codesigning and the exec helper missing the
+        // entitlements required for library insertion.
+        "-Djdk.attach.allowAttachSelf=true", Jazzer.class.getName());
+
+    return Stream.concat(ManagementFactory.getRuntimeMXBean().getInputArguments().stream(), stream);
+  }
+
+  /**
+   * Append the given elements to the value of the environment variable {@code name} that contains a
+   * list of paths separated by the system path list separator.
+   */
+  private static String appendWithPathListSeparator(String name, String... options) {
+    if (options.length == 0) {
+      throw new IllegalArgumentException("options must not be empty");
+    }
+
+    String currentValue = Optional.ofNullable(System.getenv(name)).orElse("");
+    String additionalOptions = String.join(File.pathSeparator, options);
+    if (currentValue.isEmpty()) {
+      return additionalOptions;
+    }
+    return currentValue + File.pathSeparator + additionalOptions;
+  }
+
+  private static Path findLibrary(List<String> candidateNames) {
+    if (!IS_ANDROID) {
+      return findHostClangLibrary(candidateNames);
+    }
+
+    for (String candidateName : candidateNames) {
+      String candidateFullPath = "/apex/com.android.runtime/lib64/bionic/" + candidateName;
+      File f = new File(candidateFullPath);
+      if (f.exists()) {
+        return Paths.get(candidateFullPath);
+      }
+    }
+
+    Log.error(
+        String.format("Failed to find one of %s%n for Android", String.join(", ", candidateNames)));
+    Log.error("If fuzzing hwasan, make sure you have a hwasan build flashed to your device");
+
+    exit(1);
+    throw new IllegalStateException("not reached");
+  }
+
+  private static Path findHostClangLibrary(List<String> candidateNames) {
+    for (String name : candidateNames) {
+      Optional<Path> path = tryFindLibraryInJazzerNativeSanitizersDir(name);
+      if (path.isPresent()) {
+        return path.get();
+      }
+    }
+    for (String name : candidateNames) {
+      Optional<Path> path = tryFindLibraryUsingClang(name);
+      if (path.isPresent()) {
+        return path.get();
+      }
+    }
+    Log.error("Failed to find one of: " + String.join(", ", candidateNames));
+    exit(1);
+    throw new IllegalStateException("not reached");
+  }
+
+  private static Optional<Path> tryFindLibraryInJazzerNativeSanitizersDir(String name) {
+    String nativeSanitizersDir = System.getenv("JAZZER_NATIVE_SANITIZERS_DIR");
+    if (nativeSanitizersDir == null) {
+      return Optional.empty();
+    }
+    Path candidatePath = Paths.get(nativeSanitizersDir, name);
+    if (Files.exists(candidatePath)) {
+      return Optional.of(candidatePath);
+    } else {
+      return Optional.empty();
+    }
+  }
+
+  /**
+   * Given a library name such as "libclang_rt.asan-x86_64.so", get the full path to the library
+   * installed on the host from clang (or CC, if set). Returns Optional.empty() if clang does not
+   * find the library and exits with a message in case of any other error condition.
+   */
+  private static Optional<Path> tryFindLibraryUsingClang(String name) {
+    List<String> command = asList(hostClang(), "--print-file-name", name);
+    ProcessBuilder processBuilder = new ProcessBuilder(command);
+    byte[] output;
+    try {
+      Process process = processBuilder.start();
+      if (process.waitFor() != 0) {
+        Log.error(String.format(
+            "'%s' exited with exit code %d", String.join(" ", command), process.exitValue()));
+        copy(process.getInputStream(), System.out);
+        copy(process.getErrorStream(), System.err);
+        exit(1);
+      }
+      output = readAllBytes(process.getInputStream());
+    } catch (IOException | InterruptedException e) {
+      Log.error(String.format("Failed to run '%s'", String.join(" ", command)), e);
+      exit(1);
+      throw new IllegalStateException("not reached");
+    }
+    Path library = Paths.get(new String(output).trim());
+    if (Files.exists(library)) {
+      return Optional.of(library);
+    }
+    return Optional.empty();
+  }
+
+  private static String hostClang() {
+    return Optional.ofNullable(System.getenv("CC")).orElse("clang");
+  }
+
+  private static List<String> hwasanLibNames() {
+    if (!IS_ANDROID) {
+      Log.error("HWAsan is only supported for Android. Please try --asan");
+      exit(1);
+    }
+
+    return singletonList("libclang_rt.hwasan-aarch64-android.so");
+  }
+
+  private static List<String> asanLibNames() {
+    if (isLinux()) {
+      if (IS_ANDROID) {
+        Log.error("ASan is not supported for Android at this time. Use --hwasan for Address "
+            + "Sanitization on Android");
+        exit(1);
+      }
+
+      // Since LLVM 15 sanitizer runtimes no longer have the architecture in the filename.
+      return asList("libclang_rt.asan.so", "libclang_rt.asan-x86_64.so");
+    } else {
+      return singletonList("libclang_rt.asan_osx_dynamic.dylib");
+    }
+  }
+
+  private static List<String> ubsanLibNames() {
+    if (isLinux()) {
+      if (IS_ANDROID) {
+        // return asList("libclang_rt.ubsan_standalone-aarch64-android.so");
+        Log.error("ERROR: UBSan is not supported for Android at this time.");
+        exit(1);
+      }
+
+      return asList("libclang_rt.ubsan_standalone.so", "libclang_rt.ubsan_standalone-x86_64.so");
+    } else {
+      return singletonList("libclang_rt.ubsan_osx_dynamic.dylib");
+    }
+  }
+
+  private static String preloadVariable() {
+    return isLinux() ? "LD_PRELOAD" : "DYLD_INSERT_LIBRARIES";
+  }
+
+  private static boolean isLinux() {
+    return System.getProperty("os.name").startsWith("Linux");
+  }
+
+  private static boolean isMacOs() {
+    return System.getProperty("os.name").startsWith("Mac OS X");
+  }
+
+  private static boolean isPosix() {
+    return !IS_ANDROID && FileSystems.getDefault().supportedFileAttributeViews().contains("posix");
+  }
+
+  private static String getAndroidRuntimeOptions() {
+    List<String> validInitOptions = Arrays.asList("use_platform_libs", "use_none", "");
+    String initOptString = System.getProperty("jazzer.android_init_options");
+    if (!validInitOptions.contains(initOptString)) {
+      Log.error("Invalid android_init_options set for Android Runtime.");
+      exit(1);
+    }
+    return initOptString;
+  }
+
+  private static boolean isPosixOrAndroid() {
+    if (isPosix()) {
+      return true;
+    }
+    return IS_ANDROID;
+  }
+
+  private static byte[] readAllBytes(InputStream in) throws IOException {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    copy(in, out);
+    return out.toByteArray();
+  }
+
+  private static void copy(InputStream source, OutputStream target) throws IOException {
+    byte[] buffer = new byte[64 * 104 * 1024];
+    int read;
+    while ((read = source.read(buffer)) != -1) {
+      target.write(buffer, 0, read);
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/agent/Agent.kt b/src/main/java/com/code_intelligence/jazzer/agent/Agent.kt
new file mode 100644
index 0000000..9bcd744
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/agent/Agent.kt
@@ -0,0 +1,172 @@
+// Copyright 2021 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+@file:JvmName("Agent")
+
+package com.code_intelligence.jazzer.agent
+
+import com.code_intelligence.jazzer.driver.Opt
+import com.code_intelligence.jazzer.instrumentor.CoverageRecorder
+import com.code_intelligence.jazzer.instrumentor.Hooks
+import com.code_intelligence.jazzer.instrumentor.InstrumentationType
+import com.code_intelligence.jazzer.sanitizers.Constants
+import com.code_intelligence.jazzer.utils.ClassNameGlobber
+import com.code_intelligence.jazzer.utils.Log
+import com.code_intelligence.jazzer.utils.ManifestUtils
+import java.lang.instrument.Instrumentation
+import java.nio.file.Paths
+import kotlin.io.path.exists
+import kotlin.io.path.isDirectory
+
+fun install(instrumentation: Instrumentation) {
+    installInternal(instrumentation)
+}
+
+fun installInternal(
+    instrumentation: Instrumentation,
+    userHookNames: List<String> = findManifestCustomHookNames() + Opt.customHooks,
+    disabledHookNames: List<String> = Opt.disabledHooks,
+    instrumentationIncludes: List<String> = Opt.instrumentationIncludes.get(),
+    instrumentationExcludes: List<String> = Opt.instrumentationExcludes.get(),
+    customHookIncludes: List<String> = Opt.customHookIncludes.get(),
+    customHookExcludes: List<String> = Opt.customHookExcludes.get(),
+    trace: List<String> = Opt.trace,
+    idSyncFile: String? = Opt.idSyncFile,
+    dumpClassesDir: String = Opt.dumpClassesDir,
+    additionalClassesExcludes: List<String> = Opt.additionalClassesExcludes,
+) {
+    val allCustomHookNames = (Constants.SANITIZER_HOOK_NAMES + userHookNames).toSet()
+    check(allCustomHookNames.isNotEmpty()) { "No hooks registered; expected at least the built-in hooks" }
+    val customHookNames = allCustomHookNames - disabledHookNames.toSet()
+    val disabledCustomHooksToPrint = allCustomHookNames - customHookNames.toSet()
+    if (disabledCustomHooksToPrint.isNotEmpty()) {
+        Log.info("Not using the following disabled hooks: ${disabledCustomHooksToPrint.joinToString(", ")}")
+    }
+
+    val classNameGlobber = ClassNameGlobber(instrumentationIncludes, instrumentationExcludes + customHookNames)
+    CoverageRecorder.classNameGlobber = classNameGlobber
+    val customHookClassNameGlobber = ClassNameGlobber(customHookIncludes, customHookExcludes + customHookNames)
+    // FIXME: Setting trace to the empty string explicitly results in all rather than no trace types
+    //  being applied - this is unintuitive.
+    val instrumentationTypes = (trace.takeIf { it.isNotEmpty() } ?: listOf("all")).flatMap {
+        when (it) {
+            "cmp" -> setOf(InstrumentationType.CMP)
+            "cov" -> setOf(InstrumentationType.COV)
+            "div" -> setOf(InstrumentationType.DIV)
+            "gep" -> setOf(InstrumentationType.GEP)
+            "indir" -> setOf(InstrumentationType.INDIR)
+            "native" -> setOf(InstrumentationType.NATIVE)
+            // Disable GEP instrumentation by default as it appears to negatively affect fuzzing
+            // performance. Our current GEP instrumentation only reports constant indices, but even
+            // when we instead reported non-constant indices, they tended to completely fill up the
+            // table of recent compares and value profile map.
+            "all" -> InstrumentationType.values().toSet() - InstrumentationType.GEP
+            else -> {
+                println("WARN: Skipping unknown instrumentation type $it")
+                emptySet()
+            }
+        }
+    }.toSet()
+
+    val idSyncFilePath = idSyncFile?.takeUnless { it.isEmpty() }?.let {
+        Paths.get(it).also { path ->
+            Log.info("Synchronizing coverage IDs in ${path.toAbsolutePath()}")
+        }
+    }
+    val dumpClassesDirPath = dumpClassesDir.takeUnless { it.isEmpty() }?.let {
+        Paths.get(it).toAbsolutePath().also { path ->
+            if (path.exists() && path.isDirectory()) {
+                Log.info("Dumping instrumented classes into $path")
+            } else {
+                Log.error("Cannot dump instrumented classes into $path; does not exist or not a directory")
+            }
+        }
+    }
+    val includedHookNames = instrumentationTypes
+        .mapNotNull { type ->
+            when (type) {
+                InstrumentationType.CMP -> "com.code_intelligence.jazzer.runtime.TraceCmpHooks"
+                InstrumentationType.DIV -> "com.code_intelligence.jazzer.runtime.TraceDivHooks"
+                InstrumentationType.INDIR -> "com.code_intelligence.jazzer.runtime.TraceIndirHooks"
+                InstrumentationType.NATIVE -> "com.code_intelligence.jazzer.runtime.NativeLibHooks"
+                else -> null
+            }
+        }
+    val coverageIdSynchronizer = if (idSyncFilePath != null) {
+        FileSyncCoverageIdStrategy(idSyncFilePath)
+    } else {
+        MemSyncCoverageIdStrategy()
+    }
+
+    // If we don't append the JARs containing the custom hooks to the bootstrap class loader,
+    // third-party hooks not contained in the agent JAR will not be able to instrument Java standard
+    // library classes. These classes are loaded by the bootstrap / system class loader and would
+    // not be considered when resolving references to hook methods, leading to NoClassDefFoundError
+    // being thrown.
+    Hooks.appendHooksToBootstrapClassLoaderSearch(instrumentation, customHookNames.toSet())
+    val (includedHooks, customHooks) = Hooks.loadHooks(additionalClassesExcludes, includedHookNames.toSet(), customHookNames.toSet())
+
+    val runtimeInstrumentor = RuntimeInstrumentor(
+        instrumentation,
+        classNameGlobber,
+        customHookClassNameGlobber,
+        instrumentationTypes,
+        includedHooks.hooks,
+        customHooks.hooks,
+        customHooks.additionalHookClassNameGlobber,
+        coverageIdSynchronizer,
+        dumpClassesDirPath,
+    )
+
+    // These classes are e.g. dependencies of the RuntimeInstrumentor or hooks and thus were loaded
+    // before the instrumentor was ready. Since we haven't enabled it yet, they can safely be
+    // "retransformed": They haven't been transformed yet.
+    val classesToRetransform = instrumentation.allLoadedClasses
+        .filter {
+            classNameGlobber.includes(it.name) ||
+                customHookClassNameGlobber.includes(it.name) ||
+                customHooks.additionalHookClassNameGlobber.includes(it.name)
+        }
+        .filter {
+            instrumentation.isModifiableClass(it)
+        }
+        .toTypedArray()
+
+    instrumentation.addTransformer(runtimeInstrumentor, true)
+
+    if (classesToRetransform.isNotEmpty()) {
+        if (instrumentation.isRetransformClassesSupported) {
+            retransformClassesWithRetry(instrumentation, classesToRetransform)
+        }
+    }
+}
+
+private fun retransformClassesWithRetry(instrumentation: Instrumentation, classesToRetransform: Array<Class<*>>) {
+    try {
+        instrumentation.retransformClasses(*classesToRetransform)
+    } catch (e: Throwable) {
+        if (classesToRetransform.size == 1) {
+            Log.warn("Error retransforming class ${classesToRetransform[0].name }", e)
+        } else {
+            // The docs state that no transformation was performed if an exception is thrown.
+            // Try again in a binary search fashion, until the not transformable classes have been isolated and reported.
+            retransformClassesWithRetry(instrumentation, classesToRetransform.copyOfRange(0, classesToRetransform.size / 2))
+            retransformClassesWithRetry(instrumentation, classesToRetransform.copyOfRange(classesToRetransform.size / 2, classesToRetransform.size))
+        }
+    }
+}
+
+private fun findManifestCustomHookNames() = ManifestUtils.combineManifestValues(ManifestUtils.HOOK_CLASSES)
+    .flatMap { it.split(':') }
+    .filter { it.isNotBlank() }
diff --git a/src/main/java/com/code_intelligence/jazzer/agent/AgentInstaller.java b/src/main/java/com/code_intelligence/jazzer/agent/AgentInstaller.java
new file mode 100644
index 0000000..5dd041a
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/agent/AgentInstaller.java
@@ -0,0 +1,58 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.agent;
+
+import static com.code_intelligence.jazzer.agent.AgentUtils.extractBootstrapJar;
+import static com.code_intelligence.jazzer.runtime.Constants.IS_ANDROID;
+
+import java.lang.instrument.Instrumentation;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.concurrent.atomic.AtomicBoolean;
+import net.bytebuddy.agent.ByteBuddyAgent;
+
+public class AgentInstaller {
+  private static final AtomicBoolean hasBeenInstalled = new AtomicBoolean();
+
+  /**
+   * Appends the parts of Jazzer that have to be visible to all classes, including those in the Java
+   * standard library, to the bootstrap class loader path. Additionally, if enableAgent is true,
+   * also enables the Jazzer agent that instruments classes for fuzzing.
+   */
+  public static void install(boolean enableAgent) {
+    // Only install the agent once.
+    if (!hasBeenInstalled.compareAndSet(false, true)) {
+      return;
+    }
+
+    if (IS_ANDROID) {
+      return;
+    }
+
+    Instrumentation instrumentation = ByteBuddyAgent.install();
+    instrumentation.appendToBootstrapClassLoaderSearch(extractBootstrapJar());
+    if (!enableAgent) {
+      return;
+    }
+    try {
+      Class<?> agent = Class.forName("com.code_intelligence.jazzer.agent.Agent");
+      Method install = agent.getMethod("install", Instrumentation.class);
+      install.invoke(null, instrumentation);
+    } catch (ClassNotFoundException | InvocationTargetException | NoSuchMethodException
+        | IllegalAccessException e) {
+      throw new IllegalStateException("Failed to run Agent.install", e);
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/agent/AgentUtils.java b/src/main/java/com/code_intelligence/jazzer/agent/AgentUtils.java
new file mode 100644
index 0000000..e654252
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/agent/AgentUtils.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.agent;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.StandardCopyOption;
+import java.util.jar.JarFile;
+
+final class AgentUtils {
+  private static final String BOOTSTRAP_JAR =
+      "/com/code_intelligence/jazzer/runtime/jazzer_bootstrap.jar";
+
+  public static JarFile extractBootstrapJar() {
+    try (InputStream bootstrapJarStream = AgentUtils.class.getResourceAsStream(BOOTSTRAP_JAR)) {
+      if (bootstrapJarStream == null) {
+        throw new IllegalStateException("Failed to find Jazzer agent bootstrap jar in resources");
+      }
+      File bootstrapJar = Files.createTempFile("jazzer-agent-", ".jar").toFile();
+      bootstrapJar.deleteOnExit();
+      Files.copy(bootstrapJarStream, bootstrapJar.toPath(), StandardCopyOption.REPLACE_EXISTING);
+      return new JarFile(bootstrapJar);
+    } catch (IOException e) {
+      throw new IllegalStateException("Failed to extract Jazzer agent bootstrap jar", e);
+    }
+  }
+
+  private AgentUtils() {}
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/agent/BUILD.bazel b/src/main/java/com/code_intelligence/jazzer/agent/BUILD.bazel
new file mode 100644
index 0000000..89acbda
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/agent/BUILD.bazel
@@ -0,0 +1,43 @@
+load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library")
+load("//bazel:kotlin.bzl", "ktlint")
+
+java_library(
+    name = "agent_installer",
+    srcs = ["AgentInstaller.java"],
+    resources = select({
+        "@platforms//os:android": [
+            "//src/main/java/com/code_intelligence/jazzer/android:jazzer_bootstrap_android",
+        ],
+        "//conditions:default": [
+            "//src/main/java/com/code_intelligence/jazzer/runtime:jazzer_bootstrap",
+        ],
+    }),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":agent_lib",
+        "//src/main/java/com/code_intelligence/jazzer/driver:opt",
+        "//src/main/java/com/code_intelligence/jazzer/runtime:constants",
+        "@net_bytebuddy_byte_buddy_agent//jar",
+    ],
+)
+
+kt_jvm_library(
+    name = "agent_lib",
+    srcs = [
+        "Agent.kt",
+        "AgentUtils.java",
+        "CoverageIdStrategy.kt",
+        "RuntimeInstrumentor.kt",
+    ],
+    deps = [
+        "//sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers:constants",
+        "//src/main/java/com/code_intelligence/jazzer/driver:opt",
+        "//src/main/java/com/code_intelligence/jazzer/instrumentor",
+        "//src/main/java/com/code_intelligence/jazzer/utils:class_name_globber",
+        "//src/main/java/com/code_intelligence/jazzer/utils:log",
+        "//src/main/java/com/code_intelligence/jazzer/utils:manifest_utils",
+        "@com_github_classgraph_classgraph//:classgraph",
+    ],
+)
+
+ktlint()
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/agent/CoverageIdStrategy.kt b/src/main/java/com/code_intelligence/jazzer/agent/CoverageIdStrategy.kt
similarity index 91%
rename from agent/src/main/java/com/code_intelligence/jazzer/agent/CoverageIdStrategy.kt
rename to src/main/java/com/code_intelligence/jazzer/agent/CoverageIdStrategy.kt
index 5d1d28e..75d7600 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/agent/CoverageIdStrategy.kt
+++ b/src/main/java/com/code_intelligence/jazzer/agent/CoverageIdStrategy.kt
@@ -14,8 +14,8 @@
 
 package com.code_intelligence.jazzer.agent
 
-import com.code_intelligence.jazzer.utils.append
-import com.code_intelligence.jazzer.utils.readFully
+import com.code_intelligence.jazzer.utils.Log
+import java.nio.ByteBuffer
 import java.nio.channels.FileChannel
 import java.nio.channels.FileLock
 import java.nio.file.Path
@@ -109,7 +109,7 @@
             val localIdFile = FileChannel.open(
                 idSyncFile,
                 StandardOpenOption.WRITE,
-                StandardOpenOption.READ
+                StandardOpenOption.READ,
             )
             // Wait until we have obtained the lock on the sync file. We hold the lock from this point until we have
             // finished reading and writing (if necessary) to the file.
@@ -156,7 +156,7 @@
                 }
                 else -> {
                     localIdFile.close()
-                    System.err.println(idInfo.joinToString("\n") { "${it.first}:${it.second}:${it.third}" })
+                    Log.println(idInfo.joinToString("\n") { "${it.first}:${it.second}:${it.third}" })
                     throw IllegalStateException("Multiple entries for $className in ID file")
                 }
             }
@@ -198,3 +198,26 @@
         }
     }
 }
+
+/**
+ * Reads the [FileChannel] to the end as a UTF-8 string.
+ */
+fun FileChannel.readFully(): String {
+    check(size() <= Int.MAX_VALUE)
+    val buffer = ByteBuffer.allocate(size().toInt())
+    while (buffer.hasRemaining()) {
+        when (read(buffer)) {
+            0 -> throw IllegalStateException("No bytes read")
+            -1 -> break
+        }
+    }
+    return String(buffer.array())
+}
+
+/**
+ * Appends [string] to the end of the [FileChannel].
+ */
+fun FileChannel.append(string: String) {
+    position(size())
+    write(ByteBuffer.wrap(string.toByteArray()))
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/agent/RuntimeInstrumentor.kt b/src/main/java/com/code_intelligence/jazzer/agent/RuntimeInstrumentor.kt
new file mode 100644
index 0000000..57410f3
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/agent/RuntimeInstrumentor.kt
@@ -0,0 +1,243 @@
+// Copyright 2021 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.agent
+
+import com.code_intelligence.jazzer.driver.Opt
+import com.code_intelligence.jazzer.instrumentor.ClassInstrumentor
+import com.code_intelligence.jazzer.instrumentor.CoverageRecorder
+import com.code_intelligence.jazzer.instrumentor.Hook
+import com.code_intelligence.jazzer.instrumentor.InstrumentationType
+import com.code_intelligence.jazzer.utils.ClassNameGlobber
+import com.code_intelligence.jazzer.utils.Log
+import io.github.classgraph.ClassGraph
+import java.io.File
+import java.lang.instrument.ClassFileTransformer
+import java.lang.instrument.Instrumentation
+import java.nio.file.Path
+import java.security.ProtectionDomain
+import kotlin.math.roundToInt
+import kotlin.system.exitProcess
+import kotlin.time.measureTimedValue
+
+class RuntimeInstrumentor(
+    private val instrumentation: Instrumentation,
+    private val classesToFullyInstrument: ClassNameGlobber,
+    private val classesToHookInstrument: ClassNameGlobber,
+    private val instrumentationTypes: Set<InstrumentationType>,
+    private val includedHooks: List<Hook>,
+    private val customHooks: List<Hook>,
+    // Dedicated name globber for additional classes to hook stated in hook annotations is needed due to
+    // existing include and exclude pattern of classesToHookInstrument. All classes are included in hook
+    // instrumentation except the ones from default excludes, like JDK and Kotlin classes. But additional
+    // classes to hook, based on annotations, are allowed to reference normally ignored ones, like JDK
+    // and Kotlin internals.
+    // FIXME: Adding an additional class to hook will apply _all_ hooks to it and not only the one it's
+    // defined in. At some point we might want to track the list of classes per custom hook rather than globally.
+    private val additionalClassesToHookInstrument: ClassNameGlobber,
+    private val coverageIdSynchronizer: CoverageIdStrategy,
+    private val dumpClassesDir: Path?,
+) : ClassFileTransformer {
+
+    @kotlin.time.ExperimentalTime
+    override fun transform(
+        loader: ClassLoader?,
+        internalClassName: String,
+        classBeingRedefined: Class<*>?,
+        protectionDomain: ProtectionDomain?,
+        classfileBuffer: ByteArray,
+    ): ByteArray? {
+        var pathPrefix = ""
+        if (!Opt.instrumentOnly.isEmpty() && protectionDomain != null) {
+            var outputPathPrefix = protectionDomain.getCodeSource().getLocation().getFile().toString()
+            if (outputPathPrefix.isNotEmpty()) {
+                if (outputPathPrefix.contains(File.separator)) {
+                    outputPathPrefix = outputPathPrefix.substring(outputPathPrefix.lastIndexOf(File.separator) + 1, outputPathPrefix.length)
+                }
+
+                if (outputPathPrefix.endsWith(".jar")) {
+                    outputPathPrefix = outputPathPrefix.substring(0, outputPathPrefix.lastIndexOf(".jar"))
+                }
+
+                if (outputPathPrefix.isNotEmpty()) {
+                    pathPrefix = outputPathPrefix + File.separator
+                }
+            }
+        }
+
+        return try {
+            // Bail out early if we would instrument ourselves. This prevents ClassCircularityErrors as we might need to
+            // load additional Jazzer classes until we reach the full exclusion logic.
+            if (internalClassName.startsWith("com/code_intelligence/jazzer/")) {
+                return null
+            }
+            // Workaround for a JDK bug (http://bugs.java.com/bugdatabase/view_bug.do?bug_id=JDK-8299798):
+            // When retransforming a class in the Java standard library, the provided classfileBuffer does not contain
+            // any StackMapTable attributes. Our transformations require stack map frames to calculate the number of
+            // local variables and stack slots as well as when adding control flow.
+            //
+            // We work around this by reloading the class file contents if we are retransforming (classBeingRedefined
+            // is also non-null in this situation) and the class is provided by the bootstrap loader.
+            //
+            // Alternatives considered:
+            // Using ClassWriter.COMPUTE_FRAMES as an escape hatch isn't possible in the context of an agent as the
+            // computation may itself need to load classes, which leads to circular loads and incompatible class
+            // redefinitions.
+            transformInternal(internalClassName, classfileBuffer.takeUnless { loader == null && classBeingRedefined != null })
+        } catch (t: Throwable) {
+            // Throwables raised from transform are silently dropped, making it extremely hard to detect instrumentation
+            // failures. The docs advise to use a top-level try-catch.
+            // https://docs.oracle.com/javase/9/docs/api/java/lang/instrument/ClassFileTransformer.html
+            if (dumpClassesDir != null) {
+                dumpToClassFile(internalClassName, classfileBuffer, basenameSuffix = ".failed", pathPrefix = pathPrefix)
+            }
+            Log.warn("Failed to instrument $internalClassName:", t)
+            throw t
+        }.also { instrumentedByteCode ->
+            // Only dump classes that were instrumented.
+            if (instrumentedByteCode != null && dumpClassesDir != null) {
+                dumpToClassFile(internalClassName, instrumentedByteCode, pathPrefix = pathPrefix)
+                dumpToClassFile(internalClassName, classfileBuffer, basenameSuffix = ".original", pathPrefix = pathPrefix)
+            }
+        }
+    }
+
+    private fun dumpToClassFile(internalClassName: String, bytecode: ByteArray, basenameSuffix: String = "", pathPrefix: String = "") {
+        val relativePath = "$pathPrefix$internalClassName$basenameSuffix.class"
+        val absolutePath = dumpClassesDir!!.resolve(relativePath)
+        val dumpFile = absolutePath.toFile()
+        dumpFile.parentFile.mkdirs()
+        dumpFile.writeBytes(bytecode)
+    }
+
+    @kotlin.time.ExperimentalTime
+    override fun transform(
+        module: Module?,
+        loader: ClassLoader?,
+        internalClassName: String,
+        classBeingRedefined: Class<*>?,
+        protectionDomain: ProtectionDomain?,
+        classfileBuffer: ByteArray,
+    ): ByteArray? {
+        try {
+            if (module != null && !module.canRead(RuntimeInstrumentor::class.java.module)) {
+                // Make all other modules read our (unnamed) module, which allows them to access the classes needed by the
+                // instrumentations, e.g. CoverageMap. If a module can't be modified, it should not be instrumented as the
+                // injected bytecode might throw NoClassDefFoundError.
+                // https://mail.openjdk.java.net/pipermail/jigsaw-dev/2021-May/014663.html
+                if (!instrumentation.isModifiableModule(module)) {
+                    val prettyClassName = internalClassName.replace('/', '.')
+                    Log.warn("Failed to instrument $prettyClassName in unmodifiable module ${module.name}, skipping")
+                    return null
+                }
+                instrumentation.redefineModule(
+                    module,
+                    setOf(RuntimeInstrumentor::class.java.module), // extraReads
+                    emptyMap(),
+                    emptyMap(),
+                    emptySet(),
+                    emptyMap(),
+                )
+            }
+        } catch (t: Throwable) {
+            // Throwables raised from transform are silently dropped, making it extremely hard to detect instrumentation
+            // failures. The docs advise to use a top-level try-catch.
+            // https://docs.oracle.com/javase/9/docs/api/java/lang/instrument/ClassFileTransformer.html
+            if (dumpClassesDir != null) {
+                dumpToClassFile(internalClassName, classfileBuffer, basenameSuffix = ".failed")
+            }
+            Log.warn("Failed to instrument $internalClassName:", t)
+            throw t
+        }
+        return transform(loader, internalClassName, classBeingRedefined, protectionDomain, classfileBuffer)
+    }
+
+    @kotlin.time.ExperimentalTime
+    fun transformInternal(internalClassName: String, maybeClassfileBuffer: ByteArray?): ByteArray? {
+        val (fullInstrumentation, printInfo) = when {
+            classesToFullyInstrument.includes(internalClassName) -> Pair(true, true)
+            classesToHookInstrument.includes(internalClassName) -> Pair(false, true)
+            // The classes to hook specified by hooks are more of an implementation detail of the hook. The list is
+            // always the same unless the set of hooks changes and doesn't help the user judge whether their classes are
+            // being instrumented, so we don't print info for them.
+            additionalClassesToHookInstrument.includes(internalClassName) -> Pair(false, false)
+            else -> return null
+        }
+        val className = internalClassName.replace('/', '.')
+        val classfileBuffer = maybeClassfileBuffer ?: ClassGraph()
+            .enableSystemJarsAndModules()
+            .ignoreClassVisibility()
+            .acceptClasses(className)
+            .scan()
+            .use {
+                it.getClassInfo(className)?.resource?.load() ?: run {
+                    Log.warn("Failed to load bytecode of class $className")
+                    return null
+                }
+            }
+        val (instrumentedBytecode, duration) = measureTimedValue {
+            try {
+                instrument(internalClassName, classfileBuffer, fullInstrumentation)
+            } catch (e: CoverageIdException) {
+                Log.error("Coverage IDs are out of sync")
+                e.printStackTrace()
+                exitProcess(1)
+            }
+        }
+        val durationInMs = duration.inWholeMilliseconds
+        val sizeIncrease = ((100.0 * (instrumentedBytecode.size - classfileBuffer.size)) / classfileBuffer.size).roundToInt()
+        if (printInfo) {
+            if (fullInstrumentation) {
+                Log.info("Instrumented $className (took $durationInMs ms, size +$sizeIncrease%)")
+            } else {
+                Log.info("Instrumented $className with custom hooks only (took $durationInMs ms, size +$sizeIncrease%)")
+            }
+        }
+        return instrumentedBytecode
+    }
+
+    private fun instrument(internalClassName: String, bytecode: ByteArray, fullInstrumentation: Boolean): ByteArray {
+        val classWithHooksEnabledField = if (Opt.conditionalHooks) {
+            // Let the hook instrumentation emit additional logic that checks the value of the
+            // hooksEnabled field on this class and skips the hook if it is false.
+            "com/code_intelligence/jazzer/runtime/JazzerInternal"
+        } else {
+            null
+        }
+        return ClassInstrumentor(internalClassName, bytecode).run {
+            if (fullInstrumentation) {
+                // Coverage instrumentation must be performed before any other code updates
+                // or there will be additional coverage points injected if any calls are inserted
+                // and JaCoCo will produce a broken coverage report.
+                coverageIdSynchronizer.withIdForClass(internalClassName) { firstId ->
+                    coverage(firstId).also { actualNumEdgeIds ->
+                        CoverageRecorder.recordInstrumentedClass(
+                            internalClassName,
+                            bytecode,
+                            firstId,
+                            actualNumEdgeIds,
+                        )
+                    }
+                }
+                // Hook instrumentation must be performed after data flow tracing as the injected
+                // bytecode would trigger the GEP callbacks for byte[].
+                traceDataFlow(instrumentationTypes)
+                hooks(includedHooks + customHooks, classWithHooksEnabledField)
+            } else {
+                hooks(customHooks, classWithHooksEnabledField)
+            }
+            instrumentedBytecode
+        }
+    }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/android/AndroidRuntime.java b/src/main/java/com/code_intelligence/jazzer/android/AndroidRuntime.java
new file mode 100644
index 0000000..3a80c31
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/android/AndroidRuntime.java
@@ -0,0 +1,67 @@
+// Copyright 2021 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.android;
+
+import com.code_intelligence.jazzer.utils.Log;
+import com.github.fmeum.rules_jni.RulesJni;
+
+/**
+ * Loads Android tooling library and registers native functions.
+ */
+public class AndroidRuntime {
+  private static final String DO_NOT_INITIALIZE = "use_none";
+  private static final String FUZZ_DIR = "/data/fuzz/";
+  private static final String PLATFORM_LIB_DIRS = ":/system/lib64/:/apex/com.android.i18n@1/lib64/";
+
+  public static void initialize(String runtimeLibs) {
+    if (runtimeLibs == null) {
+      return;
+    }
+
+    RulesJni.loadLibrary("jazzer_android_tooling", "/com/code_intelligence/jazzer/driver");
+    if (runtimeLibs.equals(DO_NOT_INITIALIZE)) {
+      Log.warn("Android Runtime (ART) is not being initialized for this fuzzer.");
+    } else {
+      registerNatives();
+    }
+  };
+
+  /**
+   * Returns a command to set the classpath for fuzzing.
+   *
+   * @return The classpath command.
+   */
+  public static String getClassPathsCommand() {
+    return "export CLASSPATH=" + System.getProperty("java.class.path");
+  }
+
+  /**
+   * Builds and returns the value to set for LD_LIBRARY_PATH.
+   * This value is consumed when launching jazzer on the device
+   * and specifies which directories to search for dependencies.
+   *
+   * @return The string for LD_LIBRARY_PATH.
+   */
+  public static String getLdLibraryPath() {
+    String initOptString = System.getProperty("jazzer.android_init_options");
+    if (initOptString.equals(DO_NOT_INITIALIZE) || initOptString.equals("")) {
+      return FUZZ_DIR;
+    }
+
+    return FUZZ_DIR + PLATFORM_LIB_DIRS;
+  }
+
+  private static native int registerNatives();
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/android/BUILD.bazel b/src/main/java/com/code_intelligence/jazzer/android/BUILD.bazel
new file mode 100644
index 0000000..1204c4e
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/android/BUILD.bazel
@@ -0,0 +1,95 @@
+load("//bazel:compat.bzl", "SKIP_ON_WINDOWS")
+load("@bazel_skylib//rules:copy_file.bzl", "copy_file")
+load("@fmeum_rules_jni//jni:defs.bzl", "java_jni_library")
+
+java_import(
+    name = "jazzer_bootstrap_android_import",
+    jars = [
+        "//src/main/java/com/code_intelligence/jazzer/runtime:jazzer_bootstrap",
+    ],
+    tags = ["manual"],
+    target_compatible_with = SKIP_ON_WINDOWS,
+)
+
+android_library(
+    name = "jazzer_bootstrap_android_lib",
+    tags = ["manual"],
+    target_compatible_with = SKIP_ON_WINDOWS,
+    visibility = [
+        "//src/main/java/com/code_intelligence/jazzer/agent:__pkg__",
+    ],
+    exports = [
+        ":jazzer_bootstrap_android_import",
+    ],
+)
+
+android_binary(
+    name = "jazzer_bootstrap_android_bin",
+    manifest = "//launcher/android:android_manifest",
+    min_sdk_version = 26,
+    tags = ["manual"],
+    target_compatible_with = SKIP_ON_WINDOWS,
+    deps = [
+        ":jazzer_bootstrap_android_lib",
+    ],
+)
+
+copy_file(
+    name = "jazzer_bootstrap_android",
+    src = "jazzer_bootstrap_android_bin.apk",
+    out = "jazzer_bootstrap_android.jar",
+    tags = ["manual"],
+    target_compatible_with = SKIP_ON_WINDOWS,
+    visibility = [
+        "//src/main/java/com/code_intelligence/jazzer/agent:__pkg__",
+    ],
+)
+
+java_jni_library(
+    name = "dex_file_manager",
+    srcs = ["DexFileManager.java"],
+    native_libs = [
+        "//src/main/native/com/code_intelligence/jazzer/android:android_native_agent",
+    ],
+)
+
+android_library(
+    name = "jazzer_standalone_library",
+    tags = ["manual"],
+    target_compatible_with = SKIP_ON_WINDOWS,
+    exports = [
+        "//deploy:jazzer-api",
+        "//src/main/java/com/code_intelligence/jazzer:jazzer_import",
+    ],
+)
+
+android_binary(
+    name = "jazzer_standalone_android",
+    manifest = "//launcher/android:android_manifest",
+    min_sdk_version = 26,
+    tags = ["manual"],
+    target_compatible_with = SKIP_ON_WINDOWS,
+    visibility = [
+        "//:__pkg__",
+        "//launcher/android:__pkg__",
+    ],
+    deps = [
+        ":dex_file_manager",
+        ":jazzer_standalone_library",
+    ],
+)
+
+java_jni_library(
+    name = "android_runtime",
+    srcs = ["AndroidRuntime.java"],
+    native_libs = ["//src/main/native/com/code_intelligence/jazzer/driver:jazzer_android_tooling"],
+    target_compatible_with = SKIP_ON_WINDOWS,
+    visibility = [
+        "//src/main/java/com/code_intelligence/jazzer:__pkg__",
+        "//src/main/java/com/code_intelligence/jazzer/driver:__subpackages__",
+        "//src/main/native/com/code_intelligence/jazzer/driver:__subpackages__",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/utils:log",
+    ],
+)
diff --git a/src/main/java/com/code_intelligence/jazzer/android/DexFileManager.java b/src/main/java/com/code_intelligence/jazzer/android/DexFileManager.java
new file mode 100644
index 0000000..23d2eee
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/android/DexFileManager.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.android;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.lang.Math;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Enumeration;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+
+public class DexFileManager {
+  private final static int MAX_READ_LENGTH = 2000000;
+
+  public static byte[] getBytecodeFromDex(String jarPath, String dexFile) throws IOException {
+    try (JarFile jarFile = new JarFile(jarPath)) {
+      JarEntry entry = jarFile.stream()
+                           .filter(jarEntry -> jarEntry.getName().equals(dexFile))
+                           .findFirst()
+                           .orElse(null);
+
+      if (entry == null) {
+        throw new IOException("Could not find dex file: " + dexFile);
+      }
+
+      try (InputStream is = jarFile.getInputStream(entry)) {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+
+        byte[] buffer = new byte[64 * 104 * 1024];
+        int read;
+        while ((read = is.read(buffer)) != -1) {
+          out.write(buffer, 0, read);
+        }
+
+        return out.toByteArray();
+      }
+    }
+  }
+
+  public static String[] getDexFilesForJar(String jarpath) throws IOException {
+    try (JarFile jarFile = new JarFile(jarpath)) {
+      return jarFile.stream()
+          .map(JarEntry::getName)
+          .filter(entry -> entry.endsWith(".dex"))
+          .toArray(String[] ::new);
+    }
+  }
+}
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/api/Jazzer.java b/src/main/java/com/code_intelligence/jazzer/api/Autofuzz.java
similarity index 62%
rename from agent/src/main/java/com/code_intelligence/jazzer/api/Jazzer.java
rename to src/main/java/com/code_intelligence/jazzer/api/Autofuzz.java
index 97adf57..77711ee 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/api/Jazzer.java
+++ b/src/main/java/com/code_intelligence/jazzer/api/Autofuzz.java
@@ -17,35 +17,11 @@
 import java.lang.invoke.MethodHandle;
 import java.lang.invoke.MethodHandles;
 import java.lang.invoke.MethodType;
-import java.lang.reflect.InvocationTargetException;
-import java.security.SecureRandom;
 
 /**
- * Helper class with static methods that interact with Jazzer at runtime.
+ * Static helper functions that allow Jazzer fuzz targets to use Autofuzz.
  */
-final public class Jazzer {
-  /**
-   * A 32-bit random number that hooks can use to make pseudo-random choices
-   * between multiple possible mutations they could guide the fuzzer towards.
-   * Hooks <b>must not</b> base the decision whether or not to report a finding
-   * on this number as this will make findings non-reproducible.
-   * <p>
-   * This is the same number that libFuzzer uses as a seed internally, which
-   * makes it possible to deterministically reproduce a previous fuzzing run by
-   * supplying the seed value printed by libFuzzer as the value of the
-   * {@code -seed}.
-   */
-  public static final int SEED = getLibFuzzerSeed();
-
-  private static final Class<?> JAZZER_INTERNAL;
-
-  private static final MethodHandle ON_FUZZ_TARGET_READY;
-
-  private static final MethodHandle TRACE_STRCMP;
-  private static final MethodHandle TRACE_STRSTR;
-  private static final MethodHandle TRACE_MEMCMP;
-  private static final MethodHandle TRACE_PC_INDIR;
-
+final public class Autofuzz {
   private static final MethodHandle CONSUME;
   private static final MethodHandle AUTOFUZZ_FUNCTION_1;
   private static final MethodHandle AUTOFUZZ_FUNCTION_2;
@@ -59,12 +35,6 @@
   private static final MethodHandle AUTOFUZZ_CONSUMER_5;
 
   static {
-    Class<?> jazzerInternal = null;
-    MethodHandle onFuzzTargetReady = null;
-    MethodHandle traceStrcmp = null;
-    MethodHandle traceStrstr = null;
-    MethodHandle traceMemcmp = null;
-    MethodHandle tracePcIndir = null;
     MethodHandle consume = null;
     MethodHandle autofuzzFunction1 = null;
     MethodHandle autofuzzFunction2 = null;
@@ -77,30 +47,6 @@
     MethodHandle autofuzzConsumer4 = null;
     MethodHandle autofuzzConsumer5 = null;
     try {
-      jazzerInternal = Class.forName("com.code_intelligence.jazzer.runtime.JazzerInternal");
-      MethodType onFuzzTargetReadyType = MethodType.methodType(void.class, Runnable.class);
-      onFuzzTargetReady = MethodHandles.publicLookup().findStatic(
-          jazzerInternal, "registerOnFuzzTargetReadyCallback", onFuzzTargetReadyType);
-      Class<?> traceDataFlowNativeCallbacks =
-          Class.forName("com.code_intelligence.jazzer.runtime.TraceDataFlowNativeCallbacks");
-
-      // Use method handles for hints as the calls are potentially performance critical.
-      MethodType traceStrcmpType =
-          MethodType.methodType(void.class, String.class, String.class, int.class, int.class);
-      traceStrcmp = MethodHandles.publicLookup().findStatic(
-          traceDataFlowNativeCallbacks, "traceStrcmp", traceStrcmpType);
-      MethodType traceStrstrType =
-          MethodType.methodType(void.class, String.class, String.class, int.class);
-      traceStrstr = MethodHandles.publicLookup().findStatic(
-          traceDataFlowNativeCallbacks, "traceStrstr", traceStrstrType);
-      MethodType traceMemcmpType =
-          MethodType.methodType(void.class, byte[].class, byte[].class, int.class, int.class);
-      traceMemcmp = MethodHandles.publicLookup().findStatic(
-          traceDataFlowNativeCallbacks, "traceMemcmp", traceMemcmpType);
-      MethodType tracePcIndirType = MethodType.methodType(void.class, int.class, int.class);
-      tracePcIndir = MethodHandles.publicLookup().findStatic(
-          traceDataFlowNativeCallbacks, "tracePcIndir", tracePcIndirType);
-
       Class<?> metaClass = Class.forName("com.code_intelligence.jazzer.autofuzz.Meta");
       MethodType consumeType =
           MethodType.methodType(Object.class, FuzzedDataProvider.class, Class.class);
@@ -132,16 +78,12 @@
     } catch (NoSuchMethodException | IllegalAccessException e) {
       // This should never happen as the Jazzer API is loaded from the agent and thus should always
       // match the version of the runtime classes.
+      // Does not use the Log class as it is unlikely it can be loaded if the Autofuzz classes
+      // couldn't be loaded.
       System.err.println("ERROR: Incompatible version of the Jazzer API detected, please update.");
       e.printStackTrace();
       System.exit(1);
     }
-    JAZZER_INTERNAL = jazzerInternal;
-    ON_FUZZ_TARGET_READY = onFuzzTargetReady;
-    TRACE_STRCMP = traceStrcmp;
-    TRACE_STRSTR = traceStrstr;
-    TRACE_MEMCMP = traceMemcmp;
-    TRACE_PC_INDIR = tracePcIndir;
     CONSUME = consume;
     AUTOFUZZ_FUNCTION_1 = autofuzzFunction1;
     AUTOFUZZ_FUNCTION_2 = autofuzzFunction2;
@@ -155,7 +97,7 @@
     AUTOFUZZ_CONSUMER_5 = autofuzzConsumer5;
   }
 
-  private Jazzer() {}
+  private Autofuzz() {}
 
   /**
    * Attempts to invoke {@code func} with arguments created automatically from the fuzzer input
@@ -461,180 +403,6 @@
     }
   }
 
-  /**
-   * Instructs the fuzzer to guide its mutations towards making {@code current} equal to {@code
-   * target}.
-   * <p>
-   * If the relation between the raw fuzzer input and the value of {@code current} is relatively
-   * complex, running the fuzzer with the argument {@code -use_value_profile=1} may be necessary to
-   * achieve equality.
-   *
-   * @param current a non-constant string observed during fuzz target execution
-   * @param target a string that {@code current} should become equal to, but currently isn't
-   * @param id a (probabilistically) unique identifier for this particular compare hint
-   */
-  public static void guideTowardsEquality(String current, String target, int id) {
-    if (TRACE_STRCMP == null) {
-      return;
-    }
-    try {
-      TRACE_STRCMP.invokeExact(current, target, 1, id);
-    } catch (Throwable e) {
-      e.printStackTrace();
-    }
-  }
-
-  /**
-   * Instructs the fuzzer to guide its mutations towards making {@code current} equal to {@code
-   * target}.
-   * <p>
-   * If the relation between the raw fuzzer input and the value of {@code current} is relatively
-   * complex, running the fuzzer with the argument {@code -use_value_profile=1} may be necessary to
-   * achieve equality.
-   *
-   * @param current a non-constant byte array observed during fuzz target execution
-   * @param target a byte array that {@code current} should become equal to, but currently isn't
-   * @param id a (probabilistically) unique identifier for this particular compare hint
-   */
-  public static void guideTowardsEquality(byte[] current, byte[] target, int id) {
-    if (TRACE_MEMCMP == null) {
-      return;
-    }
-    try {
-      TRACE_MEMCMP.invokeExact(current, target, 1, id);
-    } catch (Throwable e) {
-      e.printStackTrace();
-    }
-  }
-
-  /**
-   * Instructs the fuzzer to guide its mutations towards making {@code haystack} contain {@code
-   * needle} as a substring.
-   * <p>
-   * If the relation between the raw fuzzer input and the value of {@code haystack} is relatively
-   * complex, running the fuzzer with the argument {@code -use_value_profile=1} may be necessary to
-   * satisfy the substring check.
-   *
-   * @param haystack a non-constant string observed during fuzz target execution
-   * @param needle a string that should be contained in {@code haystack} as a substring, but
-   *     currently isn't
-   * @param id a (probabilistically) unique identifier for this particular compare hint
-   */
-  public static void guideTowardsContainment(String haystack, String needle, int id) {
-    if (TRACE_STRSTR == null) {
-      return;
-    }
-    try {
-      TRACE_STRSTR.invokeExact(haystack, needle, id);
-    } catch (Throwable e) {
-      e.printStackTrace();
-    }
-  }
-
-  /**
-   * Instructs the fuzzer to attain as many possible values for the absolute value of {@code state}
-   * as possible.
-   * <p>
-   * Call this function from a fuzz target or a hook to help the fuzzer track partial progress
-   * (e.g. by passing the length of a common prefix of two lists that should become equal) or
-   * explore different values of state that is not directly related to code coverage (see the
-   * MazeFuzzer example).
-   * <p>
-   * <b>Note:</b> This hint only takes effect if the fuzzer is run with the argument
-   * {@code -use_value_profile=1}.
-   *
-   * @param state a numeric encoding of a state that should be varied by the fuzzer
-   * @param id a (probabilistically) unique identifier for this particular state hint
-   */
-  public static void exploreState(byte state, int id) {
-    if (TRACE_PC_INDIR == null) {
-      return;
-    }
-    // We only use the lower 7 bits of state, which allows for 128 different state values tracked
-    // per id. The particular amount of 7 bits of state is also used in libFuzzer's
-    // TracePC::HandleCmp:
-    // https://github.com/llvm/llvm-project/blob/c12d49c4e286fa108d4d69f1c6d2b8d691993ffd/compiler-rt/lib/fuzzer/FuzzerTracePC.cpp#L390
-    // This value should be large enough for most use cases (e.g. tracking the length of a prefix in
-    // a comparison) while being small enough that the bitmap isn't filled up too quickly
-    // (65536 bits/ 128 bits per id = 512 ids).
-
-    // We use tracePcIndir as a way to set a bit in libFuzzer's value profile bitmap. In
-    // TracePC::HandleCallerCallee, which is what this function ultimately calls through to, the
-    // lower 12 bits of each argument are combined into a 24-bit index into the bitmap, which is
-    // then reduced modulo a 16-bit prime. To keep the modulo bias small, we should fill as many
-    // of the relevant bits as possible. However, there are the following restrictions:
-    // 1. Since we use the return address trampoline to set the caller address indirectly, its
-    //    upper 3 bits are fixed, which leaves a total of 21 variable bits on x86_64.
-    // 2. On arm64 macOS, where every instruction is aligned to 4 bytes, the lower 2 bits of the
-    //    caller address will always be zero, further reducing the number of variable bits in the
-    //    caller parameter to 7.
-    // https://github.com/llvm/llvm-project/blob/c12d49c4e286fa108d4d69f1c6d2b8d691993ffd/compiler-rt/lib/fuzzer/FuzzerTracePC.cpp#L121
-    // Even taking these restrictions into consideration, we pass state in the lowest bits of the
-    // caller address, which is used to form the lowest bits of the bitmap index. This should result
-    // in the best caching behavior as state is expected to change quickly in consecutive runs and
-    // in this way all its bitmap entries would be located close to each other in memory.
-    int lowerBits = (state & 0x7f) | (id << 7);
-    int upperBits = id >>> 5;
-    try {
-      TRACE_PC_INDIR.invokeExact(upperBits, lowerBits);
-    } catch (Throwable e) {
-      e.printStackTrace();
-    }
-  }
-
-  /**
-   * Make Jazzer report the provided {@link Throwable} as a finding.
-   * <p>
-   * <b>Note:</b> This method must only be called from a method hook. In a
-   * fuzz target, simply throw an exception to trigger a finding.
-   * @param finding the finding that Jazzer should report
-   */
-  public static void reportFindingFromHook(Throwable finding) {
-    try {
-      JAZZER_INTERNAL.getMethod("reportFindingFromHook", Throwable.class).invoke(null, finding);
-    } catch (NullPointerException | IllegalAccessException | NoSuchMethodException e) {
-      // We can only reach this point if the runtime is not on the classpath, e.g. in case of a
-      // reproducer. Just throw the finding.
-      rethrowUnchecked(finding);
-    } catch (InvocationTargetException e) {
-      // reportFindingFromHook throws a HardToCatchThrowable, which will bubble up wrapped in an
-      // InvocationTargetException that should not be stopped here.
-      if (e.getCause().getClass().getName().endsWith(".HardToCatchError")) {
-        throw(Error) e.getCause();
-      } else {
-        e.printStackTrace();
-      }
-    }
-  }
-
-  /**
-   * Register a callback to be executed right before the fuzz target is executed for the first time.
-   * <p>
-   * This can be used to disable hooks until after Jazzer has been fully initializing, e.g. to
-   * prevent Jazzer internals from triggering hooks on Java standard library classes.
-   *
-   * @param callback the callback to execute
-   */
-  public static void onFuzzTargetReady(Runnable callback) {
-    try {
-      ON_FUZZ_TARGET_READY.invokeExact(callback);
-    } catch (Throwable e) {
-      e.printStackTrace();
-    }
-  }
-
-  private static int getLibFuzzerSeed() {
-    // The Jazzer driver sets this property based on the value of libFuzzer's -seed command-line
-    // option, which allows for fully reproducible fuzzing runs if set. If not running in the
-    // context of the driver, fall back to a random number instead.
-    String rawSeed = System.getProperty("jazzer.seed");
-    if (rawSeed == null) {
-      return new SecureRandom().nextInt();
-    }
-    // If jazzer.seed is set, we expect it to be a valid integer.
-    return Integer.parseUnsignedInt(rawSeed);
-  }
-
   // Rethrows a (possibly checked) exception while avoiding a throws declaration.
   @SuppressWarnings("unchecked")
   private static <T extends Throwable> void rethrowUnchecked(Throwable t) throws T {
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/api/AutofuzzConstructionException.java b/src/main/java/com/code_intelligence/jazzer/api/AutofuzzConstructionException.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/api/AutofuzzConstructionException.java
rename to src/main/java/com/code_intelligence/jazzer/api/AutofuzzConstructionException.java
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/api/AutofuzzInvocationException.java b/src/main/java/com/code_intelligence/jazzer/api/AutofuzzInvocationException.java
similarity index 94%
rename from agent/src/main/java/com/code_intelligence/jazzer/api/AutofuzzInvocationException.java
rename to src/main/java/com/code_intelligence/jazzer/api/AutofuzzInvocationException.java
index 7e6203c..eb93663 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/api/AutofuzzInvocationException.java
+++ b/src/main/java/com/code_intelligence/jazzer/api/AutofuzzInvocationException.java
@@ -20,6 +20,10 @@
  * Only used internally.
  */
 public class AutofuzzInvocationException extends RuntimeException {
+  public AutofuzzInvocationException() {
+    super();
+  }
+
   public AutofuzzInvocationException(Throwable cause) {
     super(cause);
   }
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/api/BUILD.bazel b/src/main/java/com/code_intelligence/jazzer/api/BUILD.bazel
similarity index 61%
rename from agent/src/main/java/com/code_intelligence/jazzer/api/BUILD.bazel
rename to src/main/java/com/code_intelligence/jazzer/api/BUILD.bazel
index b26bb84..03ac791 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/api/BUILD.bazel
+++ b/src/main/java/com/code_intelligence/jazzer/api/BUILD.bazel
@@ -1,8 +1,12 @@
+load("@rules_jvm_external//:defs.bzl", "java_export")
+
 java_library(
     name = "api",
     srcs = [
+        "Autofuzz.java",
         "AutofuzzConstructionException.java",
         "AutofuzzInvocationException.java",
+        "BugDetectors.java",
         "CannedFuzzedDataProvider.java",
         "Consumer1.java",
         "Consumer2.java",
@@ -15,6 +19,24 @@
         "Function4.java",
         "Function5.java",
         "FuzzedDataProvider.java",
+        "SilentCloseable.java",
+    ],
+    visibility = ["//visibility:public"],
+    runtime_deps = [
+        ":hooks",
+    ],
+)
+
+java_binary(
+    name = "api_deploy_env",
+    create_executable = False,
+    visibility = ["//src/main/java/com/code_intelligence/jazzer:__pkg__"],
+    runtime_deps = [":api"],
+)
+
+java_library(
+    name = "hooks",
+    srcs = [
         "FuzzerSecurityIssueCritical.java",
         "FuzzerSecurityIssueHigh.java",
         "FuzzerSecurityIssueLow.java",
@@ -23,7 +45,7 @@
         "Jazzer.java",
         "MethodHook.java",
         "MethodHooks.java",
-        "//agent/src/main/java/jaz",
+        "//src/main/java/jaz",
     ],
     visibility = ["//visibility:public"],
 )
diff --git a/src/main/java/com/code_intelligence/jazzer/api/BugDetectors.java b/src/main/java/com/code_intelligence/jazzer/api/BugDetectors.java
new file mode 100644
index 0000000..64f0193
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/api/BugDetectors.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.api;
+
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BiPredicate;
+
+/**
+ * Provides static functions that configure the behavior of bug detectors provided by Jazzer.
+ */
+public final class BugDetectors {
+  private static final AtomicReference<BiPredicate<String, Integer>> currentPolicy =
+      getConnectionPermittedReference();
+
+  /**
+   * Allows all network connections.
+   *
+   * <p>See {@link #allowNetworkConnections(BiPredicate)} for an alternative that provides
+   * fine-grained control over which network connections are expected.
+   *
+   * <p>By default, all attempted network connections are considered unexpected and result in a
+   * finding being reported.
+   *
+   * <p>By wrapping the call into a try-with-resources statement, network connection permissions
+   * can be configured to apply to individual parts of the fuzz test only:
+   *
+   * <pre>{@code
+   *   Image image = parseImage(bytes);
+   *   Response response;
+   *   try (SilentCloseable unused = BugDetectors.allowNetworkConnections()) {
+   *     response = uploadImage(image);
+   *   }
+   *   handleResponse(response);
+   * }</pre>
+   *
+   * @return a {@link SilentCloseable} that restores the previously set permissions when closed
+   */
+  public static SilentCloseable allowNetworkConnections() {
+    return allowNetworkConnections((host, port) -> true);
+  }
+
+  /**
+   * Allows all network connections for which the provided predicate returns {@code true}.
+   *
+   * <p>By default, all attempted network connections are considered unexpected and result in a
+   * finding being reported.
+   *
+   * <p>By wrapping the call into a try-with-resources statement, network connection permissions
+   * can be configured to apply to individual parts of the fuzz test only:
+   *
+   * <pre>{@code
+   *   Image image = parseImage(bytes);
+   *   Response response;
+   *   try (SilentCloseable unused = BugDetectors.allowNetworkConnections(
+   *       (host, port) -> host.equals("example.org"))) {
+   *     response = uploadImage(image, "example.org");
+   *   }
+   *   handleResponse(response);
+   * }</pre>
+   *
+   * @param connectionPermitted a predicate that evaluate to {@code true} if network connections to
+   *                            the provided combination of host and port are permitted
+   * @return a {@link SilentCloseable} that restores the previously set predicate when closed
+   */
+  public static SilentCloseable allowNetworkConnections(
+      BiPredicate<String, Integer> connectionPermitted) {
+    if (connectionPermitted == null) {
+      throw new IllegalArgumentException("connectionPermitted must not be null");
+    }
+    if (currentPolicy == null) {
+      throw new IllegalStateException("Failed to set network connection policy");
+    }
+    BiPredicate<String, Integer> previousPolicy = currentPolicy.getAndSet(connectionPermitted);
+    return () -> {
+      if (!currentPolicy.compareAndSet(connectionPermitted, previousPolicy)) {
+        throw new IllegalStateException(
+            "Failed to reset network connection policy - using try-with-resources is highly recommended");
+      }
+    };
+  }
+
+  private static AtomicReference<BiPredicate<String, Integer>> getConnectionPermittedReference() {
+    try {
+      Class<?> ssrfSanitizer =
+          Class.forName("com.code_intelligence.jazzer.sanitizers.ServerSideRequestForgery");
+      return (AtomicReference<BiPredicate<String, Integer>>) ssrfSanitizer
+          .getField("connectionPermitted")
+          .get(null);
+    } catch (ClassNotFoundException | NoSuchFieldException | IllegalAccessException e) {
+      System.err.println("WARNING: ");
+      e.printStackTrace();
+      return null;
+    }
+  }
+
+  private BugDetectors() {}
+}
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/api/CannedFuzzedDataProvider.java b/src/main/java/com/code_intelligence/jazzer/api/CannedFuzzedDataProvider.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/api/CannedFuzzedDataProvider.java
rename to src/main/java/com/code_intelligence/jazzer/api/CannedFuzzedDataProvider.java
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/api/Consumer1.java b/src/main/java/com/code_intelligence/jazzer/api/Consumer1.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/api/Consumer1.java
rename to src/main/java/com/code_intelligence/jazzer/api/Consumer1.java
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/api/Consumer2.java b/src/main/java/com/code_intelligence/jazzer/api/Consumer2.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/api/Consumer2.java
rename to src/main/java/com/code_intelligence/jazzer/api/Consumer2.java
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/api/Consumer3.java b/src/main/java/com/code_intelligence/jazzer/api/Consumer3.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/api/Consumer3.java
rename to src/main/java/com/code_intelligence/jazzer/api/Consumer3.java
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/api/Consumer4.java b/src/main/java/com/code_intelligence/jazzer/api/Consumer4.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/api/Consumer4.java
rename to src/main/java/com/code_intelligence/jazzer/api/Consumer4.java
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/api/Consumer5.java b/src/main/java/com/code_intelligence/jazzer/api/Consumer5.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/api/Consumer5.java
rename to src/main/java/com/code_intelligence/jazzer/api/Consumer5.java
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/api/Function1.java b/src/main/java/com/code_intelligence/jazzer/api/Function1.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/api/Function1.java
rename to src/main/java/com/code_intelligence/jazzer/api/Function1.java
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/api/Function2.java b/src/main/java/com/code_intelligence/jazzer/api/Function2.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/api/Function2.java
rename to src/main/java/com/code_intelligence/jazzer/api/Function2.java
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/api/Function3.java b/src/main/java/com/code_intelligence/jazzer/api/Function3.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/api/Function3.java
rename to src/main/java/com/code_intelligence/jazzer/api/Function3.java
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/api/Function4.java b/src/main/java/com/code_intelligence/jazzer/api/Function4.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/api/Function4.java
rename to src/main/java/com/code_intelligence/jazzer/api/Function4.java
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/api/Function5.java b/src/main/java/com/code_intelligence/jazzer/api/Function5.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/api/Function5.java
rename to src/main/java/com/code_intelligence/jazzer/api/Function5.java
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/api/FuzzedDataProvider.java b/src/main/java/com/code_intelligence/jazzer/api/FuzzedDataProvider.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/api/FuzzedDataProvider.java
rename to src/main/java/com/code_intelligence/jazzer/api/FuzzedDataProvider.java
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/api/FuzzerSecurityIssueCritical.java b/src/main/java/com/code_intelligence/jazzer/api/FuzzerSecurityIssueCritical.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/api/FuzzerSecurityIssueCritical.java
rename to src/main/java/com/code_intelligence/jazzer/api/FuzzerSecurityIssueCritical.java
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/api/FuzzerSecurityIssueHigh.java b/src/main/java/com/code_intelligence/jazzer/api/FuzzerSecurityIssueHigh.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/api/FuzzerSecurityIssueHigh.java
rename to src/main/java/com/code_intelligence/jazzer/api/FuzzerSecurityIssueHigh.java
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/api/FuzzerSecurityIssueLow.java b/src/main/java/com/code_intelligence/jazzer/api/FuzzerSecurityIssueLow.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/api/FuzzerSecurityIssueLow.java
rename to src/main/java/com/code_intelligence/jazzer/api/FuzzerSecurityIssueLow.java
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/api/FuzzerSecurityIssueMedium.java b/src/main/java/com/code_intelligence/jazzer/api/FuzzerSecurityIssueMedium.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/api/FuzzerSecurityIssueMedium.java
rename to src/main/java/com/code_intelligence/jazzer/api/FuzzerSecurityIssueMedium.java
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/api/HookType.java b/src/main/java/com/code_intelligence/jazzer/api/HookType.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/api/HookType.java
rename to src/main/java/com/code_intelligence/jazzer/api/HookType.java
diff --git a/src/main/java/com/code_intelligence/jazzer/api/Jazzer.java b/src/main/java/com/code_intelligence/jazzer/api/Jazzer.java
new file mode 100644
index 0000000..aad9ae0
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/api/Jazzer.java
@@ -0,0 +1,268 @@
+// Copyright 2021 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.api;
+
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+import java.lang.reflect.InvocationTargetException;
+import java.security.SecureRandom;
+
+/**
+ * Static helper methods that hooks can use to provide feedback to the fuzzer.
+ */
+public final class Jazzer {
+  private static final Class<?> JAZZER_INTERNAL;
+
+  private static final MethodHandle ON_FUZZ_TARGET_READY;
+
+  private static final MethodHandle TRACE_STRCMP;
+  private static final MethodHandle TRACE_STRSTR;
+  private static final MethodHandle TRACE_MEMCMP;
+  private static final MethodHandle TRACE_PC_INDIR;
+
+  static {
+    Class<?> jazzerInternal = null;
+    MethodHandle onFuzzTargetReady = null;
+    MethodHandle traceStrcmp = null;
+    MethodHandle traceStrstr = null;
+    MethodHandle traceMemcmp = null;
+    MethodHandle tracePcIndir = null;
+    try {
+      jazzerInternal = Class.forName("com.code_intelligence.jazzer.runtime.JazzerInternal");
+      MethodType onFuzzTargetReadyType = MethodType.methodType(void.class, Runnable.class);
+      onFuzzTargetReady = MethodHandles.publicLookup().findStatic(
+          jazzerInternal, "registerOnFuzzTargetReadyCallback", onFuzzTargetReadyType);
+      Class<?> traceDataFlowNativeCallbacks =
+          Class.forName("com.code_intelligence.jazzer.runtime.TraceDataFlowNativeCallbacks");
+
+      // Use method handles for hints as the calls are potentially performance critical.
+      MethodType traceStrcmpType =
+          MethodType.methodType(void.class, String.class, String.class, int.class, int.class);
+      traceStrcmp = MethodHandles.publicLookup().findStatic(
+          traceDataFlowNativeCallbacks, "traceStrcmp", traceStrcmpType);
+      MethodType traceStrstrType =
+          MethodType.methodType(void.class, String.class, String.class, int.class);
+      traceStrstr = MethodHandles.publicLookup().findStatic(
+          traceDataFlowNativeCallbacks, "traceStrstr", traceStrstrType);
+      MethodType traceMemcmpType =
+          MethodType.methodType(void.class, byte[].class, byte[].class, int.class, int.class);
+      traceMemcmp = MethodHandles.publicLookup().findStatic(
+          traceDataFlowNativeCallbacks, "traceMemcmp", traceMemcmpType);
+      MethodType tracePcIndirType = MethodType.methodType(void.class, int.class, int.class);
+      tracePcIndir = MethodHandles.publicLookup().findStatic(
+          traceDataFlowNativeCallbacks, "tracePcIndir", tracePcIndirType);
+    } catch (ClassNotFoundException ignore) {
+      // Not running in the context of the agent. This is fine as long as no methods are called on
+      // this class.
+    } catch (NoSuchMethodException | IllegalAccessException e) {
+      // This should never happen as the Jazzer API is loaded from the agent and thus should always
+      // match the version of the runtime classes.
+      System.err.println("ERROR: Incompatible version of the Jazzer API detected, please update.");
+      e.printStackTrace();
+      System.exit(1);
+    }
+    JAZZER_INTERNAL = jazzerInternal;
+    ON_FUZZ_TARGET_READY = onFuzzTargetReady;
+    TRACE_STRCMP = traceStrcmp;
+    TRACE_STRSTR = traceStrstr;
+    TRACE_MEMCMP = traceMemcmp;
+    TRACE_PC_INDIR = tracePcIndir;
+  }
+
+  private Jazzer() {}
+
+  /**
+   * A 32-bit random number that hooks can use to make pseudo-random choices
+   * between multiple possible mutations they could guide the fuzzer towards.
+   * Hooks <b>must not</b> base the decision whether or not to report a finding
+   * on this number as this will make findings non-reproducible.
+   * <p>
+   * This is the same number that libFuzzer uses as a seed internally, which
+   * makes it possible to deterministically reproduce a previous fuzzing run by
+   * supplying the seed value printed by libFuzzer as the value of the
+   * {@code -seed}.
+   */
+  public static final int SEED = getLibFuzzerSeed();
+
+  /**
+   * Instructs the fuzzer to guide its mutations towards making {@code current} equal to {@code
+   * target}.
+   * <p>
+   * If the relation between the raw fuzzer input and the value of {@code current} is relatively
+   * complex, running the fuzzer with the argument {@code -use_value_profile=1} may be necessary to
+   * achieve equality.
+   *
+   * @param current a non-constant string observed during fuzz target execution
+   * @param target a string that {@code current} should become equal to, but currently isn't
+   * @param id a (probabilistically) unique identifier for this particular compare hint
+   */
+  public static void guideTowardsEquality(String current, String target, int id) {
+    if (TRACE_STRCMP == null) {
+      return;
+    }
+    try {
+      TRACE_STRCMP.invokeExact(current, target, 1, id);
+    } catch (Throwable e) {
+      e.printStackTrace();
+    }
+  }
+
+  /**
+   * Instructs the fuzzer to guide its mutations towards making {@code current} equal to {@code
+   * target}.
+   * <p>
+   * If the relation between the raw fuzzer input and the value of {@code current} is relatively
+   * complex, running the fuzzer with the argument {@code -use_value_profile=1} may be necessary to
+   * achieve equality.
+   *
+   * @param current a non-constant byte array observed during fuzz target execution
+   * @param target a byte array that {@code current} should become equal to, but currently isn't
+   * @param id a (probabilistically) unique identifier for this particular compare hint
+   */
+  public static void guideTowardsEquality(byte[] current, byte[] target, int id) {
+    if (TRACE_MEMCMP == null) {
+      return;
+    }
+    try {
+      TRACE_MEMCMP.invokeExact(current, target, 1, id);
+    } catch (Throwable e) {
+      e.printStackTrace();
+    }
+  }
+
+  /**
+   * Instructs the fuzzer to guide its mutations towards making {@code haystack} contain {@code
+   * needle} as a substring.
+   * <p>
+   * If the relation between the raw fuzzer input and the value of {@code haystack} is relatively
+   * complex, running the fuzzer with the argument {@code -use_value_profile=1} may be necessary to
+   * satisfy the substring check.
+   *
+   * @param haystack a non-constant string observed during fuzz target execution
+   * @param needle a string that should be contained in {@code haystack} as a substring, but
+   *     currently isn't
+   * @param id a (probabilistically) unique identifier for this particular compare hint
+   */
+  public static void guideTowardsContainment(String haystack, String needle, int id) {
+    if (TRACE_STRSTR == null) {
+      return;
+    }
+    try {
+      TRACE_STRSTR.invokeExact(haystack, needle, id);
+    } catch (Throwable e) {
+      e.printStackTrace();
+    }
+  }
+
+  /**
+   * Instructs the fuzzer to attain as many possible values for the absolute value of {@code state}
+   * as possible.
+   * <p>
+   * Call this function from a fuzz target or a hook to help the fuzzer track partial progress
+   * (e.g. by passing the length of a common prefix of two lists that should become equal) or
+   * explore different values of state that is not directly related to code coverage (see the
+   * MazeFuzzer example).
+   * <p>
+   * <b>Note:</b> This hint only takes effect if the fuzzer is run with the argument
+   * {@code -use_value_profile=1}.
+   *
+   * @param state a numeric encoding of a state that should be varied by the fuzzer
+   * @param id a (probabilistically) unique identifier for this particular state hint
+   */
+  public static void exploreState(byte state, int id) {
+    if (TRACE_PC_INDIR == null) {
+      return;
+    }
+    // We only use the lower 7 bits of state, which allows for 128 different state values tracked
+    // per id. The particular amount of 7 bits of state is also used in libFuzzer's
+    // TracePC::HandleCmp:
+    // https://github.com/llvm/llvm-project/blob/c12d49c4e286fa108d4d69f1c6d2b8d691993ffd/compiler-rt/lib/fuzzer/FuzzerTracePC.cpp#L390
+    // This value should be large enough for most use cases (e.g. tracking the length of a prefix in
+    // a comparison) while being small enough that the bitmap isn't filled up too quickly
+    // (65536 bits / 128 bits per id = 512 ids).
+
+    // We use tracePcIndir as a way to set a bit in libFuzzer's value profile bitmap. In
+    // TracePC::HandleCallerCallee, which is what this function ultimately calls through to, the
+    // lower 12 bits of each argument are combined into a 24-bit index into the bitmap, which is
+    // then reduced modulo a 16-bit prime. To keep the modulo bias small, we should fill as many
+    // of the relevant bits as possible.
+
+    // We pass state in the lowest bits of the caller address, which is used to form the lowest bits
+    // of the bitmap index. This should result in the best caching behavior as state is expected to
+    // change quickly in consecutive runs and in this way all its bitmap entries would be located
+    // close to each other in memory.
+    int lowerBits = (state & 0x7f) | (id << 7);
+    int upperBits = id >>> 5;
+    try {
+      TRACE_PC_INDIR.invokeExact(upperBits, lowerBits);
+    } catch (Throwable e) {
+      e.printStackTrace();
+    }
+  }
+
+  /**
+   * Make Jazzer report the provided {@link Throwable} as a finding.
+   * <p>
+   * <b>Note:</b> This method must only be called from a method hook. In a
+   * fuzz target, simply throw an exception to trigger a finding.
+   * @param finding the finding that Jazzer should report
+   */
+  public static void reportFindingFromHook(Throwable finding) {
+    try {
+      JAZZER_INTERNAL.getMethod("reportFindingFromHook", Throwable.class).invoke(null, finding);
+    } catch (NullPointerException | IllegalAccessException | NoSuchMethodException e) {
+      // We can only reach this point if the runtime is not on the classpath, e.g. in case of a
+      // reproducer. Just throw the finding.
+      rethrowUnchecked(finding);
+    } catch (InvocationTargetException e) {
+      rethrowUnchecked(e.getCause());
+    }
+  }
+
+  /**
+   * Register a callback to be executed right before the fuzz target is executed for the first time.
+   * <p>
+   * This can be used to disable hooks until after Jazzer has been fully initializing, e.g. to
+   * prevent Jazzer internals from triggering hooks on Java standard library classes.
+   *
+   * @param callback the callback to execute
+   */
+  public static void onFuzzTargetReady(Runnable callback) {
+    try {
+      ON_FUZZ_TARGET_READY.invokeExact(callback);
+    } catch (Throwable e) {
+      e.printStackTrace();
+    }
+  }
+
+  private static int getLibFuzzerSeed() {
+    // The Jazzer driver sets this property based on the value of libFuzzer's -seed command-line
+    // option, which allows for fully reproducible fuzzing runs if set. If not running in the
+    // context of the driver, fall back to a random number instead.
+    String rawSeed = System.getProperty("jazzer.internal.seed");
+    if (rawSeed == null) {
+      return new SecureRandom().nextInt();
+    }
+    // If jazzer.internal.seed is set, we expect it to be a valid integer.
+    return Integer.parseUnsignedInt(rawSeed);
+  }
+
+  // Rethrows a (possibly checked) exception while avoiding a throws declaration.
+  @SuppressWarnings("unchecked")
+  private static <T extends Throwable> void rethrowUnchecked(Throwable t) throws T {
+    throw(T) t;
+  }
+}
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/api/MethodHook.java b/src/main/java/com/code_intelligence/jazzer/api/MethodHook.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/api/MethodHook.java
rename to src/main/java/com/code_intelligence/jazzer/api/MethodHook.java
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/api/MethodHooks.java b/src/main/java/com/code_intelligence/jazzer/api/MethodHooks.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/api/MethodHooks.java
rename to src/main/java/com/code_intelligence/jazzer/api/MethodHooks.java
diff --git a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h b/src/main/java/com/code_intelligence/jazzer/api/SilentCloseable.java
similarity index 60%
copy from driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
copy to src/main/java/com/code_intelligence/jazzer/api/SilentCloseable.java
index 0e8846c..3f2d6e3 100644
--- a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
+++ b/src/main/java/com/code_intelligence/jazzer/api/SilentCloseable.java
@@ -1,11 +1,11 @@
 /*
- * Copyright 2021 Code Intelligence GmbH
+ * Copyright 2023 Code Intelligence GmbH
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
  *
- *      http://www.apache.org/licenses/LICENSE-2.0
+ *     http://www.apache.org/licenses/LICENSE-2.0
  *
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS,
@@ -14,15 +14,12 @@
  * limitations under the License.
  */
 
-#pragma once
+package com.code_intelligence.jazzer.api;
 
-#include <jni.h>
-
-namespace jazzer {
-/*
- * Print the stack traces of all active JVM threads.
- *
- * This function can be called from any thread.
+/**
+ * A specialization of {@link AutoCloseable} without a {@code throws} declarations on
+ * {@link #close()}.
  */
-void DumpJvmStackTraces();
-}  // namespace jazzer
+public interface SilentCloseable extends AutoCloseable {
+  @Override void close();
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/autofuzz/AccessibleObjectLookup.java b/src/main/java/com/code_intelligence/jazzer/autofuzz/AccessibleObjectLookup.java
new file mode 100644
index 0000000..a45a474
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/autofuzz/AccessibleObjectLookup.java
@@ -0,0 +1,147 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.autofuzz;
+
+import io.github.classgraph.ClassInfo;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Executable;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.stream.Stream;
+
+class AccessibleObjectLookup {
+  private static final Comparator<Class<?>> STABLE_CLASS_COMPARATOR =
+      Comparator.comparing(Class::getName);
+  private static final Comparator<Executable> STABLE_EXECUTABLE_COMPARATOR =
+      Comparator.comparing(Executable::getName).thenComparing(executable -> {
+        if (executable instanceof Method) {
+          return org.objectweb.asm.Type.getMethodDescriptor((Method) executable);
+        } else {
+          return org.objectweb.asm.Type.getConstructorDescriptor((Constructor<?>) executable);
+        }
+      });
+
+  private final Class<?> referenceClass;
+
+  public AccessibleObjectLookup(Class<?> referenceClass) {
+    this.referenceClass = referenceClass;
+  }
+
+  Class<?>[] getAccessibleClasses(Class<?> type) {
+    return Stream.concat(Arrays.stream(type.getDeclaredClasses()), Arrays.stream(type.getClasses()))
+        .distinct()
+        .filter(this::isAccessible)
+        .sorted(STABLE_CLASS_COMPARATOR)
+        .toArray(Class<?>[] ::new);
+  }
+
+  Constructor<?>[] getAccessibleConstructors(Class<?> type) {
+    // Neither of getDeclaredConstructors and getConstructors is a superset of the other: While
+    // getDeclaredConstructors returns constructors with all visibility modifiers, it does not
+    // return the implicit default constructor.
+    return Stream
+        .concat(
+            Arrays.stream(type.getDeclaredConstructors()), Arrays.stream(type.getConstructors()))
+        .distinct()
+        .filter(this::isAccessible)
+        .sorted(STABLE_EXECUTABLE_COMPARATOR)
+        .filter(constructor -> {
+          try {
+            constructor.setAccessible(true);
+            return true;
+          } catch (Exception e) {
+            // Can't make the constructor accessible, e.g. because it is in a standard library
+            // module. We can't do anything about this, so we skip the constructor.
+            return false;
+          }
+        })
+        .toArray(Constructor<?>[] ::new);
+  }
+
+  Method[] getAccessibleMethods(Class<?> type) {
+    return Stream.concat(Arrays.stream(type.getDeclaredMethods()), Arrays.stream(type.getMethods()))
+        .distinct()
+        .filter(this::isAccessible)
+        .sorted(STABLE_EXECUTABLE_COMPARATOR)
+        .filter(method -> {
+          try {
+            method.setAccessible(true);
+            return true;
+          } catch (Exception e) {
+            // Can't make the method accessible, e.g. because it is in a standard library module. We
+            // can't do anything about this, so we skip the method.
+            return false;
+          }
+        })
+        .toArray(Method[] ::new);
+  }
+
+  boolean isAccessible(Class<?> clazz, int modifiers) {
+    if (Modifier.isPublic(modifiers)) {
+      return true;
+    }
+    if (referenceClass == null) {
+      return false;
+    }
+    if (Modifier.isPrivate(modifiers)) {
+      return clazz.equals(referenceClass);
+    }
+    if (Modifier.isProtected(modifiers)) {
+      return clazz.isAssignableFrom(referenceClass);
+    }
+    // No visibility modifiers implies default visibility, which means visible in the same package.
+    return clazz.getPackage().equals(referenceClass.getPackage());
+  }
+
+  boolean isAccessible(ClassInfo clazz, int modifiers) {
+    if (Modifier.isPublic(modifiers)) {
+      return true;
+    }
+    if (referenceClass == null) {
+      return false;
+    }
+    if (Modifier.isPrivate(modifiers)) {
+      return clazz.getName().equals(referenceClass.getName());
+    }
+    if (Modifier.isProtected(modifiers)) {
+      return isAssignableFrom(clazz, referenceClass);
+    }
+    // No visibility modifiers implies default visibility, which means visible in the same package.
+    return clazz.getPackageName().equals(referenceClass.getPackage().getName());
+  }
+
+  boolean isAssignableFrom(ClassInfo clazz, Class<?> potentialSubclass) {
+    if (potentialSubclass.getName().equals(clazz.getName())) {
+      return true;
+    }
+    if (potentialSubclass.equals(Object.class)) {
+      return clazz.getName().equals(Object.class.getName());
+    }
+    if (potentialSubclass.getSuperclass() == null) {
+      return false;
+    }
+    return isAssignableFrom(clazz, potentialSubclass.getSuperclass());
+  }
+
+  private boolean isAccessible(Executable executable) {
+    return isAccessible(executable.getDeclaringClass(), executable.getModifiers());
+  }
+
+  private boolean isAccessible(Class<?> clazz) {
+    return isAccessible(clazz, clazz.getModifiers());
+  }
+}
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/autofuzz/AutofuzzCodegenVisitor.java b/src/main/java/com/code_intelligence/jazzer/autofuzz/AutofuzzCodegenVisitor.java
similarity index 94%
rename from agent/src/main/java/com/code_intelligence/jazzer/autofuzz/AutofuzzCodegenVisitor.java
rename to src/main/java/com/code_intelligence/jazzer/autofuzz/AutofuzzCodegenVisitor.java
index 2fbed97..2ea4e9b 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/autofuzz/AutofuzzCodegenVisitor.java
+++ b/src/main/java/com/code_intelligence/jazzer/autofuzz/AutofuzzCodegenVisitor.java
@@ -69,18 +69,17 @@
     return String.format("autofuzzVariable%s", variableCounter++);
   }
 
-  private String escapeForLiteral(String string) {
+  static String escapeForLiteral(String string) {
     // The list of escape sequences is taken from:
     // https://docs.oracle.com/javase/tutorial/java/data/characters.html
-    return string.replace("\t", "\\t")
+    return string.replace("\\", "\\\\")
+        .replace("\t", "\\t")
         .replace("\b", "\\b")
         .replace("\n", "\\n")
         .replace("\r", "\\r")
         .replace("\f", "\\f")
-        .replace("\f", "\\f")
         .replace("\"", "\\\"")
-        .replace("'", "\\'")
-        .replace("\\", "\\\\");
+        .replace("'", "\\'");
   }
 
   private String toDebugString() {
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/autofuzz/AutofuzzError.java b/src/main/java/com/code_intelligence/jazzer/autofuzz/AutofuzzError.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/autofuzz/AutofuzzError.java
rename to src/main/java/com/code_intelligence/jazzer/autofuzz/AutofuzzError.java
diff --git a/src/main/java/com/code_intelligence/jazzer/autofuzz/BUILD.bazel b/src/main/java/com/code_intelligence/jazzer/autofuzz/BUILD.bazel
new file mode 100644
index 0000000..04a076e
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/autofuzz/BUILD.bazel
@@ -0,0 +1,22 @@
+java_library(
+    name = "autofuzz",
+    srcs = [
+        "AccessibleObjectLookup.java",
+        "AutofuzzCodegenVisitor.java",
+        "AutofuzzError.java",
+        "FuzzTarget.java",
+        "Meta.java",
+        "YourAverageJavaClass.java",
+    ],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/api",
+        "//src/main/java/com/code_intelligence/jazzer/runtime:jazzer_bootstrap_compile_only",
+        "//src/main/java/com/code_intelligence/jazzer/utils",
+        "//src/main/java/com/code_intelligence/jazzer/utils:log",
+        "//src/main/java/com/code_intelligence/jazzer/utils:simple_glob_matcher",
+        "@com_github_classgraph_classgraph//:classgraph",
+        "@com_github_jhalterman_typetools//:typetools",
+        "@org_ow2_asm_asm//jar",
+    ],
+)
diff --git a/src/main/java/com/code_intelligence/jazzer/autofuzz/FuzzTarget.java b/src/main/java/com/code_intelligence/jazzer/autofuzz/FuzzTarget.java
new file mode 100644
index 0000000..885ebfb
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/autofuzz/FuzzTarget.java
@@ -0,0 +1,363 @@
+// Copyright 2023 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.autofuzz;
+
+import com.code_intelligence.jazzer.api.AutofuzzConstructionException;
+import com.code_intelligence.jazzer.api.AutofuzzInvocationException;
+import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import com.code_intelligence.jazzer.utils.Log;
+import com.code_intelligence.jazzer.utils.SimpleGlobMatcher;
+import com.code_intelligence.jazzer.utils.Utils;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Executable;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+public final class FuzzTarget {
+  private static final String AUTOFUZZ_REPRODUCER_TEMPLATE = "public class Crash_%1$s {\n"
+      + "  public static void main(String[] args) throws Throwable {\n"
+      + "    Crash_%1$s.class.getClassLoader().setDefaultAssertionStatus(true);\n"
+      + "    %2$s;\n"
+      + "  }\n"
+      + "}";
+  private static final long MAX_EXECUTIONS_WITHOUT_INVOCATION = 100;
+
+  private static Meta meta;
+  private static String methodReference;
+  private static Executable[] targetExecutables;
+  private static Object targetInstance;
+  private static Map<Executable, Class<?>[]> throwsDeclarations;
+  private static Set<SimpleGlobMatcher> ignoredExceptionMatchers;
+  private static long executionsSinceLastInvocation = 0;
+
+  public static void fuzzerInitialize(String[] args) {
+    if (args.length == 0 || !args[0].contains("::")) {
+      Log.error(
+          "Expected the argument to --autofuzz to be a method reference (e.g. System.out::println)");
+      System.exit(1);
+    }
+    String methodSignature = args[0];
+    String[] parts = methodSignature.split("::", 2);
+    String className = parts[0];
+    String methodNameAndOptionalDescriptor = parts[1];
+    String methodName;
+    String descriptor;
+    int descriptorStart = methodNameAndOptionalDescriptor.indexOf('(');
+    if (descriptorStart != -1) {
+      methodName = methodNameAndOptionalDescriptor.substring(0, descriptorStart);
+      // URL decode the descriptor to allow copy-pasting from javadoc links such as:
+      // https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/lang/String.html#valueOf(char%5B%5D)
+      try {
+        descriptor =
+            URLDecoder.decode(methodNameAndOptionalDescriptor.substring(descriptorStart), "UTF-8");
+      } catch (UnsupportedEncodingException e) {
+        // UTF-8 is always supported.
+        Log.error(e);
+        System.exit(1);
+        return;
+      }
+    } else {
+      methodName = methodNameAndOptionalDescriptor;
+      descriptor = null;
+    }
+
+    Class<?> targetClassTemp = null;
+    String targetClassName = className;
+    do {
+      try {
+        targetClassTemp = Class.forName(targetClassName);
+      } catch (ClassNotFoundException e) {
+        int classSeparatorIndex = targetClassName.lastIndexOf(".");
+        if (classSeparatorIndex == -1) {
+          Log.error(String.format(
+              "Failed to find class %s for autofuzz, please ensure it is contained in the classpath specified with --cp and specify the full package name",
+              className));
+          System.exit(1);
+          return;
+        }
+        StringBuilder classNameBuilder = new StringBuilder(targetClassName);
+        classNameBuilder.setCharAt(classSeparatorIndex, '$');
+        targetClassName = classNameBuilder.toString();
+      }
+    } while (targetClassTemp == null);
+    final Class<?> targetClass = targetClassTemp;
+
+    AccessibleObjectLookup lookup = new AccessibleObjectLookup(targetClass);
+
+    Executable[] executables;
+    boolean isConstructor = methodName.equals("new");
+    // We filter out inherited methods, which can lead to unexpected results when autofuzzing a
+    // method by name without a descriptor. If desired, these can be autofuzzed explicitly by
+    // referencing the parent class. If a descriptor is provided, we also allow fuzzing non-public
+    // methods. This is necessary e.g. when using Autofuzz on a package-private JUnit @FuzzTest
+    // method.
+    if (isConstructor) {
+      executables = Arrays.stream(lookup.getAccessibleConstructors(targetClass))
+                        .filter(constructor -> constructor.getDeclaringClass().equals(targetClass))
+                        .filter(constructor
+                            -> (descriptor == null && Modifier.isPublic(constructor.getModifiers()))
+                                || Utils.getReadableDescriptor(constructor).equals(descriptor))
+                        .toArray(Executable[] ::new);
+    } else {
+      executables = Arrays.stream(lookup.getAccessibleMethods(targetClass))
+                        .filter(method -> method.getDeclaringClass().equals(targetClass))
+                        .filter(method
+                            -> method.getName().equals(methodName)
+                                && ((descriptor == null && Modifier.isPublic(method.getModifiers()))
+                                    || Utils.getReadableDescriptor(method).equals(descriptor)))
+                        .toArray(Executable[] ::new);
+    }
+    if (executables.length == 0) {
+      if (isConstructor) {
+        if (descriptor == null) {
+          Log.error(
+              String.format("Failed to find constructors in class %s for autofuzz.%n", className));
+        } else {
+          Log.error(String.format(
+              "Failed to find constructors with signature %s in class %s for autofuzz.%n"
+                  + "Public constructors declared by the class:%n%s",
+              descriptor, className,
+              Arrays.stream(lookup.getAccessibleConstructors(targetClass))
+                  .filter(constructor -> Modifier.isPublic(constructor.getModifiers()))
+                  .filter(constructor -> constructor.getDeclaringClass().equals(targetClass))
+                  .map(method
+                      -> String.format("%s::new%s", method.getDeclaringClass().getName(),
+                          Utils.getReadableDescriptor(method)))
+                  .distinct()
+                  .collect(Collectors.joining(System.lineSeparator()))));
+        }
+      } else {
+        if (descriptor == null) {
+          Log.error(String.format("Failed to find methods named %s in class %s for autofuzz.%n"
+                  + "Public methods declared by the class:%n%s",
+              methodName, className,
+              Arrays.stream(lookup.getAccessibleMethods(targetClass))
+                  .filter(method -> Modifier.isPublic(method.getModifiers()))
+                  .filter(method -> method.getDeclaringClass().equals(targetClass))
+                  .map(method
+                      -> String.format(
+                          "%s::%s", method.getDeclaringClass().getName(), method.getName()))
+                  .distinct()
+                  .collect(Collectors.joining(System.lineSeparator()))));
+        } else {
+          Log.error(String.format(
+              "Failed to find public methods named %s with signature %s in class %s for autofuzz.%n"
+                  + "Public methods with that name:%n%s",
+              methodName, descriptor, className,
+              Arrays.stream(lookup.getAccessibleMethods(targetClass))
+                  .filter(method -> Modifier.isPublic(method.getModifiers()))
+                  .filter(method -> method.getDeclaringClass().equals(targetClass))
+                  .filter(method -> method.getName().equals(methodName))
+                  .map(method
+                      -> String.format("%s::%s%s", method.getDeclaringClass().getName(),
+                          method.getName(), Utils.getReadableDescriptor(method)))
+                  .distinct()
+                  .collect(Collectors.joining(System.lineSeparator()))));
+        }
+      }
+      System.exit(1);
+    }
+
+    Set<SimpleGlobMatcher> ignoredExceptionGlobMatchers = Arrays.stream(args)
+                                                              .skip(1)
+                                                              .filter(s -> s.contains("*"))
+                                                              .map(SimpleGlobMatcher::new)
+                                                              .collect(Collectors.toSet());
+
+    List<Class<?>> alwaysIgnore =
+        Arrays.stream(args)
+            .skip(1)
+            .filter(s -> !s.contains("*"))
+            .map(name -> {
+              try {
+                return ClassLoader.getSystemClassLoader().loadClass(name);
+              } catch (ClassNotFoundException e) {
+                Log.error(String.format(
+                    "Failed to find class '%s' specified in --autofuzz_ignore", name));
+                System.exit(1);
+              }
+              throw new Error("Not reached");
+            })
+            .collect(Collectors.toList());
+
+    Map<Executable, Class<?>[]> ignoredExceptionClasses =
+        Arrays.stream(executables)
+            .collect(Collectors.toMap(method
+                -> method,
+                method
+                -> Stream.concat(Arrays.stream(method.getExceptionTypes()), alwaysIgnore.stream())
+                       .toArray(Class[] ::new)));
+
+    setTarget(
+        executables, null, methodSignature, ignoredExceptionGlobMatchers, ignoredExceptionClasses);
+  }
+
+  /**
+   * Set the target executables to (auto-)fuzz. This method is primarily used by the JUnit
+   * integration to set the target class and method passed in by the test framework.
+   */
+  public static void setTarget(Executable[] targetExecutables, Object targetInstance,
+      String methodReference, Set<SimpleGlobMatcher> ignoredExceptionMatchers,
+      Map<Executable, Class<?>[]> throwsDeclarations) {
+    Class<?> targetClass = null;
+    for (Executable executable : targetExecutables) {
+      if (targetClass != null && !targetClass.equals(executable.getDeclaringClass())) {
+        throw new IllegalStateException(
+            "All target executables must be declared in the same class");
+      }
+      targetClass = executable.getDeclaringClass();
+      executable.setAccessible(true);
+    }
+
+    FuzzTarget.meta = new Meta(targetClass);
+    FuzzTarget.targetExecutables = targetExecutables;
+    FuzzTarget.targetInstance = targetInstance;
+    FuzzTarget.methodReference = methodReference;
+    FuzzTarget.ignoredExceptionMatchers = ignoredExceptionMatchers;
+    FuzzTarget.throwsDeclarations = throwsDeclarations;
+  }
+
+  public static void fuzzerTestOneInput(FuzzedDataProvider data) throws Throwable {
+    AutofuzzCodegenVisitor codegenVisitor = null;
+    if (Meta.IS_DEBUG) {
+      codegenVisitor = new AutofuzzCodegenVisitor();
+    }
+    fuzzerTestOneInput(data, codegenVisitor);
+    if (codegenVisitor != null) {
+      Log.println(codegenVisitor.generate());
+    }
+  }
+
+  public static void dumpReproducer(FuzzedDataProvider data, String reproducerPath, String sha) {
+    AutofuzzCodegenVisitor codegenVisitor = new AutofuzzCodegenVisitor();
+    try {
+      fuzzerTestOneInput(data, codegenVisitor);
+    } catch (Throwable ignored) {
+    }
+    String javaSource = String.format(AUTOFUZZ_REPRODUCER_TEMPLATE, sha, codegenVisitor.generate());
+    Path javaPath = Paths.get(reproducerPath, String.format("Crash_%s.java", sha));
+    try {
+      Files.write(javaPath, javaSource.getBytes(StandardCharsets.UTF_8));
+    } catch (IOException e) {
+      Log.error(String.format("Failed to write Java reproducer to %s%n", javaPath), e);
+    }
+    Log.println(String.format(
+        "reproducer_path='%s'; Java reproducer written to %s%n", reproducerPath, javaPath));
+  }
+
+  private static void fuzzerTestOneInput(
+      FuzzedDataProvider data, AutofuzzCodegenVisitor codegenVisitor) throws Throwable {
+    Executable targetExecutable;
+    if (FuzzTarget.targetExecutables.length == 1) {
+      targetExecutable = FuzzTarget.targetExecutables[0];
+    } else {
+      targetExecutable = data.pickValue(FuzzTarget.targetExecutables);
+    }
+    Object returnValue = null;
+    try {
+      if (targetExecutable instanceof Method) {
+        if (targetInstance != null) {
+          returnValue =
+              meta.autofuzz(data, (Method) targetExecutable, targetInstance, codegenVisitor);
+        } else {
+          returnValue = meta.autofuzz(data, (Method) targetExecutable, codegenVisitor);
+        }
+      } else {
+        // No targetInstance for constructors possible.
+        returnValue = meta.autofuzz(data, (Constructor<?>) targetExecutable, codegenVisitor);
+      }
+      executionsSinceLastInvocation = 0;
+    } catch (AutofuzzConstructionException e) {
+      if (Meta.IS_DEBUG) {
+        Log.error(e);
+      }
+      // Ignore exceptions thrown while constructing the parameters for the target method. We can
+      // only guess how to generate valid parameters and any exceptions thrown while doing so
+      // are most likely on us. However, if this happens too often, Autofuzz got stuck and we should
+      // let the user know.
+      executionsSinceLastInvocation++;
+      if (executionsSinceLastInvocation >= MAX_EXECUTIONS_WITHOUT_INVOCATION) {
+        Log.error(
+            String.format("Failed to generate valid arguments to '%s' in %d attempts; giving up",
+                methodReference, executionsSinceLastInvocation));
+        System.exit(1);
+      } else if (executionsSinceLastInvocation == MAX_EXECUTIONS_WITHOUT_INVOCATION / 2) {
+        // The application under test might perform classpath modifications or create classes
+        // dynamically that implement interfaces or extend abstract classes. Rescanning the
+        // classpath might help with constructing objects.
+        Meta.rescanClasspath();
+      }
+    } catch (AutofuzzInvocationException e) {
+      executionsSinceLastInvocation = 0;
+      Throwable cause = e.getCause();
+      Class<?> causeClass = cause.getClass();
+      // Do not report exceptions declared to be thrown by the method under test.
+      for (Class<?> declaredThrow :
+          throwsDeclarations.getOrDefault(targetExecutable, new Class[0])) {
+        if (declaredThrow.isAssignableFrom(causeClass)) {
+          return;
+        }
+      }
+
+      if (ignoredExceptionMatchers.stream().anyMatch(m -> m.matches(causeClass.getName()))) {
+        return;
+      }
+      cleanStackTraces(cause);
+      throw cause;
+    } catch (Throwable t) {
+      Log.error("Unexpected exception encountered during autofuzz", t);
+      System.exit(1);
+    } finally {
+      if (returnValue instanceof Closeable) {
+        ((Closeable) returnValue).close();
+      }
+    }
+  }
+
+  // Removes all stack trace elements that live in the Java reflection packages or the autofuzz
+  // package from the bottom of all stack frames.
+  private static void cleanStackTraces(Throwable t) {
+    Throwable cause = t;
+    while (cause != null) {
+      StackTraceElement[] elements = cause.getStackTrace();
+      int firstInterestingPos;
+      for (firstInterestingPos = elements.length - 1; firstInterestingPos > 0;
+           firstInterestingPos--) {
+        String className = elements[firstInterestingPos].getClassName();
+        if (!className.startsWith("com.code_intelligence.jazzer.autofuzz.")
+            && !className.startsWith("java.lang.reflect.")
+            && !className.startsWith("jdk.internal.reflect.")) {
+          break;
+        }
+      }
+      cause.setStackTrace(Arrays.copyOfRange(elements, 0, firstInterestingPos + 1));
+      cause = cause.getCause();
+    }
+  }
+}
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/autofuzz/Meta.java b/src/main/java/com/code_intelligence/jazzer/autofuzz/Meta.java
similarity index 71%
rename from agent/src/main/java/com/code_intelligence/jazzer/autofuzz/Meta.java
rename to src/main/java/com/code_intelligence/jazzer/autofuzz/Meta.java
index 3d48017..543284f 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/autofuzz/Meta.java
+++ b/src/main/java/com/code_intelligence/jazzer/autofuzz/Meta.java
@@ -27,6 +27,7 @@
 import com.code_intelligence.jazzer.api.Function4;
 import com.code_intelligence.jazzer.api.Function5;
 import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import com.code_intelligence.jazzer.runtime.HardToCatchError;
 import com.code_intelligence.jazzer.utils.Utils;
 import io.github.classgraph.ClassGraph;
 import io.github.classgraph.ClassInfoList;
@@ -44,26 +45,194 @@
 import java.lang.reflect.Type;
 import java.lang.reflect.TypeVariable;
 import java.lang.reflect.WildcardType;
-import java.util.*;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.WeakHashMap;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 import net.jodah.typetools.TypeResolver;
 import net.jodah.typetools.TypeResolver.Unknown;
 
 public class Meta {
-  static final WeakHashMap<Class<?>, List<Class<?>>> implementingClassesCache = new WeakHashMap<>();
-  static final WeakHashMap<Class<?>, List<Class<?>>> nestedBuilderClassesCache =
+  public static final boolean IS_DEBUG = isDebug();
+
+  private static final Meta PUBLIC_LOOKUP_INSTANCE = new Meta(null);
+  private static final boolean IS_TEST = isTest();
+  private static final WeakHashMap<Class<?>, List<Class<?>>> implementingClassesCache =
       new WeakHashMap<>();
-  static final WeakHashMap<Class<?>, List<Method>> originalObjectCreationMethodsCache =
+  private static final WeakHashMap<Class<?>, List<Class<?>>> nestedBuilderClassesCache =
       new WeakHashMap<>();
-  static final WeakHashMap<Class<?>, List<Method>> cascadingBuilderMethodsCache =
+  private static final WeakHashMap<Class<?>, List<Method>> originalObjectCreationMethodsCache =
+      new WeakHashMap<>();
+  private static final WeakHashMap<Class<?>, List<Method>> cascadingBuilderMethodsCache =
       new WeakHashMap<>();
 
-  public static Object autofuzz(FuzzedDataProvider data, Method method) {
+  private final AccessibleObjectLookup lookup;
+
+  public Meta(Class<?> referenceClass) {
+    lookup = new AccessibleObjectLookup(referenceClass);
+  }
+
+  @SuppressWarnings("unchecked")
+  public static <T1> void autofuzz(FuzzedDataProvider data, Consumer1<T1> func) {
+    Class<?>[] types = TypeResolver.resolveRawArguments(Consumer1.class, func.getClass());
+    func.accept((T1) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 0));
+  }
+
+  @SuppressWarnings("unchecked")
+  public static <T1, T2> void autofuzz(FuzzedDataProvider data, Consumer2<T1, T2> func) {
+    Class<?>[] types = TypeResolver.resolveRawArguments(Consumer2.class, func.getClass());
+    func.accept((T1) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 0),
+        (T2) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 1));
+  }
+
+  @SuppressWarnings("unchecked")
+  public static <T1, T2, T3> void autofuzz(FuzzedDataProvider data, Consumer3<T1, T2, T3> func) {
+    Class<?>[] types = TypeResolver.resolveRawArguments(Consumer3.class, func.getClass());
+    func.accept((T1) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 0),
+        (T2) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 1),
+        (T3) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 2));
+  }
+
+  @SuppressWarnings("unchecked")
+  public static <T1, T2, T3, T4> void autofuzz(
+      FuzzedDataProvider data, Consumer4<T1, T2, T3, T4> func) {
+    Class<?>[] types = TypeResolver.resolveRawArguments(Consumer4.class, func.getClass());
+    func.accept((T1) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 0),
+        (T2) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 1),
+        (T3) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 2),
+        (T4) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 3));
+  }
+
+  @SuppressWarnings("unchecked")
+  public static <T1, T2, T3, T4, T5> void autofuzz(
+      FuzzedDataProvider data, Consumer5<T1, T2, T3, T4, T5> func) {
+    Class<?>[] types = TypeResolver.resolveRawArguments(Consumer5.class, func.getClass());
+    func.accept((T1) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 0),
+        (T2) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 1),
+        (T3) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 2),
+        (T4) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 3),
+        (T5) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 4));
+  }
+
+  @SuppressWarnings("unchecked")
+  public static <T1, R> R autofuzz(FuzzedDataProvider data, Function1<T1, R> func) {
+    Class<?>[] types = TypeResolver.resolveRawArguments(Function1.class, func.getClass());
+    return func.apply((T1) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 0));
+  }
+
+  @SuppressWarnings("unchecked")
+  public static <T1, T2, R> R autofuzz(FuzzedDataProvider data, Function2<T1, T2, R> func) {
+    Class<?>[] types = TypeResolver.resolveRawArguments(Function2.class, func.getClass());
+    return func.apply((T1) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 0),
+        (T2) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 1));
+  }
+
+  @SuppressWarnings("unchecked")
+  public static <T1, T2, T3, R> R autofuzz(FuzzedDataProvider data, Function3<T1, T2, T3, R> func) {
+    Class<?>[] types = TypeResolver.resolveRawArguments(Function3.class, func.getClass());
+    return func.apply((T1) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 0),
+        (T2) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 1),
+        (T3) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 2));
+  }
+
+  @SuppressWarnings("unchecked")
+  public static <T1, T2, T3, T4, R> R autofuzz(
+      FuzzedDataProvider data, Function4<T1, T2, T3, T4, R> func) {
+    Class<?>[] types = TypeResolver.resolveRawArguments(Function4.class, func.getClass());
+    return func.apply((T1) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 0),
+        (T2) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 1),
+        (T3) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 2),
+        (T4) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 3));
+  }
+
+  @SuppressWarnings("unchecked")
+  public static <T1, T2, T3, T4, T5, R> R autofuzz(
+      FuzzedDataProvider data, Function5<T1, T2, T3, T4, T5, R> func) {
+    Class<?>[] types = TypeResolver.resolveRawArguments(Function5.class, func.getClass());
+    return func.apply((T1) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 0),
+        (T2) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 1),
+        (T3) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 2),
+        (T4) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 3),
+        (T5) PUBLIC_LOOKUP_INSTANCE.consumeChecked(data, types, 4));
+  }
+
+  public static Object consume(FuzzedDataProvider data, Class<?> type) {
+    return PUBLIC_LOOKUP_INSTANCE.consume(data, type, null);
+  }
+
+  static void rescanClasspath() {
+    implementingClassesCache.clear();
+  }
+
+  private static boolean isTest() {
+    String value = System.getenv("JAZZER_AUTOFUZZ_TESTING");
+    return value != null && !value.isEmpty();
+  }
+
+  private static boolean isDebug() {
+    String value = System.getenv("JAZZER_AUTOFUZZ_DEBUG");
+    return value != null && !value.isEmpty();
+  }
+
+  private static int consumeArrayLength(FuzzedDataProvider data, int sizeOfElement) {
+    // Spend at most half of the fuzzer input bytes so that the remaining arguments that require
+    // construction still have non-trivial data to work with.
+    int bytesToSpend = data.remainingBytes() / 2;
+    return bytesToSpend / Math.max(sizeOfElement, 1);
+  }
+
+  private static String deepToString(Object obj) {
+    if (obj == null) {
+      return "null";
+    }
+    if (obj.getClass().isArray()) {
+      return String.format("(%s[]) %s", obj.getClass().getComponentType().getName(),
+          Arrays.deepToString((Object[]) obj));
+    }
+    return obj.toString();
+  }
+
+  private static String getDebugSummary(
+      Executable executable, Object thisObject, Object[] arguments) {
+    return String.format("%nMethod: %s::%s%s%nthis: %s%nArguments: %s",
+        executable.getDeclaringClass().getName(), executable.getName(),
+        Utils.getReadableDescriptor(executable), thisObject,
+        Arrays.stream(arguments).map(Meta::deepToString).collect(Collectors.joining(", ")));
+  }
+
+  static Class<?> getRawType(Type genericType) {
+    if (genericType instanceof Class<?>) {
+      return (Class<?>) genericType;
+    } else if (genericType instanceof ParameterizedType) {
+      return getRawType(((ParameterizedType) genericType).getRawType());
+    } else if (genericType instanceof WildcardType) {
+      // TODO: Improve this.
+      return Object.class;
+    } else if (genericType instanceof TypeVariable<?>) {
+      throw new AutofuzzError("Did not expect genericType to be a TypeVariable: " + genericType);
+    } else if (genericType instanceof GenericArrayType) {
+      return Array
+          .newInstance(getRawType(((GenericArrayType) genericType).getGenericComponentType()), 0)
+          .getClass();
+    } else {
+      throw new AutofuzzError("Got unexpected class implementing Type: " + genericType);
+    }
+  }
+
+  public Object autofuzz(FuzzedDataProvider data, Method method) {
     return autofuzz(data, method, null);
   }
 
-  static Object autofuzz(FuzzedDataProvider data, Method method, AutofuzzCodegenVisitor visitor) {
+  // Renamed so that it doesn't clash with the static method consume, which we don't want to rename
+  // as the api package depends on it by name.
+  public Object consumeNonStatic(FuzzedDataProvider data, Class<?> type) {
+    return consume(data, type, null);
+  }
+
+  Object autofuzz(FuzzedDataProvider data, Method method, AutofuzzCodegenVisitor visitor) {
     Object result;
     if (Modifier.isStatic(method.getModifiers())) {
       if (visitor != null) {
@@ -81,14 +250,14 @@
     } else {
       if (visitor != null) {
         // This group will always have two elements: The thisObject and the method call.
-        // Since the this object can be a complex expression, wrap it in paranthesis.
+        // Since the this object can be a complex expression, wrap it in parenthesis.
         visitor.pushGroup("(", ").", "");
       }
-      Object thisObject = consume(data, method.getDeclaringClass(), visitor);
-      if (thisObject == null) {
-        throw new AutofuzzConstructionException();
-      }
       try {
+        Object thisObject = consume(data, method.getDeclaringClass(), visitor);
+        if (thisObject == null) {
+          throw new AutofuzzConstructionException();
+        }
         result = autofuzz(data, method, thisObject, visitor);
       } finally {
         if (visitor != null) {
@@ -99,11 +268,11 @@
     return result;
   }
 
-  public static Object autofuzz(FuzzedDataProvider data, Method method, Object thisObject) {
+  public Object autofuzz(FuzzedDataProvider data, Method method, Object thisObject) {
     return autofuzz(data, method, thisObject, null);
   }
 
-  static Object autofuzz(
+  Object autofuzz(
       FuzzedDataProvider data, Method method, Object thisObject, AutofuzzCodegenVisitor visitor) {
     if (visitor != null) {
       visitor.pushGroup(String.format("%s(", method.getName()), ", ", ")");
@@ -118,15 +287,50 @@
       // We should ensure that the arguments fed into the method are always valid.
       throw new AutofuzzError(getDebugSummary(method, thisObject, arguments), e);
     } catch (InvocationTargetException e) {
+      if (e.getCause() instanceof HardToCatchError) {
+        throw new AutofuzzInvocationException();
+      }
       throw new AutofuzzInvocationException(e.getCause());
     }
   }
 
-  public static <R> R autofuzz(FuzzedDataProvider data, Constructor<R> constructor) {
+  Object autofuzzForConsume(
+      FuzzedDataProvider data, Constructor<?> constructor, AutofuzzCodegenVisitor visitor) {
+    try {
+      return autofuzz(data, constructor, visitor);
+    } catch (AutofuzzConstructionException e) {
+      // Do not nest AutofuzzConstructionExceptions.
+      throw e;
+    } catch (AutofuzzInvocationException e) {
+      // If an invocation fails during consume and thus while trying to construct a valid object,
+      // the exception should not be reported as a finding, so we rewrap it.
+      throw new AutofuzzConstructionException(e.getCause());
+    } catch (Throwable t) {
+      throw new AutofuzzConstructionException(t);
+    }
+  }
+
+  Object autofuzzForConsume(
+      FuzzedDataProvider data, Method method, Object thisObject, AutofuzzCodegenVisitor visitor) {
+    try {
+      return autofuzz(data, method, thisObject, visitor);
+    } catch (AutofuzzConstructionException e) {
+      // Do not nest AutofuzzConstructionExceptions.
+      throw e;
+    } catch (AutofuzzInvocationException e) {
+      // If an invocation fails during consume and thus while trying to construct a valid object,
+      // the exception should not be reported as a finding, so we rewrap it.
+      throw new AutofuzzConstructionException(e.getCause());
+    } catch (Throwable t) {
+      throw new AutofuzzConstructionException(t);
+    }
+  }
+
+  public <R> R autofuzz(FuzzedDataProvider data, Constructor<R> constructor) {
     return autofuzz(data, constructor, null);
   }
 
-  static <R> R autofuzz(
+  <R> R autofuzz(
       FuzzedDataProvider data, Constructor<R> constructor, AutofuzzCodegenVisitor visitor) {
     if (visitor != null) {
       // getCanonicalName is correct also for nested classes.
@@ -144,132 +348,71 @@
       // constructors of abstract classes or private constructors.
       throw new AutofuzzError(getDebugSummary(constructor, null, arguments), e);
     } catch (InvocationTargetException e) {
+      if (e.getCause() instanceof HardToCatchError) {
+        throw new AutofuzzInvocationException();
+      }
       throw new AutofuzzInvocationException(e.getCause());
     }
   }
 
-  @SuppressWarnings("unchecked")
-  public static <T1> void autofuzz(FuzzedDataProvider data, Consumer1<T1> func) {
-    Class<?>[] types = TypeResolver.resolveRawArguments(Consumer1.class, func.getClass());
-    func.accept((T1) consumeChecked(data, types, 0));
-  }
-
-  @SuppressWarnings("unchecked")
-  public static <T1, T2> void autofuzz(FuzzedDataProvider data, Consumer2<T1, T2> func) {
-    Class<?>[] types = TypeResolver.resolveRawArguments(Consumer2.class, func.getClass());
-    func.accept((T1) consumeChecked(data, types, 0), (T2) consumeChecked(data, types, 1));
-  }
-
-  @SuppressWarnings("unchecked")
-  public static <T1, T2, T3> void autofuzz(FuzzedDataProvider data, Consumer3<T1, T2, T3> func) {
-    Class<?>[] types = TypeResolver.resolveRawArguments(Consumer3.class, func.getClass());
-    func.accept((T1) consumeChecked(data, types, 0), (T2) consumeChecked(data, types, 1),
-        (T3) consumeChecked(data, types, 2));
-  }
-
-  @SuppressWarnings("unchecked")
-  public static <T1, T2, T3, T4> void autofuzz(
-      FuzzedDataProvider data, Consumer4<T1, T2, T3, T4> func) {
-    Class<?>[] types = TypeResolver.resolveRawArguments(Consumer4.class, func.getClass());
-    func.accept((T1) consumeChecked(data, types, 0), (T2) consumeChecked(data, types, 1),
-        (T3) consumeChecked(data, types, 2), (T4) consumeChecked(data, types, 3));
-  }
-
-  @SuppressWarnings("unchecked")
-  public static <T1, T2, T3, T4, T5> void autofuzz(
-      FuzzedDataProvider data, Consumer5<T1, T2, T3, T4, T5> func) {
-    Class<?>[] types = TypeResolver.resolveRawArguments(Consumer5.class, func.getClass());
-    func.accept((T1) consumeChecked(data, types, 0), (T2) consumeChecked(data, types, 1),
-        (T3) consumeChecked(data, types, 2), (T4) consumeChecked(data, types, 3),
-        (T5) consumeChecked(data, types, 4));
-  }
-
-  @SuppressWarnings("unchecked")
-  public static <T1, R> R autofuzz(FuzzedDataProvider data, Function1<T1, R> func) {
-    Class<?>[] types = TypeResolver.resolveRawArguments(Function1.class, func.getClass());
-    return func.apply((T1) consumeChecked(data, types, 0));
-  }
-
-  @SuppressWarnings("unchecked")
-  public static <T1, T2, R> R autofuzz(FuzzedDataProvider data, Function2<T1, T2, R> func) {
-    Class<?>[] types = TypeResolver.resolveRawArguments(Function2.class, func.getClass());
-    return func.apply((T1) consumeChecked(data, types, 0), (T2) consumeChecked(data, types, 1));
-  }
-
-  @SuppressWarnings("unchecked")
-  public static <T1, T2, T3, R> R autofuzz(FuzzedDataProvider data, Function3<T1, T2, T3, R> func) {
-    Class<?>[] types = TypeResolver.resolveRawArguments(Function3.class, func.getClass());
-    return func.apply((T1) consumeChecked(data, types, 0), (T2) consumeChecked(data, types, 1),
-        (T3) consumeChecked(data, types, 2));
-  }
-
-  @SuppressWarnings("unchecked")
-  public static <T1, T2, T3, T4, R> R autofuzz(
-      FuzzedDataProvider data, Function4<T1, T2, T3, T4, R> func) {
-    Class<?>[] types = TypeResolver.resolveRawArguments(Function4.class, func.getClass());
-    return func.apply((T1) consumeChecked(data, types, 0), (T2) consumeChecked(data, types, 1),
-        (T3) consumeChecked(data, types, 2), (T4) consumeChecked(data, types, 3));
-  }
-
-  @SuppressWarnings("unchecked")
-  public static <T1, T2, T3, T4, T5, R> R autofuzz(
-      FuzzedDataProvider data, Function5<T1, T2, T3, T4, T5, R> func) {
-    Class<?>[] types = TypeResolver.resolveRawArguments(Function5.class, func.getClass());
-    return func.apply((T1) consumeChecked(data, types, 0), (T2) consumeChecked(data, types, 1),
-        (T3) consumeChecked(data, types, 2), (T4) consumeChecked(data, types, 3),
-        (T5) consumeChecked(data, types, 4));
-  }
-
-  public static Object consume(FuzzedDataProvider data, Class<?> type) {
-    return consume(data, type, null);
-  }
-
   // Invariant: The Java source code representation of the returned object visited by visitor must
   // represent an object of the same type as genericType. For example, a null value returned for
   // the genericType Class<java.lang.String> should lead to the generated code
   // "(java.lang.String) null", not just "null". This makes it possible to safely use consume in
   // recursive argument constructions.
-  static Object consume(FuzzedDataProvider data, Type genericType, AutofuzzCodegenVisitor visitor) {
+  // Exception: Some Java libraries offer public methods that take private interfaces or abstract
+  // classes as parameters. In this case, a cast to the parent type would cause an
+  // IllegalAccessError. Since this case should be rare and there is no good alternative to
+  // disambiguate overloads, we omit the cast in this case.
+  Object consume(FuzzedDataProvider data, Type genericType, AutofuzzCodegenVisitor visitor) {
     Class<?> type = getRawType(genericType);
     if (type == byte.class || type == Byte.class) {
       byte result = data.consumeByte();
-      if (visitor != null)
+      if (visitor != null) {
         visitor.pushElement(String.format("(byte) %s", result));
+      }
       return result;
     } else if (type == short.class || type == Short.class) {
       short result = data.consumeShort();
-      if (visitor != null)
+      if (visitor != null) {
         visitor.pushElement(String.format("(short) %s", result));
+      }
       return result;
     } else if (type == int.class || type == Integer.class) {
       int result = data.consumeInt();
-      if (visitor != null)
+      if (visitor != null) {
         visitor.pushElement(Integer.toString(result));
+      }
       return result;
     } else if (type == long.class || type == Long.class) {
       long result = data.consumeLong();
-      if (visitor != null)
+      if (visitor != null) {
         visitor.pushElement(String.format("%sL", result));
+      }
       return result;
     } else if (type == float.class || type == Float.class) {
       float result = data.consumeFloat();
-      if (visitor != null)
+      if (visitor != null) {
         visitor.pushElement(String.format("%sF", result));
+      }
       return result;
     } else if (type == double.class || type == Double.class) {
       double result = data.consumeDouble();
-      if (visitor != null)
+      if (visitor != null) {
         visitor.pushElement(Double.toString(result));
+      }
       return result;
     } else if (type == boolean.class || type == Boolean.class) {
       boolean result = data.consumeBoolean();
-      if (visitor != null)
+      if (visitor != null) {
         visitor.pushElement(Boolean.toString(result));
+      }
       return result;
     } else if (type == char.class || type == Character.class) {
       char result = data.consumeChar();
-      if (visitor != null)
+      if (visitor != null) {
         visitor.addCharLiteral(result);
+      }
       return result;
     }
     // Sometimes, but rarely return null for non-primitive and non-boxed types.
@@ -288,8 +431,9 @@
     }
     if (type == String.class || type == CharSequence.class) {
       String result = data.consumeString(consumeArrayLength(data, 1));
-      if (visitor != null)
+      if (visitor != null) {
         visitor.addStringLiteral(result);
+      }
       return result;
     } else if (type.isArray()) {
       if (type == byte[].class) {
@@ -430,38 +574,49 @@
       }
       return enumValue;
     } else if (type == Class.class) {
-      if (visitor != null)
+      if (visitor != null) {
         visitor.pushElement(String.format("%s.class", YourAverageJavaClass.class.getName()));
+      }
       return YourAverageJavaClass.class;
     } else if (type == Method.class) {
       if (visitor != null) {
         throw new AutofuzzError("codegen has not been implemented for Method.class");
       }
-      return data.pickValue(sortExecutables(YourAverageJavaClass.class.getMethods()));
+      return data.pickValue(lookup.getAccessibleMethods(YourAverageJavaClass.class));
     } else if (type == Constructor.class) {
       if (visitor != null) {
         throw new AutofuzzError("codegen has not been implemented for Constructor.class");
       }
-      return data.pickValue(sortExecutables(YourAverageJavaClass.class.getConstructors()));
+      return data.pickValue(lookup.getAccessibleConstructors(YourAverageJavaClass.class));
     } else if (type.isInterface() || Modifier.isAbstract(type.getModifiers())) {
       List<Class<?>> implementingClasses = implementingClassesCache.get(type);
       if (implementingClasses == null) {
-        ClassGraph classGraph =
-            new ClassGraph().enableClassInfo().enableInterClassDependencies().rejectPackages(
-                "jaz.*");
-        if (!isTest()) {
-          classGraph.rejectPackages("com.code_intelligence.jazzer.*");
+        // TODO: We may be scanning multiple times. Instead, we should keep the ScanResult around
+        //  for as long as there is enough memory.
+        ClassGraph classGraph = new ClassGraph()
+                                    .enableClassInfo()
+                                    .ignoreClassVisibility()
+                                    .ignoreMethodVisibility()
+                                    .enableInterClassDependencies()
+                                    .rejectPackages("jaz");
+        if (!IS_TEST) {
+          classGraph.rejectPackages("com.code_intelligence.jazzer");
         }
         try (ScanResult result = classGraph.scan()) {
           ClassInfoList children =
               type.isInterface() ? result.getClassesImplementing(type) : result.getSubclasses(type);
-          implementingClasses =
-              children.getStandardClasses().filter(cls -> !cls.isAbstract()).loadClasses();
+          implementingClasses = children.getStandardClasses()
+                                    .filter(info -> !Modifier.isAbstract(info.getModifiers()))
+                                    .filter(info -> lookup.isAccessible(info, info.getModifiers()))
+                                    // Filter out anonymous and local classes, which can't be
+                                    // instantiated in reproducers.
+                                    .filter(info -> info.getName() != null)
+                                    .loadClasses();
           implementingClassesCache.put(type, implementingClasses);
         }
       }
       if (implementingClasses.isEmpty()) {
-        if (isDebug()) {
+        if (IS_DEBUG) {
           throw new AutofuzzConstructionException(String.format(
               "Could not find classes implementing %s on the classpath", type.getName()));
         } else {
@@ -469,16 +624,23 @@
         }
       }
       if (visitor != null) {
-        // This group will always have a single element: The instance of the implementing class.
-        visitor.pushGroup(String.format("(%s) ", type.getName()), "", "");
+        // See the "Exception" note in the method comment.
+        if (Modifier.isPublic(type.getModifiers())) {
+          // This group will always have a single element: The instance of the implementing class.
+          visitor.pushGroup(String.format("(%s) ", type.getCanonicalName()), "", "");
+        }
       }
       Object result = consume(data, data.pickValue(implementingClasses), visitor);
       if (visitor != null) {
-        visitor.popGroup();
+        if (Modifier.isPublic(type.getModifiers())) {
+          visitor.popGroup();
+        }
       }
       return result;
-    } else if (type.getConstructors().length > 0) {
-      Constructor<?> constructor = data.pickValue(sortExecutables(type.getConstructors()));
+    }
+    Constructor<?>[] constructors = lookup.getAccessibleConstructors(type);
+    if (constructors.length > 0) {
+      Constructor<?> constructor = data.pickValue(constructors);
       boolean applySetters = constructor.getParameterCount() == 0;
       if (visitor != null && applySetters) {
         // Embed the instance creation and setters into an immediately invoked lambda expression to
@@ -489,14 +651,14 @@
             String.format("; %s.", uniqueVariableName),
             String.format("; return %s;})).get()", uniqueVariableName));
       }
-      Object obj = autofuzz(data, constructor, visitor);
+      Object obj = autofuzzForConsume(data, constructor, visitor);
       if (applySetters) {
         List<Method> potentialSetters = getPotentialSetters(type);
         if (!potentialSetters.isEmpty()) {
           List<Method> pickedSetters =
               data.pickValues(potentialSetters, data.consumeInt(0, potentialSetters.size()));
           for (Method setter : pickedSetters) {
-            autofuzz(data, setter, obj, visitor);
+            autofuzzForConsume(data, setter, obj, visitor);
           }
         }
         if (visitor != null) {
@@ -524,14 +686,14 @@
         // Group for the chain of builder methods.
         visitor.pushGroup("", ".", "");
       }
-      Object builderObj =
-          autofuzz(data, data.pickValue(sortExecutables(pickedBuilder.getConstructors())), visitor);
+      Object builderObj = autofuzzForConsume(
+          data, data.pickValue(lookup.getAccessibleConstructors(pickedBuilder)), visitor);
       for (Method method : pickedMethods) {
-        builderObj = autofuzz(data, method, builderObj, visitor);
+        builderObj = autofuzzForConsume(data, method, builderObj, visitor);
       }
 
       try {
-        Object obj = autofuzz(data, builderMethod, builderObj, visitor);
+        Object obj = autofuzzForConsume(data, builderMethod, builderObj, visitor);
         if (visitor != null) {
           visitor.popGroup();
         }
@@ -543,122 +705,71 @@
 
     // We ran out of ways to construct an instance of the requested type. If in debug mode, report
     // more detailed information.
-    if (!isDebug()) {
-      throw new AutofuzzConstructionException();
-    } else {
+    if (IS_DEBUG) {
       String summary = String.format(
           "Failed to generate instance of %s:%nAccessible constructors: %s%nNested subclasses: %s%n",
           type.getName(),
-          Arrays.stream(type.getConstructors())
+          Arrays.stream(lookup.getAccessibleConstructors(type))
               .map(Utils::getReadableDescriptor)
               .collect(Collectors.joining(", ")),
-          Arrays.stream(type.getClasses()).map(Class::getName).collect(Collectors.joining(", ")));
+          Arrays.stream(lookup.getAccessibleClasses(type))
+              .map(Class::getName)
+              .collect(Collectors.joining(", ")));
       throw new AutofuzzConstructionException(summary);
+    } else {
+      throw new AutofuzzConstructionException();
     }
   }
 
-  static void rescanClasspath() {
-    implementingClassesCache.clear();
-  }
-
-  static boolean isTest() {
-    String value = System.getenv("JAZZER_AUTOFUZZ_TESTING");
-    return value != null && !value.isEmpty();
-  }
-
-  static boolean isDebug() {
-    String value = System.getenv("JAZZER_AUTOFUZZ_DEBUG");
-    return value != null && !value.isEmpty();
-  }
-
-  private static int consumeArrayLength(FuzzedDataProvider data, int sizeOfElement) {
-    // Spend at most half of the fuzzer input bytes so that the remaining arguments that require
-    // construction still have non-trivial data to work with.
-    int bytesToSpend = data.remainingBytes() / 2;
-    return bytesToSpend / Math.max(sizeOfElement, 1);
-  }
-
-  private static String getDebugSummary(
-      Executable executable, Object thisObject, Object[] arguments) {
-    return String.format("%nMethod: %s::%s%s%nthis: %s%nArguments: %s",
-        executable.getDeclaringClass().getName(), executable.getName(),
-        Utils.getReadableDescriptor(executable), thisObject,
-        Arrays.stream(arguments)
-            .map(arg -> arg == null ? "null" : arg.toString())
-            .collect(Collectors.joining(", ")));
-  }
-
-  private static <T extends Executable> List<T> sortExecutables(T[] executables) {
-    List<T> list = Arrays.asList(executables);
-    sortExecutables(list);
-    return list;
-  }
-
-  private static void sortExecutables(List<? extends Executable> executables) {
-    executables.sort(Comparator.comparing(Executable::getName).thenComparing(Utils::getDescriptor));
-  }
-
-  private static void sortClasses(List<? extends Class<?>> classes) {
-    classes.sort(Comparator.comparing(Class::getName));
-  }
-
-  private static List<Class<?>> getNestedBuilderClasses(Class<?> type) {
+  private List<Class<?>> getNestedBuilderClasses(Class<?> type) {
     List<Class<?>> nestedBuilderClasses = nestedBuilderClassesCache.get(type);
     if (nestedBuilderClasses == null) {
-      nestedBuilderClasses = Arrays.stream(type.getClasses())
+      nestedBuilderClasses = Arrays.stream(lookup.getAccessibleClasses(type))
                                  .filter(cls -> cls.getName().endsWith("Builder"))
                                  .filter(cls -> !getOriginalObjectCreationMethods(cls).isEmpty())
                                  .collect(Collectors.toList());
-      sortClasses(nestedBuilderClasses);
       nestedBuilderClassesCache.put(type, nestedBuilderClasses);
     }
     return nestedBuilderClasses;
   }
 
-  private static List<Method> getOriginalObjectCreationMethods(Class<?> builder) {
+  private List<Method> getOriginalObjectCreationMethods(Class<?> builder) {
     List<Method> originalObjectCreationMethods = originalObjectCreationMethodsCache.get(builder);
     if (originalObjectCreationMethods == null) {
       originalObjectCreationMethods =
-          Arrays.stream(builder.getMethods())
+          Arrays.stream(lookup.getAccessibleMethods(builder))
               .filter(m -> m.getReturnType() == builder.getEnclosingClass())
               .collect(Collectors.toList());
-      sortExecutables(originalObjectCreationMethods);
       originalObjectCreationMethodsCache.put(builder, originalObjectCreationMethods);
     }
     return originalObjectCreationMethods;
   }
 
-  private static List<Method> getCascadingBuilderMethods(Class<?> builder) {
+  private List<Method> getCascadingBuilderMethods(Class<?> builder) {
     List<Method> cascadingBuilderMethods = cascadingBuilderMethodsCache.get(builder);
     if (cascadingBuilderMethods == null) {
-      cascadingBuilderMethods = Arrays.stream(builder.getMethods())
+      cascadingBuilderMethods = Arrays.stream(lookup.getAccessibleMethods(builder))
                                     .filter(m -> m.getReturnType() == builder)
                                     .collect(Collectors.toList());
-      sortExecutables(cascadingBuilderMethods);
       cascadingBuilderMethodsCache.put(builder, cascadingBuilderMethods);
     }
     return cascadingBuilderMethods;
   }
 
-  private static List<Method> getPotentialSetters(Class<?> type) {
-    List<Method> potentialSetters = new ArrayList<>();
-    Method[] methods = type.getMethods();
-    for (Method method : methods) {
-      if (void.class.equals(method.getReturnType()) && method.getParameterCount() == 1
-          && method.getName().startsWith("set")) {
-        potentialSetters.add(method);
-      }
-    }
-    sortExecutables(potentialSetters);
-    return potentialSetters;
+  private List<Method> getPotentialSetters(Class<?> type) {
+    return Arrays.stream(lookup.getAccessibleMethods(type))
+        .filter(method -> void.class.equals(method.getReturnType()))
+        .filter(method -> method.getParameterCount() == 1)
+        .filter(method -> method.getName().startsWith("set"))
+        .collect(Collectors.toList());
   }
 
-  private static Object[] consumeArguments(
+  public Object[] consumeArguments(
       FuzzedDataProvider data, Executable executable, AutofuzzCodegenVisitor visitor) {
     Object[] result;
     try {
       result = Arrays.stream(executable.getGenericParameterTypes())
-                   .map((type) -> consume(data, type, visitor))
+                   .map(type -> consume(data, type, visitor))
                    .toArray();
       return result;
     } catch (AutofuzzConstructionException e) {
@@ -673,13 +784,13 @@
     }
   }
 
-  private static Object consumeChecked(FuzzedDataProvider data, Class<?>[] types, int i) {
+  private Object consumeChecked(FuzzedDataProvider data, Class<?>[] types, int i) {
     if (types[i] == Unknown.class) {
       throw new AutofuzzError("Failed to determine type of argument " + (i + 1));
     }
     Object result;
     try {
-      result = consume(data, types[i]);
+      result = consumeNonStatic(data, types[i]);
     } catch (AutofuzzConstructionException e) {
       // Do not nest AutofuzzConstructionExceptions.
       throw e;
@@ -695,22 +806,4 @@
     }
     return result;
   }
-
-  private static Class<?> getRawType(Type genericType) {
-    if (genericType instanceof Class<?>) {
-      return (Class<?>) genericType;
-    } else if (genericType instanceof ParameterizedType) {
-      return getRawType(((ParameterizedType) genericType).getRawType());
-    } else if (genericType instanceof WildcardType) {
-      // TODO: Improve this.
-      return Object.class;
-    } else if (genericType instanceof TypeVariable<?>) {
-      throw new AutofuzzError("Did not expect genericType to be a TypeVariable: " + genericType);
-    } else if (genericType instanceof GenericArrayType) {
-      // TODO: Improve this;
-      return Object[].class;
-    } else {
-      throw new AutofuzzError("Got unexpected class implementing Type: " + genericType);
-    }
-  }
 }
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/autofuzz/YourAverageJavaClass.java b/src/main/java/com/code_intelligence/jazzer/autofuzz/YourAverageJavaClass.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/autofuzz/YourAverageJavaClass.java
rename to src/main/java/com/code_intelligence/jazzer/autofuzz/YourAverageJavaClass.java
diff --git a/src/main/java/com/code_intelligence/jazzer/driver/BUILD.bazel b/src/main/java/com/code_intelligence/jazzer/driver/BUILD.bazel
new file mode 100644
index 0000000..37202c6
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/driver/BUILD.bazel
@@ -0,0 +1,177 @@
+load("@fmeum_rules_jni//jni:defs.bzl", "java_jni_library")
+load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library")
+load("//bazel:kotlin.bzl", "ktlint")
+
+java_library(
+    name = "constants",
+    srcs = ["Constants.java"],
+    visibility = ["//src/main/java/com/code_intelligence/jazzer/driver:__subpackages__"],
+)
+
+java_library(
+    name = "driver",
+    srcs = ["Driver.java"],
+    visibility = [
+        "//src/main/java/com/code_intelligence/jazzer:__pkg__",
+    ],
+    deps = [
+        ":fuzz_target_finder",
+        ":fuzz_target_holder",
+        ":fuzz_target_runner",
+        ":offline_instrumentor",
+        ":opt",
+        "//src/main/java/com/code_intelligence/jazzer/agent:agent_installer",
+        "//src/main/java/com/code_intelligence/jazzer/android:android_runtime",
+        "//src/main/java/com/code_intelligence/jazzer/driver/junit:junit_runner",
+        "//src/main/java/com/code_intelligence/jazzer/runtime:constants",
+        "//src/main/java/com/code_intelligence/jazzer/utils:log",
+    ],
+)
+
+java_library(
+    name = "offline_instrumentor",
+    srcs = ["OfflineInstrumentor.java"],
+    visibility = [
+        "//src/main/java/com/code_intelligence/jazzer:__pkg__",
+    ],
+    deps = [
+        ":opt",
+        "//src/main/java/com/code_intelligence/jazzer/agent:agent_installer",
+        "//src/main/java/com/code_intelligence/jazzer/utils:log",
+        "//src/main/java/com/code_intelligence/jazzer/utils:zip_utils",
+    ],
+)
+
+kt_jvm_library(
+    name = "exception_utils",
+    srcs = ["ExceptionUtils.kt"],
+    visibility = ["//src/main/java/com/code_intelligence/jazzer/driver:__subpackages__"],
+    deps = [
+        ":opt",
+        "//src/main/java/com/code_intelligence/jazzer/api:hooks",
+        "//src/main/java/com/code_intelligence/jazzer/runtime:constants",
+        "//src/main/java/com/code_intelligence/jazzer/utils:log",
+    ],
+)
+
+java_library(
+    name = "fuzz_target_finder",
+    srcs = ["FuzzTargetFinder.java"],
+    visibility = ["//src/test/java/com/code_intelligence/jazzer/driver:__pkg__"],
+    deps = [
+        ":fuzz_target_holder",
+        ":opt",
+        "//src/main/java/com/code_intelligence/jazzer/api",
+        "//src/main/java/com/code_intelligence/jazzer/runtime:constants",
+        "//src/main/java/com/code_intelligence/jazzer/utils:log",
+        "//src/main/java/com/code_intelligence/jazzer/utils:manifest_utils",
+    ],
+)
+
+java_library(
+    name = "fuzz_target_holder",
+    srcs = ["FuzzTargetHolder.java"],
+    visibility = [
+        "//src/main/java/com/code_intelligence/jazzer/junit:__pkg__",
+        "//src/test/java/com/code_intelligence/jazzer/driver:__pkg__",
+    ],
+    deps = [
+        ":opt",
+        "//src/main/java/com/code_intelligence/jazzer/api",
+        "//src/main/java/com/code_intelligence/jazzer/autofuzz",
+    ],
+)
+
+java_jni_library(
+    name = "fuzz_target_runner",
+    srcs = ["FuzzTargetRunner.java"],
+    # This library is loaded by the classes in the agent runtime package as it needs to be available
+    # in the bootstrap class loader. It is packaged here rather than in jazzer_boostrap.jar since
+    # the bootstrap class loader doesn't support resources.
+    native_libs = [
+        "//src/main/native/com/code_intelligence/jazzer/driver:jazzer_driver",
+    ],
+    visibility = [
+        "//examples/junit/src/test/java/com/example:__pkg__",
+        "//src/main/java/com/code_intelligence/jazzer/driver/junit:__pkg__",
+        "//src/main/java/com/code_intelligence/jazzer/junit:__pkg__",
+        "//src/test:__subpackages__",
+    ],
+    deps = [
+        ":constants",
+        ":exception_utils",
+        ":fuzz_target_holder",
+        ":fuzzed_data_provider_impl",
+        ":opt",
+        ":recording_fuzzed_data_provider",
+        ":reproducer_template",
+        ":signal_handler",
+        "//src/main/java/com/code_intelligence/jazzer/api",
+        "//src/main/java/com/code_intelligence/jazzer/autofuzz",
+        "//src/main/java/com/code_intelligence/jazzer/instrumentor",
+        "//src/main/java/com/code_intelligence/jazzer/mutation",
+        "//src/main/java/com/code_intelligence/jazzer/runtime:constants",
+        "//src/main/java/com/code_intelligence/jazzer/runtime:jazzer_bootstrap_compile_only",
+        "//src/main/java/com/code_intelligence/jazzer/utils:log",
+        "//src/main/java/com/code_intelligence/jazzer/utils:manifest_utils",
+        "//src/main/java/com/code_intelligence/jazzer/utils:unsafe_provider",
+    ],
+)
+
+java_jni_library(
+    name = "fuzzed_data_provider_impl",
+    srcs = ["FuzzedDataProviderImpl.java"],
+    native_libs = ["//src/main/native/com/code_intelligence/jazzer/driver:jazzer_fuzzed_data_provider"],
+    visibility = [
+        "//src:__subpackages__",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/api",
+        "//src/main/java/com/code_intelligence/jazzer/utils:unsafe_provider",
+    ],
+)
+
+java_library(
+    name = "reproducer_template",
+    srcs = ["ReproducerTemplate.java"],
+    resources = ["Reproducer.java.tmpl"],
+    deps = [
+        ":opt",
+        "//src/main/java/com/code_intelligence/jazzer/utils:log",
+    ],
+)
+
+java_library(
+    name = "opt",
+    srcs = [
+        "Opt.java",
+        "OptParser.java",
+    ],
+    visibility = [
+        "//src/main/java/com/code_intelligence/jazzer/agent:__pkg__",
+        "//src/main/java/com/code_intelligence/jazzer/driver:__subpackages__",
+        "//src/main/java/com/code_intelligence/jazzer/junit:__pkg__",
+        "//src/test/java/com/code_intelligence/jazzer/driver:__subpackages__",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer:constants",
+        "//src/main/java/com/code_intelligence/jazzer/utils:log",
+    ],
+)
+
+java_library(
+    name = "recording_fuzzed_data_provider",
+    srcs = ["RecordingFuzzedDataProvider.java"],
+    visibility = ["//src/test/java/com/code_intelligence/jazzer/driver:__pkg__"],
+    deps = ["//src/main/java/com/code_intelligence/jazzer/api"],
+)
+
+java_jni_library(
+    name = "signal_handler",
+    srcs = ["SignalHandler.java"],
+    native_libs = ["//src/main/native/com/code_intelligence/jazzer/driver:jazzer_signal_handler"],
+    visibility = ["//src/main/native/com/code_intelligence/jazzer/driver:__pkg__"],
+    deps = [":opt"],
+)
+
+ktlint()
diff --git a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h b/src/main/java/com/code_intelligence/jazzer/driver/Constants.java
similarity index 68%
copy from driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
copy to src/main/java/com/code_intelligence/jazzer/driver/Constants.java
index 0e8846c..80d476d 100644
--- a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
+++ b/src/main/java/com/code_intelligence/jazzer/driver/Constants.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2021 Code Intelligence GmbH
+ * Copyright 2023 Code Intelligence GmbH
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,15 +14,11 @@
  * limitations under the License.
  */
 
-#pragma once
+package com.code_intelligence.jazzer.driver;
 
-#include <jni.h>
+public final class Constants {
+  // Default value of the libFuzzer -error_exitcode flag.
+  public static final int JAZZER_FINDING_EXIT_CODE = 77;
 
-namespace jazzer {
-/*
- * Print the stack traces of all active JVM threads.
- *
- * This function can be called from any thread.
- */
-void DumpJvmStackTraces();
-}  // namespace jazzer
+  private Constants(){};
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/driver/Driver.java b/src/main/java/com/code_intelligence/jazzer/driver/Driver.java
new file mode 100644
index 0000000..8640e6c
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/driver/Driver.java
@@ -0,0 +1,161 @@
+/*
+ * Copyright 2022 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.driver;
+
+import static com.code_intelligence.jazzer.runtime.Constants.IS_ANDROID;
+import static java.lang.System.exit;
+
+import com.code_intelligence.jazzer.agent.AgentInstaller;
+import com.code_intelligence.jazzer.driver.junit.JUnitRunner;
+import com.code_intelligence.jazzer.utils.Log;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.SecureRandom;
+import java.util.List;
+import java.util.Optional;
+
+public class Driver {
+  public static int start(List<String> args, boolean spawnsSubprocesses) throws IOException {
+    if (IS_ANDROID) {
+      if (!System.getProperty("jazzer.autofuzz", "").isEmpty()) {
+        Log.error("--autofuzz is not supported for Android");
+        return 1;
+      }
+      if (!System.getProperty("jazzer.coverage_report", "").isEmpty()) {
+        Log.warn("--coverage_report is not supported for Android and has been disabled");
+        System.clearProperty("jazzer.coverage_report");
+      }
+      if (!System.getProperty("jazzer.coverage_dump", "").isEmpty()) {
+        Log.warn("--coverage_dump is not supported for Android and has been disabled");
+        System.clearProperty("jazzer.coverage_dump");
+      }
+    }
+
+    if (spawnsSubprocesses) {
+      if (!System.getProperty("jazzer.coverage_report", "").isEmpty()) {
+        Log.warn("--coverage_report does not support parallel fuzzing and has been disabled");
+        System.clearProperty("jazzer.coverage_report");
+      }
+      if (!System.getProperty("jazzer.coverage_dump", "").isEmpty()) {
+        Log.warn("--coverage_dump does not support parallel fuzzing and has been disabled");
+        System.clearProperty("jazzer.coverage_dump");
+      }
+
+      String idSyncFileArg = System.getProperty("jazzer.id_sync_file", "");
+      Path idSyncFile;
+      if (idSyncFileArg.isEmpty()) {
+        // Create an empty temporary file used for coverage ID synchronization and
+        // pass its path to the agent in every child process. This requires adding
+        // the argument to argv for it to be picked up by libFuzzer, which then
+        // forwards it to child processes.
+        if (!IS_ANDROID) {
+          idSyncFile = Files.createTempFile("jazzer-", "");
+        } else {
+          File f = File.createTempFile("jazzer-", "", new File("/data/local/tmp/"));
+          idSyncFile = f.toPath();
+        }
+
+        args.add("--id_sync_file=" + idSyncFile.toAbsolutePath());
+      } else {
+        // Creates the file, truncating it if it exists.
+        idSyncFile = Files.write(Paths.get(idSyncFileArg), new byte[] {});
+      }
+      // This wouldn't run in case we exit the process with _Exit, but the parent process of a -fork
+      // run is expected to exit with a regular exit(0), which does cause JVM shutdown hooks to run:
+      // https://github.com/llvm/llvm-project/blob/940e178c0018b32af2f1478d331fc41a92a7dac7/compiler-rt/lib/fuzzer/FuzzerFork.cpp#L491
+      idSyncFile.toFile().deleteOnExit();
+    }
+
+    if (args.stream().anyMatch("-merge_inner=1" ::equals)) {
+      System.setProperty("jazzer.internal.merge_inner", "true");
+    }
+
+    // Jazzer's hooks use deterministic randomness and thus require a seed. Search for the last
+    // occurrence of a "-seed" argument as that is the one that is used by libFuzzer. If none is
+    // set, generate one and pass it to libFuzzer so that a fuzzing run can be reproduced simply by
+    // setting the seed printed by libFuzzer.
+    String seed = args.stream().reduce(
+        null, (prev, cur) -> cur.startsWith("-seed=") ? cur.substring("-seed=".length()) : prev);
+    if (seed == null) {
+      seed = Integer.toUnsignedString(new SecureRandom().nextInt());
+      // Only add the -seed argument to the command line if not running in a mode
+      // that spawns subprocesses. These would inherit the same seed, which might
+      // make them less effective.
+      if (!spawnsSubprocesses) {
+        args.add("-seed=" + seed);
+      }
+    }
+    System.setProperty("jazzer.internal.seed", seed);
+
+    if (args.stream().noneMatch(arg -> arg.startsWith("-rss_limit_mb="))) {
+      args.add(getDefaultRssLimitMbArg());
+    }
+
+    // Do not modify properties beyond this point, loading Opt locks in their values.
+    if (!Opt.instrumentOnly.isEmpty()) {
+      boolean instrumentationSuccess = OfflineInstrumentor.instrumentJars(Opt.instrumentOnly);
+      if (!instrumentationSuccess) {
+        exit(1);
+      }
+      exit(0);
+    }
+
+    Driver.class.getClassLoader().setDefaultAssertionStatus(true);
+
+    if (!Opt.autofuzz.isEmpty()) {
+      AgentInstaller.install(Opt.hooks);
+      FuzzTargetHolder.fuzzTarget = FuzzTargetHolder.AUTOFUZZ_FUZZ_TARGET;
+      return FuzzTargetRunner.startLibFuzzer(args);
+    }
+
+    String targetClassName = FuzzTargetFinder.findFuzzTargetClassName();
+    if (targetClassName == null) {
+      Log.error("Missing argument --target_class=<fuzz_target_class>");
+      exit(1);
+    }
+
+    // The JUnitRunner calls AgentInstaller.install itself after modifying flags affecting the
+    // agent.
+    if (JUnitRunner.isSupported()) {
+      Optional<JUnitRunner> runner = JUnitRunner.create(targetClassName, args);
+      if (runner.isPresent()) {
+        return runner.get().run();
+      }
+    }
+
+    // Installing the agent after the following "findFuzzTarget" leads to an asan error
+    // in it on "Class.forName(targetClassName)", but only during native fuzzing.
+    AgentInstaller.install(Opt.hooks);
+    FuzzTargetHolder.fuzzTarget = FuzzTargetFinder.findFuzzTarget(targetClassName);
+    return FuzzTargetRunner.startLibFuzzer(args);
+  }
+
+  private static String getDefaultRssLimitMbArg() {
+    // Java OutOfMemoryErrors are strictly more informative than libFuzzer's out of memory crashes.
+    // We thus want to scale the default libFuzzer memory limit, which includes all memory used by
+    // the process including Jazzer's native and non-native memory footprint, such that:
+    // 1. we never reach it purely by allocating memory on the Java heap;
+    // 2. it is still reached if the fuzz target allocates excessively on the native heap.
+    // As a heuristic, we set the overall memory limit to 2 * the maximum size of the Java heap and
+    // add a fixed 1 GiB on top for the fuzzer's own memory usage.
+    long maxHeapInBytes = Runtime.getRuntime().maxMemory();
+    return "-rss_limit_mb=" + ((2 * maxHeapInBytes / (1024 * 1024)) + 1024);
+  }
+}
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/utils/ExceptionUtils.kt b/src/main/java/com/code_intelligence/jazzer/driver/ExceptionUtils.kt
similarity index 71%
rename from agent/src/main/java/com/code_intelligence/jazzer/utils/ExceptionUtils.kt
rename to src/main/java/com/code_intelligence/jazzer/driver/ExceptionUtils.kt
index 30f6fb3..ed4b056 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/utils/ExceptionUtils.kt
+++ b/src/main/java/com/code_intelligence/jazzer/driver/ExceptionUtils.kt
@@ -14,13 +14,27 @@
 
 @file:JvmName("ExceptionUtils")
 
-package com.code_intelligence.jazzer.utils
+package com.code_intelligence.jazzer.driver
 
 import com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow
+import com.code_intelligence.jazzer.runtime.Constants.IS_ANDROID
+import com.code_intelligence.jazzer.utils.Log
 import java.lang.management.ManagementFactory
 import java.nio.ByteBuffer
 import java.security.MessageDigest
 
+private val JAZZER_PACKAGE_PREFIX = "com.code_intelligence.jazzer."
+private val PUBLIC_JAZZER_PACKAGES = setOf("api", "replay", "sanitizers")
+
+private val StackTraceElement.isInternalFrame: Boolean
+    get() = if (!className.startsWith(JAZZER_PACKAGE_PREFIX)) {
+        false
+    } else {
+        val jazzerSubPackage =
+            className.substring(JAZZER_PACKAGE_PREFIX.length).split(".", limit = 2)[0]
+        jazzerSubPackage !in PUBLIC_JAZZER_PACKAGES
+    }
+
 private fun hash(throwable: Throwable, passToRootCause: Boolean): ByteArray =
     MessageDigest.getInstance("SHA-256").run {
         // It suffices to hash the stack trace of the deepest cause as the higher-level causes only
@@ -32,9 +46,15 @@
             }
         }
         update(rootCause.javaClass.name.toByteArray())
-        for (element in rootCause.stackTrace) {
-            update(element.toString().toByteArray())
-        }
+        rootCause.stackTrace
+            .takeWhile { !it.isInternalFrame }
+            .filterNot {
+                it.className.startsWith("jdk.internal.") ||
+                    it.className.startsWith("java.lang.reflect.") ||
+                    it.className.startsWith("sun.reflect.") ||
+                    it.className.startsWith("java.lang.invoke.")
+            }
+            .forEach { update(it.toString().toByteArray()) }
         if (throwable.suppressed.isNotEmpty()) {
             update("suppressed".toByteArray())
             for (suppressed in throwable.suppressed) {
@@ -77,17 +97,34 @@
         val bottomFramesWithoutRepetition = throwable.stackTrace.takeLastWhile { frame ->
             (frame !in observedFrames).also { observedFrames.add(frame) }
         }
-        FuzzerSecurityIssueLow("Stack overflow (use '${getReproducingXssArg()}' to reproduce)", throwable).apply {
+        var securityIssueMessage = "Stack overflow"
+        if (!IS_ANDROID) {
+            securityIssueMessage = "$securityIssueMessage (use '${getReproducingXssArg()}' to reproduce)"
+        }
+        FuzzerSecurityIssueLow(securityIssueMessage, throwable).apply {
             stackTrace = bottomFramesWithoutRepetition.toTypedArray()
         }
     }
-    is OutOfMemoryError -> stripOwnStackTrace(
-        FuzzerSecurityIssueLow(
-            "Out of memory (use '${getReproducingXmxArg()}' to reproduce)", throwable
-        )
-    )
+    is OutOfMemoryError -> {
+        var securityIssueMessage = "Out of memory"
+        if (!IS_ANDROID) {
+            securityIssueMessage = "$securityIssueMessage (use '${getReproducingXmxArg()}' to reproduce)"
+        }
+        stripOwnStackTrace(FuzzerSecurityIssueLow(securityIssueMessage, throwable))
+    }
     is VirtualMachineError -> stripOwnStackTrace(FuzzerSecurityIssueLow(throwable))
     else -> throwable
+}.also { dropInternalFrames(it) }
+
+/**
+ * Recursively strips all Jazzer-internal stack frames from the given [Throwable] and its causes.
+ */
+private fun dropInternalFrames(throwable: Throwable?) {
+    throwable?.run {
+        stackTrace = stackTrace.takeWhile { !it.isInternalFrame }.toTypedArray()
+        suppressed.forEach { it.stackTrace = stackTrace.takeWhile { !it.isInternalFrame }.toTypedArray() }
+        dropInternalFrames(throwable.cause)
+    }
 }
 
 /**
@@ -134,8 +171,8 @@
     val javaPrintFlagsProcess = ProcessBuilder(
         listOf(javaBinary) + currentJvmArgs + listOf(
             "-XX:+PrintFlagsFinal",
-            "-version"
-        )
+            "-version",
+        ),
     ).start()
     return javaPrintFlagsProcess.inputStream.bufferedReader().useLines { lineSequence ->
         lineSequence
@@ -145,28 +182,34 @@
 }
 
 fun dumpAllStackTraces() {
-    System.err.println("\nStack traces of all JVM threads:\n")
+    Log.println("\nStack traces of all JVM threads:")
     for ((thread, stack) in Thread.getAllStackTraces()) {
-        System.err.println(thread)
+        Log.println(thread.toString())
         // Remove traces of this method and the methods it calls.
         stack.asList()
             .asReversed()
             .takeWhile {
                 !(
-                    it.className == "com.code_intelligence.jazzer.runtime.ExceptionUtils" &&
+                    it.className == "com.code_intelligence.jazzer.driver.ExceptionUtils" &&
                         it.methodName == "dumpAllStackTraces"
                     )
             }
             .asReversed()
             .forEach { frame ->
-                System.err.println("\tat $frame")
+                Log.println("\tat $frame")
             }
-        System.err.println()
+        Log.println("")
     }
-    System.err.println("Garbage collector stats:")
-    System.err.println(
+
+    if (IS_ANDROID) {
+        // ManagementFactory is not supported on Android
+        return
+    }
+
+    Log.println("Garbage collector stats:")
+    Log.println(
         ManagementFactory.getGarbageCollectorMXBeans().joinToString("\n", "\n", "\n") {
             "${it.name}: ${it.collectionCount} collections took ${it.collectionTime}ms"
-        }
+        },
     )
 }
diff --git a/src/main/java/com/code_intelligence/jazzer/driver/FuzzTargetFinder.java b/src/main/java/com/code_intelligence/jazzer/driver/FuzzTargetFinder.java
new file mode 100644
index 0000000..c2c4177
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/driver/FuzzTargetFinder.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright 2022 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.driver;
+
+import static com.code_intelligence.jazzer.runtime.Constants.IS_ANDROID;
+import static java.lang.System.exit;
+
+import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import com.code_intelligence.jazzer.driver.FuzzTargetHolder.FuzzTarget;
+import com.code_intelligence.jazzer.utils.Log;
+import com.code_intelligence.jazzer.utils.ManifestUtils;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.Callable;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+class FuzzTargetFinder {
+  private static final String FUZZER_TEST_ONE_INPUT = "fuzzerTestOneInput";
+  private static final String FUZZER_INITIALIZE = "fuzzerInitialize";
+  private static final String FUZZER_TEAR_DOWN = "fuzzerTearDown";
+
+  static String findFuzzTargetClassName() {
+    if (!Opt.targetClass.isEmpty()) {
+      return Opt.targetClass;
+    }
+    if (IS_ANDROID) {
+      // Fuzz target detection tools aren't supported on android
+      return null;
+    }
+    return ManifestUtils.detectFuzzTargetClass();
+  }
+
+  /**
+   * @throws IllegalArgumentException if the fuzz target method is invalid or couldn't be found
+   * @param targetClassName name of the fuzz target class
+   * @return a {@link FuzzTarget}
+   */
+  static FuzzTarget findFuzzTarget(String targetClassName) {
+    Class<?> fuzzTargetClass;
+    try {
+      fuzzTargetClass =
+          Class.forName(targetClassName, false, FuzzTargetFinder.class.getClassLoader());
+    } catch (ClassNotFoundException e) {
+      Log.error(String.format(
+          "'%s' not found on classpath:%n%n%s%n%nAll required classes must be on the classpath specified via --cp.",
+          targetClassName, System.getProperty("java.class.path")));
+      exit(1);
+      throw new IllegalStateException("Not reached");
+    }
+
+    return findFuzzTargetByMethodName(fuzzTargetClass);
+  }
+
+  // Finds the traditional static fuzzerTestOneInput fuzz target method.
+  private static FuzzTarget findFuzzTargetByMethodName(Class<?> clazz) {
+    Method fuzzTargetMethod;
+    if (Opt.experimentalMutator) {
+      List<Method> fuzzTargetMethods =
+          Arrays.stream(clazz.getMethods())
+              .filter(method -> "fuzzerTestOneInput".equals(method.getName()))
+              .filter(method -> Modifier.isStatic(method.getModifiers()))
+              .collect(Collectors.toList());
+      if (fuzzTargetMethods.size() != 1) {
+        throw new IllegalArgumentException(
+            String.format("%s must define exactly one function of this form:%n"
+                    + "public static void fuzzerTestOneInput(...)%n",
+                clazz.getName()));
+      }
+      fuzzTargetMethod = fuzzTargetMethods.get(0);
+    } else {
+      Optional<Method> bytesFuzzTarget =
+          targetPublicStaticMethod(clazz, FUZZER_TEST_ONE_INPUT, byte[].class);
+      Optional<Method> dataFuzzTarget =
+          targetPublicStaticMethod(clazz, FUZZER_TEST_ONE_INPUT, FuzzedDataProvider.class);
+      if (bytesFuzzTarget.isPresent() == dataFuzzTarget.isPresent()) {
+        throw new IllegalArgumentException(String.format(
+            "%s must define exactly one of the following two functions:%n"
+                + "public static void fuzzerTestOneInput(byte[] ...)%n"
+                + "public static void fuzzerTestOneInput(FuzzedDataProvider ...)%n"
+                + "Note: Fuzz targets returning boolean are no longer supported; exceptions should be thrown instead of returning true.",
+            clazz.getName()));
+      }
+      fuzzTargetMethod = dataFuzzTarget.orElseGet(bytesFuzzTarget::get);
+    }
+
+    Callable<Object> initialize =
+        Stream
+            .of(targetPublicStaticMethod(clazz, FUZZER_INITIALIZE, String[].class)
+                    .map(init -> (Callable<Object>) () -> {
+                      init.invoke(null, (Object) Opt.targetArgs.toArray(new String[] {}));
+                      return null;
+                    }),
+                targetPublicStaticMethod(clazz, FUZZER_INITIALIZE)
+                    .map(init -> (Callable<Object>) () -> {
+                      init.invoke(null);
+                      return null;
+                    }))
+            .filter(Optional::isPresent)
+            .map(Optional::get)
+            .findFirst()
+            .orElse(() -> null);
+
+    return new FuzzTarget(
+        fuzzTargetMethod, initialize, targetPublicStaticMethod(clazz, FUZZER_TEAR_DOWN));
+  }
+
+  private static Optional<Method> targetPublicStaticMethod(
+      Class<?> clazz, String name, Class<?>... parameterTypes) {
+    try {
+      Method method = clazz.getMethod(name, parameterTypes);
+      if (!Modifier.isStatic(method.getModifiers()) || !Modifier.isPublic(method.getModifiers())) {
+        return Optional.empty();
+      }
+      return Optional.of(method);
+    } catch (NoSuchMethodException e) {
+      return Optional.empty();
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/driver/FuzzTargetHolder.java b/src/main/java/com/code_intelligence/jazzer/driver/FuzzTargetHolder.java
new file mode 100644
index 0000000..e748c6f
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/driver/FuzzTargetHolder.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.driver;
+
+import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import java.lang.reflect.Method;
+import java.util.Optional;
+import java.util.concurrent.Callable;
+
+public class FuzzTargetHolder {
+  public static FuzzTarget autofuzzFuzzTarget(Callable<Object> newInstance) {
+    try {
+      Method fuzzerTestOneInput = com.code_intelligence.jazzer.autofuzz.FuzzTarget.class.getMethod(
+          "fuzzerTestOneInput", FuzzedDataProvider.class);
+      return new FuzzTargetHolder.FuzzTarget(fuzzerTestOneInput, newInstance, Optional.empty());
+    } catch (NoSuchMethodException e) {
+      throw new IllegalStateException(e);
+    }
+  }
+
+  public static final FuzzTarget AUTOFUZZ_FUZZ_TARGET = autofuzzFuzzTarget(() -> {
+    com.code_intelligence.jazzer.autofuzz.FuzzTarget.fuzzerInitialize(
+        Opt.targetArgs.toArray(new String[0]));
+    return null;
+  });
+
+  /**
+   * The fuzz target that {@link FuzzTargetRunner} should fuzz.
+   */
+  public static FuzzTarget fuzzTarget;
+
+  public static class FuzzTarget {
+    public final Method method;
+    public final Callable<Object> newInstance;
+    public final Optional<Method> tearDown;
+
+    public FuzzTarget(Method method, Callable<Object> newInstance, Optional<Method> tearDown) {
+      this.method = method;
+      this.newInstance = newInstance;
+      this.tearDown = tearDown;
+    }
+
+    public boolean usesFuzzedDataProvider() {
+      return this.method.getParameterCount() == 1
+          && this.method.getParameterTypes()[0] == FuzzedDataProvider.class;
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/driver/FuzzTargetRunner.java b/src/main/java/com/code_intelligence/jazzer/driver/FuzzTargetRunner.java
new file mode 100644
index 0000000..aefa535
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/driver/FuzzTargetRunner.java
@@ -0,0 +1,542 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.driver;
+
+import static com.code_intelligence.jazzer.driver.Constants.JAZZER_FINDING_EXIT_CODE;
+import static com.code_intelligence.jazzer.runtime.Constants.IS_ANDROID;
+import static java.lang.System.exit;
+import static java.util.stream.Collectors.joining;
+
+import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import com.code_intelligence.jazzer.autofuzz.FuzzTarget;
+import com.code_intelligence.jazzer.instrumentor.CoverageRecorder;
+import com.code_intelligence.jazzer.mutation.ArgumentsMutator;
+import com.code_intelligence.jazzer.runtime.FuzzTargetRunnerNatives;
+import com.code_intelligence.jazzer.runtime.JazzerInternal;
+import com.code_intelligence.jazzer.utils.Log;
+import com.code_intelligence.jazzer.utils.UnsafeProvider;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.function.Predicate;
+import java.util.stream.Stream;
+import sun.misc.Unsafe;
+
+/**
+ * Executes a fuzz target and reports findings.
+ *
+ * <p>This class maintains global state (both native and non-native) and thus cannot be used
+ * concurrently.
+ */
+public final class FuzzTargetRunner {
+  private static final String OPENTEST4J_TEST_ABORTED_EXCEPTION =
+      "org.opentest4j.TestAbortedException";
+
+  private static final Unsafe UNSAFE = UnsafeProvider.getUnsafe();
+
+  private static final long BYTE_ARRAY_OFFSET = UNSAFE.arrayBaseOffset(byte[].class);
+
+  // Possible return values for the libFuzzer callback runOne.
+  private static final int LIBFUZZER_CONTINUE = 0;
+  private static final int LIBFUZZER_RETURN_FROM_DRIVER = -2;
+
+  private static boolean invalidCorpusFileWarningShown = false;
+  private static final Set<Long> ignoredTokens = new HashSet<>(Opt.ignore);
+  private static final FuzzedDataProviderImpl fuzzedDataProvider =
+      FuzzedDataProviderImpl.withNativeData();
+  private static final MethodHandle fuzzTargetMethod;
+  private static final boolean useFuzzedDataProvider;
+  // Reused in every iteration analogous to JUnit's PER_CLASS lifecycle.
+  private static final Object fuzzTargetInstance;
+  private static final Method fuzzerTearDown;
+  private static final ArgumentsMutator mutator;
+  private static final ReproducerTemplate reproducerTemplate;
+  private static Predicate<Throwable> findingHandler;
+
+  static {
+    FuzzTargetHolder.FuzzTarget fuzzTarget = FuzzTargetHolder.fuzzTarget;
+    Class<?> fuzzTargetClass = fuzzTarget.method.getDeclaringClass();
+
+    // The method may not be accessible - JUnit test classes and methods are usually declared
+    // without access modifiers and thus package-private.
+    fuzzTarget.method.setAccessible(true);
+    try {
+      fuzzTargetMethod = MethodHandles.lookup().unreflect(fuzzTarget.method);
+    } catch (IllegalAccessException e) {
+      throw new IllegalStateException(e);
+    }
+    useFuzzedDataProvider = fuzzTarget.usesFuzzedDataProvider();
+    if (!useFuzzedDataProvider && IS_ANDROID) {
+      Log.error("Android fuzz targets must use " + FuzzedDataProvider.class.getName());
+      exit(1);
+      throw new IllegalStateException("Not reached");
+    }
+
+    fuzzerTearDown = fuzzTarget.tearDown.orElse(null);
+    reproducerTemplate = new ReproducerTemplate(fuzzTargetClass.getName(), useFuzzedDataProvider);
+
+    JazzerInternal.onFuzzTargetReady(fuzzTargetClass.getName());
+
+    try {
+      fuzzTargetInstance = fuzzTarget.newInstance.call();
+    } catch (Throwable t) {
+      Log.finding(t);
+      exit(1);
+      throw new IllegalStateException("Not reached");
+    }
+
+    if (Opt.experimentalMutator) {
+      if (Modifier.isStatic(fuzzTarget.method.getModifiers())) {
+        mutator = ArgumentsMutator.forStaticMethodOrThrow(fuzzTarget.method);
+      } else {
+        mutator = ArgumentsMutator.forInstanceMethodOrThrow(fuzzTargetInstance, fuzzTarget.method);
+      }
+      Log.info("Using experimental mutator: " + mutator);
+    } else {
+      mutator = null;
+    }
+
+    if (Opt.hooks) {
+      // libFuzzer will clear the coverage map after this method returns and keeps no record of the
+      // coverage accumulated so far (e.g. by static initializers). We record it here to keep it
+      // around for JaCoCo coverage reports.
+      CoverageRecorder.updateCoveredIdsWithCoverageMap();
+    }
+
+    Runtime.getRuntime().addShutdownHook(new Thread(FuzzTargetRunner::shutdown));
+  }
+
+  /**
+   * A test-only convenience wrapper around {@link #runOne(long, int)}.
+   */
+  static int runOne(byte[] data) {
+    long dataPtr = UNSAFE.allocateMemory(data.length);
+    UNSAFE.copyMemory(data, BYTE_ARRAY_OFFSET, null, dataPtr, data.length);
+    try {
+      return runOne(dataPtr, data.length);
+    } finally {
+      UNSAFE.freeMemory(dataPtr);
+    }
+  }
+
+  /**
+   * Executes the user-provided fuzz target once.
+   *
+   * @param dataPtr    a native pointer to beginning of the input provided by the fuzzer for this
+   *                   execution
+   * @param dataLength length of the fuzzer input
+   * @return the value that the native LLVMFuzzerTestOneInput function should return. Currently,
+   * this is always 0. The function may exit the process instead of returning.
+   */
+  private static int runOne(long dataPtr, int dataLength) {
+    Throwable finding = null;
+    byte[] data;
+    Object argument;
+    if (Opt.experimentalMutator) {
+      // TODO: Instead of copying the native data and then reading it in, consider the following
+      //  optimizations if they turn out to be worthwhile in benchmarks:
+      //  1. Let libFuzzer pass in a null pointer if the byte array hasn't changed since the last
+      //     call to our custom mutator and skip the read entirely.
+      //  2. Implement a InputStream backed by Unsafe to avoid the copyToArray overhead.
+      byte[] buf = copyToArray(dataPtr, dataLength);
+      boolean readExactly = mutator.read(new ByteArrayInputStream(buf));
+
+      // All inputs constructed by the mutator framework can be read exactly, existing corpus files
+      // may not be valid for the current fuzz target anymore, though. In this case, print a warning
+      // once.
+      if (!(invalidCorpusFileWarningShown || readExactly || isFixedLibFuzzerInput(buf))) {
+        invalidCorpusFileWarningShown = true;
+        Log.warn("Some files in the seed corpus do not match the fuzz target signature. "
+            + "This indicates that they were generated with a different signature and may cause issues reproducing previous findings.");
+      }
+      data = null;
+      argument = null;
+    } else if (useFuzzedDataProvider) {
+      fuzzedDataProvider.setNativeData(dataPtr, dataLength);
+      data = null;
+      argument = fuzzedDataProvider;
+    } else {
+      data = copyToArray(dataPtr, dataLength);
+      argument = data;
+    }
+    try {
+      if (Opt.experimentalMutator) {
+        // No need to detach as we are currently reading in the mutator state from bytes in every
+        // iteration.
+        mutator.invoke(false);
+      } else if (fuzzTargetInstance == null) {
+        fuzzTargetMethod.invoke(argument);
+      } else {
+        fuzzTargetMethod.invoke(fuzzTargetInstance, argument);
+      }
+    } catch (Throwable uncaughtFinding) {
+      finding = uncaughtFinding;
+    }
+
+    // When using libFuzzer's -merge flag, only the coverage of the current input is relevant, not
+    // whether it is crashing. Since every crash would cause a restart of the process and thus the
+    // JVM, we can optimize this case by not crashing.
+    //
+    // Incidentally, this makes the behavior of fuzz targets relying on global states more
+    // consistent: Rather than resetting the global state after every crashing input and thus
+    // dependent on the particular ordering of the inputs, we never reset it.
+    if (Opt.mergeInner) {
+      return LIBFUZZER_CONTINUE;
+    }
+
+    // Explicitly reported findings take precedence over uncaught exceptions.
+    if (JazzerInternal.lastFinding != null) {
+      finding = JazzerInternal.lastFinding;
+      JazzerInternal.lastFinding = null;
+    }
+    // Allow skipping invalid inputs in fuzz tests by using e.g. JUnit's assumeTrue.
+    if (finding == null || finding.getClass().getName().equals(OPENTEST4J_TEST_ABORTED_EXCEPTION)) {
+      return LIBFUZZER_CONTINUE;
+    }
+    if (Opt.hooks) {
+      finding = ExceptionUtils.preprocessThrowable(finding);
+    }
+
+    long dedupToken = Opt.dedup ? ExceptionUtils.computeDedupToken(finding) : 0;
+    if (Opt.dedup && !ignoredTokens.add(dedupToken)) {
+      return LIBFUZZER_CONTINUE;
+    }
+
+    if (findingHandler != null) {
+      // We still print the libFuzzer crashing input information, which also dumps the crashing
+      // input as a side effect.
+      printCrashingInput();
+      if (findingHandler.test(finding)) {
+        return LIBFUZZER_CONTINUE;
+      } else {
+        return LIBFUZZER_RETURN_FROM_DRIVER;
+      }
+    }
+
+    // The user-provided fuzz target method has returned. Any further exits are on us and should not
+    // result in a "fuzz target exited" warning being printed by libFuzzer.
+    temporarilyDisableLibfuzzerExitHook();
+
+    Log.finding(finding);
+    if (Opt.dedup) {
+      // Has to be printed to stdout as it is parsed by libFuzzer when minimizing a crash. It does
+      // not necessarily have to appear at the beginning of a line.
+      // https://github.com/llvm/llvm-project/blob/4c106c93eb68f8f9f201202677cd31e326c16823/compiler-rt/lib/fuzzer/FuzzerDriver.cpp#L342
+      Log.structuredOutput(String.format(Locale.ROOT, "DEDUP_TOKEN: %016x", dedupToken));
+    }
+    Log.println("== libFuzzer crashing input ==");
+    printCrashingInput();
+    // dumpReproducer needs to be called after libFuzzer printed its final stats as otherwise it
+    // would report incorrect coverage - the reproducer generation involved rerunning the fuzz
+    // target.
+    // It doesn't support @FuzzTest fuzz targets, but these come with an integrated regression test
+    // that satisfies the same purpose.
+    // It also doesn't support the experimental mutator yet as that requires implementing Java code
+    // generation for mutators.
+    if (fuzzTargetInstance == null && !Opt.experimentalMutator) {
+      dumpReproducer(data);
+    }
+
+    if (!Opt.dedup || Long.compareUnsigned(ignoredTokens.size(), Opt.keepGoing) >= 0) {
+      // Reached the maximum amount of findings to keep going for, crash after shutdown. We use
+      // _Exit rather than System.exit to not trigger libFuzzer's exit handlers.
+      if (!Opt.autofuzz.isEmpty() && Opt.dedup) {
+        Log.println("");
+        Log.info(String.format(
+            "To continue fuzzing past this particular finding, rerun with the following additional argument:"
+                + "%n%n    --ignore=%s%n%n"
+                + "To ignore all findings of this kind, rerun with the following additional argument:"
+                + "%n%n    --autofuzz_ignore=%s",
+            ignoredTokens.stream()
+                .map(token -> Long.toUnsignedString(token, 16))
+                .collect(joining(",")),
+            Stream.concat(Opt.autofuzzIgnore.stream(), Stream.of(finding.getClass().getName()))
+                .collect(joining(","))));
+      }
+      System.exit(JAZZER_FINDING_EXIT_CODE);
+      throw new IllegalStateException("Not reached");
+    }
+    return LIBFUZZER_CONTINUE;
+  }
+
+  private static boolean isFixedLibFuzzerInput(byte[] input) {
+    // Detect special libFuzzer inputs which can not be processed by the mutator framework.
+    // libFuzzer always uses an empty input, and one with a single line feed (10) to indicate
+    // end of initial corpus file processing.
+    return input.length == 0 || (input.length == 1 && input[0] == 10);
+  }
+
+  // Called via JNI, being passed data from LLVMFuzzerCustomMutator.
+  @SuppressWarnings("unused")
+  private static int mutateOne(long data, int size, int maxSize, int seed) {
+    mutate(data, size, seed);
+    return writeToMemory(mutator, data, maxSize);
+  }
+
+  private static void mutate(long data, int size, int seed) {
+    // libFuzzer sends the input "\n" when there are no corpus entries. We use that as a signal to
+    // initialize the mutator instead of just reading that trivial input to produce a more
+    // interesting value.
+    if (size == 1 && UNSAFE.getByte(data) == '\n') {
+      mutator.init(seed);
+    } else {
+      // TODO: See the comment on earlier calls to read for potential optimizations.
+      mutator.read(new ByteArrayInputStream(copyToArray(data, size)));
+      mutator.mutate(seed);
+    }
+  }
+
+  private static long crossOverCount = 0;
+
+  // Called via JNI, being passed data from LLVMFuzzerCustomCrossOver.
+  @SuppressWarnings("unused")
+  private static int crossOver(
+      long data1, int size1, long data2, int size2, long out, int maxOutSize, int seed) {
+    // Custom cross over and custom mutate are the only mutators registered in
+    // libFuzzer, hence cross over is picked as often as mutate, which is way
+    // too frequently. Without custom mutate, cross over would be picked from
+    // the list of default mutators, so ~1/12 of the time. This also seems too
+    // much and is reduced to a configurable frequency, default 1/100, here,
+    // mutate is used in the other cases.
+    if (Opt.experimentalCrossOverFrequency != 0
+        && crossOverCount++ % Opt.experimentalCrossOverFrequency == 0) {
+      mutator.crossOver(new ByteArrayInputStream(copyToArray(data1, size1)),
+          new ByteArrayInputStream(copyToArray(data2, size2)), seed);
+    } else {
+      mutate(data1, size1, seed);
+    }
+    return writeToMemory(mutator, out, maxOutSize);
+  }
+
+  @SuppressWarnings("SameParameterValue")
+  private static int writeToMemory(ArgumentsMutator mutator, long out, int maxOutSize) {
+    ByteArrayOutputStream baos = new ByteArrayOutputStream();
+    // TODO: Instead of writing to a byte array and then copying that array's contents into
+    //  memory, consider introducing an OutputStream backed by Unsafe.
+    mutator.write(baos);
+    byte[] mutatedBytes = baos.toByteArray();
+    int newSize = Math.min(mutatedBytes.length, maxOutSize);
+    UNSAFE.copyMemory(mutatedBytes, BYTE_ARRAY_OFFSET, null, out, newSize);
+    return newSize;
+  }
+
+  /*
+   * Starts libFuzzer via LLVMFuzzerRunDriver.
+   */
+  public static int startLibFuzzer(List<String> args) {
+    // We always define LLVMFuzzerCustomMutator, but only use it when --experimental_mutator is
+    // specified. libFuzzer contains logic that disables --len_control when it finds the custom
+    // mutator symbol:
+    // https://github.com/llvm/llvm-project/blob/da3623de2411dd931913eb510e94fe846c929c24/compiler-rt/lib/fuzzer/FuzzerDriver.cpp#L202-L207
+    // We thus have to explicitly set --len_control to its default value when not using the new
+    // mutator.
+    // TODO: libFuzzer still emits a message about --len_control being disabled by default even if
+    //  we override it via a flag. We may want to patch this out.
+    if (!Opt.experimentalMutator) {
+      // args may not be mutable.
+      args = new ArrayList<>(args);
+      // https://github.com/llvm/llvm-project/blob/da3623de2411dd931913eb510e94fe846c929c24/compiler-rt/lib/fuzzer/FuzzerFlags.def#L19
+      args.add("-len_control=100");
+    }
+
+    for (String arg : args.subList(1, args.size())) {
+      if (!arg.startsWith("-")) {
+        Log.info("using inputs from: " + arg);
+      }
+    }
+
+    if (!IS_ANDROID) {
+      SignalHandler.initialize();
+    }
+    return startLibFuzzer(
+        args.stream().map(str -> str.getBytes(StandardCharsets.UTF_8)).toArray(byte[][] ::new));
+  }
+
+  /**
+   * Registers a custom handler for findings.
+   *
+   * @param findingHandler a consumer for the finding that returns true if the fuzzer should
+   *                       continue fuzzing and false if it should return from
+   *                       {@link FuzzTargetRunner#startLibFuzzer(List)}.
+   */
+  public static void registerFindingHandler(Predicate<Throwable> findingHandler) {
+    FuzzTargetRunner.findingHandler = findingHandler;
+  }
+
+  private static void shutdown() {
+    if (!Opt.coverageDump.isEmpty() || !Opt.coverageReport.isEmpty()) {
+      if (!Opt.coverageDump.isEmpty()) {
+        CoverageRecorder.dumpJacocoCoverage(Opt.coverageDump);
+      }
+      if (!Opt.coverageReport.isEmpty()) {
+        CoverageRecorder.dumpCoverageReport(Opt.coverageReport);
+      }
+    }
+
+    if (fuzzerTearDown == null) {
+      return;
+    }
+    Log.info("calling fuzzerTearDown function");
+    try {
+      fuzzerTearDown.invoke(null);
+    } catch (InvocationTargetException e) {
+      Log.finding(e.getCause());
+      System.exit(JAZZER_FINDING_EXIT_CODE);
+    } catch (Throwable t) {
+      Log.error(t);
+      System.exit(1);
+    }
+  }
+
+  private static void dumpReproducer(byte[] data) {
+    if (data == null) {
+      assert useFuzzedDataProvider;
+      fuzzedDataProvider.reset();
+      data = fuzzedDataProvider.consumeRemainingAsBytes();
+    }
+    MessageDigest digest;
+    try {
+      digest = MessageDigest.getInstance("SHA-1");
+    } catch (NoSuchAlgorithmException e) {
+      throw new IllegalStateException("SHA-1 not available", e);
+    }
+    String dataSha1 = toHexString(digest.digest(data));
+
+    if (!Opt.autofuzz.isEmpty()) {
+      fuzzedDataProvider.reset();
+      FuzzTarget.dumpReproducer(fuzzedDataProvider, Opt.reproducerPath, dataSha1);
+      return;
+    }
+
+    String base64Data;
+    if (useFuzzedDataProvider) {
+      fuzzedDataProvider.reset();
+      FuzzedDataProvider recordingFuzzedDataProvider =
+          RecordingFuzzedDataProvider.makeFuzzedDataProviderProxy(fuzzedDataProvider);
+      try {
+        fuzzTargetMethod.invokeExact(recordingFuzzedDataProvider);
+        if (JazzerInternal.lastFinding == null) {
+          Log.warn("Failed to reproduce crash when rerunning with recorder");
+        }
+      } catch (Throwable ignored) {
+        // Expected.
+      }
+      try {
+        base64Data = RecordingFuzzedDataProvider.serializeFuzzedDataProviderProxy(
+            recordingFuzzedDataProvider);
+      } catch (IOException e) {
+        Log.error("Failed to create reproducer", e);
+        // Don't let libFuzzer print a native stack trace.
+        System.exit(1);
+        throw new IllegalStateException("Not reached");
+      }
+    } else {
+      base64Data = Base64.getEncoder().encodeToString(data);
+    }
+
+    reproducerTemplate.dumpReproducer(base64Data, dataSha1);
+  }
+
+  /**
+   * Convert a byte array to a lower-case hex string.
+   *
+   * <p>The returned hex string always has {@code 2 * bytes.length} characters.
+   *
+   * @param bytes the bytes to convert
+   * @return a lower-case hex string representing the bytes
+   */
+  private static String toHexString(byte[] bytes) {
+    String unpadded = new BigInteger(1, bytes).toString(16);
+    int numLeadingZeroes = 2 * bytes.length - unpadded.length();
+    return String.join("", Collections.nCopies(numLeadingZeroes, "0")) + unpadded;
+  }
+
+  // Accessed by fuzz_target_runner.cpp.
+  @SuppressWarnings("unused")
+  private static void dumpAllStackTraces() {
+    ExceptionUtils.dumpAllStackTraces();
+  }
+
+  private static byte[] copyToArray(long ptr, int length) {
+    // TODO: Use Unsafe.allocateUninitializedArray instead once Java 9 is the base.
+    byte[] array = new byte[length];
+    UNSAFE.copyMemory(null, ptr, array, BYTE_ARRAY_OFFSET, length);
+    return array;
+  }
+
+  /**
+   * Starts libFuzzer via LLVMFuzzerRunDriver.
+   *
+   * @param args command-line arguments encoded in UTF-8 (not null-terminated)
+   * @return the return value of LLVMFuzzerRunDriver
+   */
+  private static int startLibFuzzer(byte[][] args) {
+    return FuzzTargetRunnerNatives.startLibFuzzer(
+        args, FuzzTargetRunner.class, Opt.experimentalMutator);
+  }
+
+  /**
+   * Causes libFuzzer to write the current input to disk as a crashing input and emit some
+   * information about it to stderr.
+   */
+  public static void printCrashingInput() {
+    FuzzTargetRunnerNatives.printCrashingInput();
+  }
+
+  /**
+   * Returns the debug string of the current mutator.
+   * If no mutator is used, returns null.
+   */
+  public static String mutatorDebugString() {
+    return mutator != null ? mutator.toString() : null;
+  }
+
+  /**
+   * Returns whether the current mutator has detected invalid corpus files.
+   * If no mutator is used, returns false.
+   */
+  public static boolean invalidCorpusFilesPresent() {
+    return mutator != null && invalidCorpusFileWarningShown;
+  }
+
+  /**
+   * Disables libFuzzer's fuzz target exit detection until the next call to {@link #runOne}.
+   *
+   * <p>Calling {@link System#exit} after having called this method will not trigger libFuzzer's
+   * exit hook that would otherwise print the "fuzz target exited" error message. This method should
+   * thus only be called after control has returned from the user-provided fuzz target.
+   */
+  private static void temporarilyDisableLibfuzzerExitHook() {
+    FuzzTargetRunnerNatives.temporarilyDisableLibfuzzerExitHook();
+  }
+}
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/runtime/FuzzedDataProviderImpl.java b/src/main/java/com/code_intelligence/jazzer/driver/FuzzedDataProviderImpl.java
similarity index 91%
rename from agent/src/main/java/com/code_intelligence/jazzer/runtime/FuzzedDataProviderImpl.java
rename to src/main/java/com/code_intelligence/jazzer/driver/FuzzedDataProviderImpl.java
index b7aad33..08e5298 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/runtime/FuzzedDataProviderImpl.java
+++ b/src/main/java/com/code_intelligence/jazzer/driver/FuzzedDataProviderImpl.java
@@ -12,24 +12,22 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.code_intelligence.jazzer.runtime;
+package com.code_intelligence.jazzer.driver;
 
 import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import com.code_intelligence.jazzer.utils.UnsafeProvider;
 import com.github.fmeum.rules_jni.RulesJni;
 import sun.misc.Unsafe;
 
 public class FuzzedDataProviderImpl implements FuzzedDataProvider, AutoCloseable {
   static {
-    // The replayer loads a standalone version of the FuzzedDataProvider.
-    if (System.getProperty("jazzer.is_replayer") == null) {
-      RulesJni.loadLibrary("jazzer_driver", "/com/code_intelligence/jazzer/driver");
-    }
+    RulesJni.loadLibrary("jazzer_fuzzed_data_provider", "/com/code_intelligence/jazzer/driver");
     nativeInit();
   }
 
   private static native void nativeInit();
 
-  private final boolean ownsNativeData;
+  private final byte[] javaData;
   private long originalDataPtr;
   private int originalRemainingBytes;
 
@@ -37,8 +35,8 @@
   private long dataPtr;
   private int remainingBytes;
 
-  private FuzzedDataProviderImpl(long dataPtr, int remainingBytes, boolean ownsNativeData) {
-    this.ownsNativeData = ownsNativeData;
+  private FuzzedDataProviderImpl(long dataPtr, int remainingBytes, byte[] javaData) {
+    this.javaData = javaData;
     this.originalDataPtr = dataPtr;
     this.dataPtr = dataPtr;
     this.originalRemainingBytes = remainingBytes;
@@ -59,7 +57,7 @@
    * @return a {@link FuzzedDataProvider} backed by {@code data}
    */
   public static FuzzedDataProviderImpl withJavaData(byte[] data) {
-    return new FuzzedDataProviderImpl(allocateNativeCopy(data), data.length, true);
+    return new FuzzedDataProviderImpl(allocateNativeCopy(data), data.length, data);
   }
 
   /**
@@ -71,7 +69,7 @@
    * @return a {@link FuzzedDataProvider} backed by an empty array.
    */
   public static FuzzedDataProviderImpl withNativeData() {
-    return new FuzzedDataProviderImpl(0, 0, false);
+    return new FuzzedDataProviderImpl(0, 0, null);
   }
 
   /**
@@ -90,6 +88,14 @@
   }
 
   /**
+   * Returns the Java byte array used to construct the instance, or null if it was created with
+   * {@link FuzzedDataProviderImpl#withNativeData()};
+   */
+  public byte[] getJavaData() {
+    return javaData;
+  }
+
+  /**
    * Resets the FuzzedDataProvider state to read from the beginning to the end of its current
    * backing item.
    */
@@ -109,7 +115,8 @@
     if (originalDataPtr == 0) {
       return;
     }
-    if (ownsNativeData) {
+    // We own the native memory iff the instance was created backed by a Java byte array.
+    if (javaData != null) {
       UNSAFE.freeMemory(originalDataPtr);
     }
     // Prevent double-frees and use-after-frees by effectively making all methods no-ops after
diff --git a/src/main/java/com/code_intelligence/jazzer/driver/OfflineInstrumentor.java b/src/main/java/com/code_intelligence/jazzer/driver/OfflineInstrumentor.java
new file mode 100644
index 0000000..3e779d4
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/driver/OfflineInstrumentor.java
@@ -0,0 +1,179 @@
+// Copyright 2021 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.driver;
+
+import com.code_intelligence.jazzer.agent.AgentInstaller;
+import com.code_intelligence.jazzer.utils.Log;
+import com.code_intelligence.jazzer.utils.ZipUtils;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.lang.UnsupportedClassVersionError;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.zip.ZipOutputStream;
+
+public class OfflineInstrumentor {
+  /**
+   * Create a new jar file at <jazzer_path>/<jarBaseName>.instrumented.jar
+   * for each jar in passed in, with classes that have Jazzer instrumentation.
+   *
+   * @param jarLists list of jars to instrument
+   * @return a boolean representing the success status
+   */
+  public static boolean instrumentJars(List<String> jarLists) {
+    AgentInstaller.install(Opt.hooks);
+
+    // Clear Opt.dumpClassesDir before adding new instrumented classes
+    File dumpClassesDir = new File(Opt.dumpClassesDir);
+    if (dumpClassesDir.exists()) {
+      for (String fn : dumpClassesDir.list()) {
+        new File(Opt.dumpClassesDir, fn).delete();
+      }
+    }
+
+    List<String> errorMessages = new ArrayList<>();
+    for (String jarPath : jarLists) {
+      String outputBaseName = jarPath;
+      if (outputBaseName.contains(File.separator)) {
+        outputBaseName = outputBaseName.substring(
+            outputBaseName.lastIndexOf(File.separator) + 1, outputBaseName.length());
+      }
+
+      if (outputBaseName.contains(".jar")) {
+        outputBaseName = outputBaseName.substring(0, outputBaseName.lastIndexOf(".jar"));
+      }
+
+      Log.info("Instrumenting jar file: " + jarPath);
+
+      try {
+        errorMessages = createInstrumentedClasses(jarPath);
+      } catch (IOException e) {
+        errorMessages.add("Failed to instrument jar: " + jarPath
+            + ". Please ensure the file at this location is a jar file. Error Message: " + e);
+        continue;
+      }
+
+      try {
+        createInstrumentedJar(jarPath, Opt.dumpClassesDir + File.separator + outputBaseName,
+            outputBaseName + ".instrumented.jar");
+      } catch (Exception e) {
+        errorMessages.add("Failed to instrument jar: " + jarPath + ". Error: " + e);
+      }
+    }
+
+    // Log all errors at the end
+    for (String error : errorMessages) {
+      Log.error(error);
+    }
+
+    return errorMessages.isEmpty();
+  }
+
+  /**
+   * Loops over all classes in jar file and adds instrumentation. The output
+   * of the instrumented classes will be at --dump-classes-dir
+   *
+   * @param jarPath a path to a jar file to instrument.
+   * @return a list of errors that were hit when trying to instrument all classes in jar
+   */
+  private static List<String> createInstrumentedClasses(String jarPath) throws IOException {
+    List<String> errorMessages = new ArrayList<>();
+    List<String> allClasses = new ArrayList<>();
+
+    // Collect all classes for jar file
+    try (JarFile jarFile = new JarFile(jarPath)) {
+      Enumeration<JarEntry> allEntries = jarFile.entries();
+      while (allEntries.hasMoreElements()) {
+        JarEntry entry = allEntries.nextElement();
+        if (entry.isDirectory()) {
+          continue;
+        }
+
+        String name = entry.getName();
+        if (!name.endsWith(".class")) {
+          Log.info("Skipping instrumenting file: " + name);
+          continue;
+        }
+
+        String className = name.substring(0, name.lastIndexOf(".class"));
+        className = className.replace('/', '.');
+        allClasses.add(className);
+        Log.info("Found class: " + className);
+      }
+    }
+
+    // No classes found, so none to load. Return errors
+    if (allClasses.size() == 0) {
+      errorMessages.add("Classes is empty for jar: " + jarPath);
+      return errorMessages;
+    }
+
+    // Create class loader to load in all classes
+    File file = new File(jarPath);
+    URL url = file.toURI().toURL();
+    URL[] urls = new URL[] {url};
+    ClassLoader cl = new URLClassLoader(urls);
+
+    // Loop through all files and load in all classes, agent will instrument them as they load
+    for (String className : allClasses) {
+      try {
+        cl.loadClass(className);
+      } catch (UnsupportedClassVersionError ucve) {
+        // The classes will still get instrumented here, but warn so the user knows something
+        // happened
+        Log.warn(ucve.toString());
+      } catch (Throwable e) {
+        // Catch all exceptions/errors and keep instrumenting to give user the option to manually
+        // fix one offs if possible
+        errorMessages.add("Failed to instrument class: " + className + ". Error: " + e);
+      }
+    }
+
+    return errorMessages;
+  }
+
+  /**
+   * This will create a new jar out of specified original jar and the merge in the instrumented
+   * classes from the specified instrumented classes dir
+   *
+   * @param originalJarPath a path to the original jar.
+   * @param instrumentedClassesDir a path to the instrumented classes dir.
+   * @param outputZip output file.
+   */
+  private static void createInstrumentedJar(
+      String originalJarPath, String instrumentedClassesDir, String outputZip) throws IOException {
+    try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(outputZip))) {
+      Set<String> dirFilesToSkip = new HashSet<>();
+      dirFilesToSkip.add(".original.class");
+      dirFilesToSkip.add(".failed.class");
+      Set<String> filesMerged =
+          ZipUtils.mergeDirectoryToZip(instrumentedClassesDir, zos, dirFilesToSkip);
+
+      ZipUtils.mergeZipToZip(originalJarPath, zos, filesMerged);
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/driver/Opt.java b/src/main/java/com/code_intelligence/jazzer/driver/Opt.java
new file mode 100644
index 0000000..f1a45c3
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/driver/Opt.java
@@ -0,0 +1,220 @@
+/*
+ * Copyright 2022 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.driver;
+
+import static com.code_intelligence.jazzer.Constants.JAZZER_VERSION;
+import static com.code_intelligence.jazzer.driver.OptParser.boolSetting;
+import static com.code_intelligence.jazzer.driver.OptParser.ignoreSetting;
+import static com.code_intelligence.jazzer.driver.OptParser.lazyStringListSetting;
+import static com.code_intelligence.jazzer.driver.OptParser.stringListSetting;
+import static com.code_intelligence.jazzer.driver.OptParser.stringSetting;
+import static com.code_intelligence.jazzer.driver.OptParser.uint64Setting;
+import static java.lang.System.exit;
+import static java.util.Collections.unmodifiableList;
+import static java.util.Collections.unmodifiableSet;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+import static java.util.stream.Stream.concat;
+
+import com.code_intelligence.jazzer.utils.Log;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Supplier;
+import java.util.stream.Stream;
+
+/**
+ * Static options that determine the runtime behavior of the fuzzer, set via Java properties.
+ *
+ * <p>Each option corresponds to a command-line argument of the driver of the same name.
+ *
+ * <p>Every public field should be deeply immutable.
+ */
+public final class Opt {
+  static {
+    if (Opt.class.getClassLoader() == null) {
+      throw new IllegalStateException("Opt should not be loaded in the bootstrap class loader");
+    }
+  }
+
+  static {
+    // We additionally list system properties supported by the Jazzer JUnit engine that do not
+    // directly map to arguments. These are not shown in help texts.
+    ignoreSetting("instrument");
+    ignoreSetting("valueprofile");
+    // The following arguments are interpreted by the native launcher only. They do appear in the
+    // help text, but aren't read by the driver.
+    stringListSetting("jvm_args",
+        "Arguments to pass to the JVM (separator can be escaped with '\\', native launcher only)");
+    stringListSetting("additional_jvm_args",
+        "Additional arguments to pass to the JVM (separator can be escaped with '\\', native launcher only)");
+    stringSetting(
+        "agent_path", null, "Custom path to jazzer_agent_deploy.jar (native launcher only)");
+    // The following arguments are interpreted by the Jazzer main class directly as they require
+    // starting Jazzer as a subprocess.
+    boolSetting(
+        "asan", false, "Allow fuzzing of native libraries compiled with '-fsanitize=address'");
+    boolSetting(
+        "ubsan", false, "Allow fuzzing of native libraries compiled with '-fsanitize=undefined'");
+    boolSetting("native", false,
+        "Allow fuzzing of native libraries compiled with '-fsanitize=fuzzer' (implied by --asan and --ubsan)");
+    // Options currently used by Android only
+    stringSetting("android_init_options", null,
+        "Which libraries to use when initializing ART (native launcher only)");
+    boolSetting("hwasan", false, "Allow fuzzing of native libraries compiled with hwasan");
+  }
+
+  public static final String autofuzz = stringSetting("autofuzz", "",
+      "Fully qualified reference (optionally with a Javadoc-style signature) to a "
+          + "method on the class path to be fuzzed with automatically generated arguments "
+          + "(examples: java.lang.System.out::println, java.lang.String::new(byte[]))");
+  public static final List<String> autofuzzIgnore = stringListSetting("autofuzz_ignore", ',',
+      "Fully qualified names of exception classes to ignore during fuzzing");
+  public static final String coverageDump = stringSetting("coverage_dump", "",
+      "Path to write a JaCoCo .exec file to when the fuzzer exits (if non-empty)");
+  public static final String coverageReport = stringSetting("coverage_report", "",
+      "Path to write a human-readable coverage report to when the fuzzer exits (if non-empty)");
+  public static final List<String> customHooks =
+      stringListSetting("custom_hooks", "Names of classes to load custom hooks from");
+  public static final List<String> disabledHooks = stringListSetting("disabled_hooks",
+      "Names of classes from which hooks (custom or built-in) should not be loaded from");
+  public static final String dumpClassesDir = stringSetting(
+      "dump_classes_dir", "", "Directory to dump instrumented .class files into (if non-empty)");
+  public static final boolean experimentalMutator =
+      boolSetting("experimental_mutator", false, "Use an experimental structured mutator");
+  public static final long experimentalCrossOverFrequency = uint64Setting(
+      "experimental_cross_over_frequency", 100,
+      "(Used in experimental mutator) Frequency of cross-over mutations actually being executed "
+          + "when the cross-over function is picked by the underlying fuzzing engine (~1/2 of all mutations), "
+          + "other invocations perform type specific mutations via the experimental mutator. "
+          + "(0 = disabled, 1 = every call, 2 = every other call, etc.).");
+  public static final boolean hooks = boolSetting(
+      "hooks", true, "Apply fuzzing instrumentation (use 'trace' for finer-grained control)");
+  public static final String idSyncFile = stringSetting("id_sync_file", null, null);
+  public static final Set<Long> ignore =
+      unmodifiableSet(stringListSetting("ignore", ',',
+          "Hex strings representing deduplication tokens of findings that should be ignored")
+                          .stream()
+                          .map(token -> Long.parseUnsignedLong(token, 16))
+                          .collect(toSet()));
+  public static final long keepGoing = uint64Setting(
+      "keep_going", 1, "Number of distinct findings after which the fuzzer should stop");
+  public static final String reproducerPath = stringSetting("reproducer_path", ".",
+      "Directory in which stand-alone Java reproducers are stored for each finding");
+  public static final String targetClass = stringSetting("target_class", "",
+      "Fully qualified name of the fuzz target class (required unless --autofuzz is specified)");
+  // Used to disambiguate between multiple methods annotated with @FuzzTest in the target class.
+  public static final String targetMethod = stringSetting("target_method", "", null);
+  public static final List<String> trace = stringListSetting("trace",
+      "Types of instrumentation to apply: cmp, cov, div, gep (disabled by default), indir, native");
+
+  // When Jazzer is executed from the command line, these settings are potentially modified by
+  // JUnit's AgentConfigurator after the Driver has initialized Opt, which would result in stale
+  // values being read if the settings weren't evaluated lazily.
+  // TODO: Look into making all settings lazy, but verify that their value never changes after they
+  //  have been read once.
+  public static final Supplier<List<String>> customHookIncludes =
+      lazyStringListSetting("custom_hook_includes",
+          "Glob patterns matching names of classes to instrument with hooks (custom and built-in)");
+  public static final Supplier<List<String>> customHookExcludes = lazyStringListSetting(
+      "custom_hook_excludes",
+      "Glob patterns matching names of classes that should not be instrumented with hooks (custom and built-in)");
+  public static final Supplier<List<String>> instrumentationIncludes =
+      lazyStringListSetting("instrumentation_includes",
+          "Glob patterns matching names of classes to instrument for fuzzing");
+  public static final Supplier<List<String>> instrumentationExcludes =
+      lazyStringListSetting("instrumentation_excludes",
+          "Glob patterns matching names of classes that should not be instrumented for fuzzing");
+  // The values of this setting depends on autofuzz.
+  public static final List<String> targetArgs = autofuzz.isEmpty()
+      ? stringListSetting(
+          "target_args", ' ', "Arguments to pass to the fuzz target's fuzzerInitialize method")
+      : unmodifiableList(concat(Stream.of(autofuzz), autofuzzIgnore.stream()).collect(toList()));
+
+  // Default to false if hooks is false to mimic the original behavior of the native fuzz target
+  // runner, but still support hooks = false && dedup = true.
+  public static final boolean dedup =
+      boolSetting("dedup", hooks, "Compute and print a deduplication token for every finding");
+
+  public static final String androidBootclassJarPath = stringSetting("android_bootclass_jar_path",
+      null,
+      "Full path to booclass jar path that will be used on Android runs. If you are using the launcher this will be set for you.");
+
+  public static final String androidBootclassClassesOverrides = stringSetting(
+      "android_bootpath_classes_overrides", null,
+      "Used for fuzzing classes loaded in through the bootstrap class loader on Android. Full path to jar file with the instrumented versions of the classes you want to override.");
+
+  // Whether hook instrumentation should add a check for JazzerInternal#hooksEnabled before
+  // executing hooks. Used to disable hooks during non-fuzz JUnit tests.
+  public static final boolean conditionalHooks =
+      boolSetting("internal.conditional_hooks", false, null);
+
+  static final boolean mergeInner = boolSetting("internal.merge_inner", false, null);
+
+  private static final boolean help =
+      boolSetting("help", false, "Show this list of all available arguments");
+  private static final boolean version = boolSetting("version", false, "Print version information");
+
+  // Methods below currently used by Android only
+  public static final List<String> cp =
+      stringListSetting("cp", "The class path to use for fuzzing (native launcher only)");
+
+  public static final List<String> additionalClassesExcludes =
+      stringListSetting("additional_classes_excludes",
+          "Glob patterns matching names of classes from Java that are not in your jar file, "
+              + "but may be included in your program");
+
+  // Default to false. Sets if fuzzing is taking place on Android device (virtual or physical)
+  public static final boolean isAndroid =
+      boolSetting("android", false, "Jazzer is running on Android");
+
+  // Some scenarios require instrumenting the jar before fuzzing begins
+  public static final List<String> instrumentOnly = stringListSetting("instrument_only", ',',
+      "Comma separated list of jar files to instrument. No fuzzing is performed.");
+
+  static {
+    OptParser.failOnUnknownArgument();
+
+    if (help) {
+      Log.println(OptParser.getHelpText());
+      exit(0);
+    }
+    if (version) {
+      Log.println("Jazzer v" + JAZZER_VERSION);
+      exit(0);
+    }
+    if (!targetClass.isEmpty() && !autofuzz.isEmpty()) {
+      Log.error("--target_class and --autofuzz cannot be specified together");
+      exit(1);
+    }
+    if (!stringListSetting("target_args", ' ', null).isEmpty() && !autofuzz.isEmpty()) {
+      Log.error("--target_args and --autofuzz cannot be specified together");
+      exit(1);
+    }
+    if (autofuzz.isEmpty() && !autofuzzIgnore.isEmpty()) {
+      Log.error("--autofuzz_ignore requires --autofuzz");
+      exit(1);
+    }
+    if ((!ignore.isEmpty() || keepGoing > 1) && !dedup) {
+      Log.error("--nodedup is not supported with --ignore or --keep_going");
+      exit(1);
+    }
+    if (!instrumentOnly.isEmpty() && dumpClassesDir.isEmpty()) {
+      Log.error("--dump_classes_dir must be set with --instrument_only");
+      exit(1);
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/driver/OptParser.java b/src/main/java/com/code_intelligence/jazzer/driver/OptParser.java
new file mode 100644
index 0000000..ab096f2
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/driver/OptParser.java
@@ -0,0 +1,212 @@
+/*
+ * Copyright 2022 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.driver;
+
+import static java.lang.System.exit;
+
+import com.code_intelligence.jazzer.utils.Log;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.TreeMap;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+final class OptParser {
+  private static final String[] HELP_HEADER = new String[] {
+      "A coverage-guided, in-process fuzzer for the JVM",
+      "",
+      "Usage:",
+      String.format(
+          "  java -cp jazzer.jar[%cclasspath_entries] com.code_intelligence.jazzer.Jazzer --target_class=<target class> [args...]",
+          File.separatorChar),
+      String.format(
+          "  java -cp jazzer.jar[%cclasspath_entries] com.code_intelligence.jazzer.Jazzer --autofuzz=<method reference> [args...]",
+          File.separatorChar),
+      "",
+      "In addition to the options listed below, Jazzer also accepts all",
+      "libFuzzer options described at:",
+      "  https://llvm.org/docs/LibFuzzer.html#options",
+      "",
+      "Options:",
+  };
+  private static final String OPTIONS_PREFIX = "jazzer.";
+
+  // All supported arguments are added to this set by the individual *Setting methods.
+  private static final Map<String, OptDetails> knownArgs = new TreeMap<>();
+
+  static String getHelpText() {
+    return Stream
+        .concat(Arrays.stream(HELP_HEADER),
+            knownArgs.values().stream().filter(Objects::nonNull).map(OptDetails::toString))
+        .collect(Collectors.joining("\n\n"));
+  }
+
+  static void ignoreSetting(String name) {
+    knownArgs.put(name, null);
+  }
+
+  static String stringSetting(String name, String defaultValue, String description) {
+    knownArgs.put(name, OptDetails.create(name, "string", defaultValue, description));
+    return System.getProperty(OPTIONS_PREFIX + name, defaultValue);
+  }
+
+  static List<String> stringListSetting(String name, String description) {
+    return lazyStringListSetting(name, description).get();
+  }
+
+  static List<String> stringListSetting(String name, char separator, String description) {
+    return lazyStringListSetting(name, separator, description).get();
+  }
+
+  static Supplier<List<String>> lazyStringListSetting(String name, String description) {
+    return lazyStringListSetting(name, File.pathSeparatorChar, description);
+  }
+
+  static Supplier<List<String>> lazyStringListSetting(
+      String name, char separator, String description) {
+    knownArgs.put(name,
+        OptDetails.create(
+            name, String.format("list separated by '%c'", separator), "", description));
+    return () -> {
+      String value = System.getProperty(OPTIONS_PREFIX + name);
+      if (value == null || value.isEmpty()) {
+        return Collections.emptyList();
+      }
+      return splitOnUnescapedSeparator(value, separator);
+    };
+  }
+
+  static boolean boolSetting(String name, boolean defaultValue, String description) {
+    knownArgs.put(
+        name, OptDetails.create(name, "boolean", Boolean.toString(defaultValue), description));
+    String value = System.getProperty(OPTIONS_PREFIX + name);
+    if (value == null) {
+      return defaultValue;
+    }
+    return Boolean.parseBoolean(value);
+  }
+
+  static long uint64Setting(String name, long defaultValue, String description) {
+    knownArgs.put(
+        name, OptDetails.create(name, "uint64", Long.toUnsignedString(defaultValue), description));
+    String value = System.getProperty(OPTIONS_PREFIX + name);
+    if (value == null) {
+      return defaultValue;
+    }
+    return Long.parseUnsignedLong(value, 10);
+  }
+
+  static void failOnUnknownArgument() {
+    System.getProperties()
+        .keySet()
+        .stream()
+        .map(key -> (String) key)
+        .filter(key -> key.startsWith("jazzer."))
+        .map(key -> key.substring("jazzer.".length()))
+        .filter(key -> !key.startsWith("internal."))
+        .filter(key -> !knownArgs.containsKey(key))
+        .findFirst()
+        .ifPresent(unknownArg -> {
+          Log.error(String.format(
+              "Unknown argument '--%1$s' or property 'jazzer.%1$s' (list all available arguments with --help)",
+              unknownArg));
+          exit(1);
+        });
+  }
+
+  /**
+   * Split value into non-empty takens separated by separator. Backslashes can be used to escape
+   * separators (or backslashes).
+   *
+   * @param value the string to split
+   * @param separator a single character to split on (backslash is not allowed)
+   * @return an immutable list of tokens obtained by splitting value on separator
+   */
+  static List<String> splitOnUnescapedSeparator(String value, char separator) {
+    if (separator == '\\') {
+      throw new IllegalArgumentException("separator '\\' is not supported");
+    }
+    ArrayList<String> tokens = new ArrayList<>();
+    StringBuilder currentToken = new StringBuilder();
+    boolean inEscapeState = false;
+    for (int pos = 0; pos < value.length(); pos++) {
+      char c = value.charAt(pos);
+      if (inEscapeState) {
+        currentToken.append(c);
+        inEscapeState = false;
+      } else if (c == '\\') {
+        inEscapeState = true;
+      } else if (c == separator) {
+        // Do not emit empty tokens between consecutive separators.
+        if (currentToken.length() > 0) {
+          tokens.add(currentToken.toString());
+        }
+        currentToken.setLength(0);
+      } else {
+        currentToken.append(c);
+      }
+    }
+    if (currentToken.length() > 0) {
+      tokens.add(currentToken.toString());
+    }
+    return Collections.unmodifiableList(tokens);
+  }
+
+  private static final class OptDetails {
+    final String name;
+    final String type;
+    final String defaultValue;
+    final String description;
+
+    private OptDetails(String name, String type, String defaultValue, String description) {
+      this.name = name;
+      this.type = type;
+      this.defaultValue = defaultValue;
+      this.description = description;
+    }
+
+    static OptDetails create(String name, String type, String defaultValue, String description) {
+      if (description == null) {
+        return null;
+      }
+      return new OptDetails(checkNotNullOrEmpty(name, "name"), checkNotNullOrEmpty(type, "type"),
+          defaultValue, checkNotNullOrEmpty(description, "description"));
+    }
+
+    @Override
+    public String toString() {
+      return String.format(
+          "--%s (%s, default: \"%s\")%n     %s", name, type, defaultValue, description);
+    }
+
+    private static String checkNotNullOrEmpty(String arg, String name) {
+      if (arg == null) {
+        throw new NullPointerException(name + " must not be null");
+      }
+      if (arg.isEmpty()) {
+        throw new NullPointerException(name + " must not be empty");
+      }
+      return arg;
+    }
+  }
+}
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/runtime/RecordingFuzzedDataProvider.java b/src/main/java/com/code_intelligence/jazzer/driver/RecordingFuzzedDataProvider.java
similarity index 98%
rename from agent/src/main/java/com/code_intelligence/jazzer/runtime/RecordingFuzzedDataProvider.java
rename to src/main/java/com/code_intelligence/jazzer/driver/RecordingFuzzedDataProvider.java
index 4eb8022..6593f0d 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/runtime/RecordingFuzzedDataProvider.java
+++ b/src/main/java/com/code_intelligence/jazzer/driver/RecordingFuzzedDataProvider.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.code_intelligence.jazzer.runtime;
+package com.code_intelligence.jazzer.driver;
 
 import com.code_intelligence.jazzer.api.FuzzedDataProvider;
 import java.io.ByteArrayOutputStream;
diff --git a/driver/src/main/java/com/code_intelligence/jazzer/driver/Reproducer.java.tmpl b/src/main/java/com/code_intelligence/jazzer/driver/Reproducer.java.tmpl
similarity index 92%
rename from driver/src/main/java/com/code_intelligence/jazzer/driver/Reproducer.java.tmpl
rename to src/main/java/com/code_intelligence/jazzer/driver/Reproducer.java.tmpl
index d9cb1e9..3c44175 100644
--- a/driver/src/main/java/com/code_intelligence/jazzer/driver/Reproducer.java.tmpl
+++ b/src/main/java/com/code_intelligence/jazzer/driver/Reproducer.java.tmpl
@@ -5,7 +5,7 @@
     static final String base64Bytes = String.join("", "%2$s");
 
     public static void main(String[] args) throws Throwable {
-        ClassLoader.getSystemClassLoader().setDefaultAssertionStatus(true);
+        Crash_%1$s.class.getClassLoader().setDefaultAssertionStatus(true);
         try {
             Method fuzzerInitialize = %3$s.class.getMethod("fuzzerInitialize");
             fuzzerInitialize.invoke(null);
diff --git a/driver/src/main/java/com/code_intelligence/jazzer/driver/ReproducerTemplate.java b/src/main/java/com/code_intelligence/jazzer/driver/ReproducerTemplate.java
similarity index 93%
rename from driver/src/main/java/com/code_intelligence/jazzer/driver/ReproducerTemplate.java
rename to src/main/java/com/code_intelligence/jazzer/driver/ReproducerTemplate.java
index 0c7721c..a69a8db 100644
--- a/driver/src/main/java/com/code_intelligence/jazzer/driver/ReproducerTemplate.java
+++ b/src/main/java/com/code_intelligence/jazzer/driver/ReproducerTemplate.java
@@ -16,6 +16,7 @@
 
 package com.code_intelligence.jazzer.driver;
 
+import com.code_intelligence.jazzer.utils.Log;
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.InputStreamReader;
@@ -23,7 +24,6 @@
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
-import java.nio.file.StandardOpenOption;
 import java.util.ArrayList;
 import java.util.stream.Collectors;
 
@@ -62,13 +62,13 @@
     String javaSource = String.format(template, sha, chunkedData, targetClass, targetArg);
     Path javaPath = Paths.get(Opt.reproducerPath, String.format("Crash_%s.java", sha));
     try {
-      Files.write(javaPath, javaSource.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
+      Files.write(javaPath, javaSource.getBytes(StandardCharsets.UTF_8));
     } catch (IOException e) {
-      System.err.printf("ERROR: Failed to write Java reproducer to %s%n", javaPath);
+      Log.error(String.format("Failed to write Java reproducer to %s%n", javaPath));
       e.printStackTrace();
     }
-    System.out.printf(
-        "reproducer_path='%s'; Java reproducer written to %s%n", Opt.reproducerPath, javaPath);
+    Log.println(String.format(
+        "reproducer_path='%s'; Java reproducer written to %s%n", Opt.reproducerPath, javaPath));
   }
 
   // The serialization of recorded FuzzedDataProvider invocations can get too long to be emitted
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/runtime/SignalHandler.java b/src/main/java/com/code_intelligence/jazzer/driver/SignalHandler.java
similarity index 95%
rename from agent/src/main/java/com/code_intelligence/jazzer/runtime/SignalHandler.java
rename to src/main/java/com/code_intelligence/jazzer/driver/SignalHandler.java
index 49ee80c..215a047 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/runtime/SignalHandler.java
+++ b/src/main/java/com/code_intelligence/jazzer/driver/SignalHandler.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.code_intelligence.jazzer.runtime;
+package com.code_intelligence.jazzer.driver;
 
 import com.github.fmeum.rules_jni.RulesJni;
 import sun.misc.Signal;
diff --git a/src/main/java/com/code_intelligence/jazzer/driver/junit/BUILD.bazel b/src/main/java/com/code_intelligence/jazzer/driver/junit/BUILD.bazel
new file mode 100644
index 0000000..c715365
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/driver/junit/BUILD.bazel
@@ -0,0 +1,30 @@
+java_library(
+    name = "junit_runner",
+    srcs = ["JUnitRunner.java"],
+    visibility = ["//src/main/java/com/code_intelligence/jazzer/driver:__pkg__"],
+    deps = [
+        ":exit_code_exception",
+        ":junit_compile_only",
+        "//src/main/java/com/code_intelligence/jazzer/driver:constants",
+        "//src/main/java/com/code_intelligence/jazzer/driver:exception_utils",
+        "//src/main/java/com/code_intelligence/jazzer/driver:fuzz_target_runner",
+        "//src/main/java/com/code_intelligence/jazzer/driver:opt",
+        "//src/main/java/com/code_intelligence/jazzer/utils:log",
+        "@maven//:org_junit_platform_junit_platform_engine",
+    ],
+)
+
+java_library(
+    name = "exit_code_exception",
+    srcs = ["ExitCodeException.java"],
+    visibility = ["//src/main/java/com/code_intelligence/jazzer/junit:__pkg__"],
+)
+
+java_library(
+    name = "junit_compile_only",
+    neverlink = True,
+    exports = [
+        "@maven//:org_junit_jupiter_junit_jupiter_engine",
+        "@maven//:org_junit_platform_junit_platform_launcher",
+    ],
+)
diff --git a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h b/src/main/java/com/code_intelligence/jazzer/driver/junit/ExitCodeException.java
similarity index 65%
copy from driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
copy to src/main/java/com/code_intelligence/jazzer/driver/junit/ExitCodeException.java
index 0e8846c..662fb9b 100644
--- a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
+++ b/src/main/java/com/code_intelligence/jazzer/driver/junit/ExitCodeException.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2021 Code Intelligence GmbH
+ * Copyright 2023 Code Intelligence GmbH
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,15 +14,13 @@
  * limitations under the License.
  */
 
-#pragma once
+package com.code_intelligence.jazzer.driver.junit;
 
-#include <jni.h>
+public final class ExitCodeException extends Exception {
+  public final int exitCode;
 
-namespace jazzer {
-/*
- * Print the stack traces of all active JVM threads.
- *
- * This function can be called from any thread.
- */
-void DumpJvmStackTraces();
-}  // namespace jazzer
+  public ExitCodeException(String message, int exitCode) {
+    super(message);
+    this.exitCode = exitCode;
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/driver/junit/JUnitRunner.java b/src/main/java/com/code_intelligence/jazzer/driver/junit/JUnitRunner.java
new file mode 100644
index 0000000..5bba434
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/driver/junit/JUnitRunner.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.driver.junit;
+
+import static com.code_intelligence.jazzer.driver.Constants.JAZZER_FINDING_EXIT_CODE;
+import static com.code_intelligence.jazzer.driver.FuzzTargetRunner.printCrashingInput;
+import static org.junit.platform.engine.FilterResult.includedIf;
+import static org.junit.platform.engine.TestExecutionResult.Status.ABORTED;
+import static org.junit.platform.engine.TestExecutionResult.Status.FAILED;
+import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
+import static org.junit.platform.launcher.TagFilter.includeTags;
+
+import com.code_intelligence.jazzer.driver.ExceptionUtils;
+import com.code_intelligence.jazzer.driver.Opt;
+import com.code_intelligence.jazzer.utils.Log;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.junit.jupiter.engine.JupiterTestEngine;
+import org.junit.jupiter.engine.descriptor.MethodBasedTestDescriptor;
+import org.junit.platform.engine.TestExecutionResult;
+import org.junit.platform.engine.reporting.ReportEntry;
+import org.junit.platform.launcher.Launcher;
+import org.junit.platform.launcher.LauncherDiscoveryRequest;
+import org.junit.platform.launcher.PostDiscoveryFilter;
+import org.junit.platform.launcher.TestExecutionListener;
+import org.junit.platform.launcher.TestIdentifier;
+import org.junit.platform.launcher.TestPlan;
+import org.junit.platform.launcher.core.LauncherConfig;
+import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
+import org.junit.platform.launcher.core.LauncherFactory;
+
+public final class JUnitRunner {
+  private final Launcher launcher;
+  private final TestPlan testPlan;
+
+  private JUnitRunner(Launcher launcher, TestPlan testPlan) {
+    this.launcher = launcher;
+    this.testPlan = testPlan;
+  }
+
+  // Detects the presence of both the JUnit launcher and the Jupiter engine on the classpath.
+  public static boolean isSupported() {
+    try {
+      Class.forName("org.junit.platform.launcher.LauncherDiscoveryRequest");
+      Class.forName("org.junit.jupiter.engine.JupiterTestEngine");
+      return true;
+    } catch (ClassNotFoundException e) {
+      return false;
+    }
+  }
+
+  public static Optional<JUnitRunner> create(String testClassName, List<String> libFuzzerArgs) {
+    // We want the test execution to be as lightweight as possible, so disable all auto-discover and
+    // only register the test engine we are using for @FuzzTest, JUnit Jupiter.
+    LauncherConfig config = LauncherConfig.builder()
+                                .addTestEngines(new JupiterTestEngine())
+                                .enableLauncherDiscoveryListenerAutoRegistration(false)
+                                .enableLauncherSessionListenerAutoRegistration(false)
+                                .enablePostDiscoveryFilterAutoRegistration(false)
+                                .enableTestEngineAutoRegistration(false)
+                                .enableTestExecutionListenerAutoRegistration(false)
+                                .build();
+
+    Map<String, String> indexedArgs =
+        IntStream.range(0, libFuzzerArgs.size())
+            .boxed()
+            .collect(Collectors.toMap(i -> "jazzer.internal.arg." + i, libFuzzerArgs::get));
+
+    LauncherDiscoveryRequestBuilder requestBuilder =
+        LauncherDiscoveryRequestBuilder.request()
+            .configurationParameter("jazzer.internal.commandLine", "true")
+            .configurationParameters(indexedArgs)
+            .selectors(selectClass(testClassName))
+            .filters(includeTags("jazzer"));
+    if (!Opt.targetMethod.isEmpty()) {
+      // HACK: This depends on JUnit internals as we need to filter by method name without having to
+      // specify the parameter types of the method.
+      requestBuilder.filters((PostDiscoveryFilter) testDescriptor
+          -> includedIf(!(testDescriptor instanceof MethodBasedTestDescriptor)
+              || ((MethodBasedTestDescriptor) testDescriptor)
+                     .getTestMethod()
+                     .getName()
+                     .equals(Opt.targetMethod)));
+    }
+    LauncherDiscoveryRequest request = requestBuilder.build();
+    Launcher launcher = LauncherFactory.create(config);
+    TestPlan testPlan = launcher.discover(request);
+    if (!testPlan.containsTests()) {
+      return Optional.empty();
+    }
+    return Optional.of(new JUnitRunner(launcher, testPlan));
+  }
+
+  public int run() {
+    AtomicReference<TestExecutionResult> resultHolder =
+        new AtomicReference<>(TestExecutionResult.successful());
+    launcher.execute(testPlan, new TestExecutionListener() {
+      @Override
+      public void executionFinished(
+          TestIdentifier testIdentifier, TestExecutionResult testExecutionResult) {
+        // Lifecycle methods can fail too, which results in failed execution results on container
+        // nodes. We keep the last failing one with a stack trace. For tests, we also keep the stack
+        // traces of aborted tests so that we can show a warning. In JUnit Jupiter, tests and
+        // containers always fail with a throwable:
+        // https://github.com/junit-team/junit5/blob/ac31e9a7d58973db73496244dab4defe17ae563e/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ThrowableCollector.java#LL176C37-L176C37
+        if ((testIdentifier.isTest() && testExecutionResult.getThrowable().isPresent())
+            || testExecutionResult.getStatus() == FAILED) {
+          resultHolder.set(testExecutionResult);
+        }
+        if (testExecutionResult.getStatus() == FAILED
+            && testExecutionResult.getThrowable().isPresent()) {
+          resultHolder.set(testExecutionResult);
+        }
+      }
+
+      @Override
+      public void reportingEntryPublished(TestIdentifier testIdentifier, ReportEntry entry) {
+        entry.getKeyValuePairs().values().forEach(Log::info);
+      }
+    });
+
+    TestExecutionResult result = resultHolder.get();
+    if (result.getStatus() != FAILED) {
+      // We do not generate a finding for Aborted tests (i.e. tests whose preconditions were not
+      // met) as such tests also wouldn't make a test run fail.
+      if (result.getStatus() == ABORTED) {
+        Log.warn("Fuzz test aborted", result.getThrowable().orElse(null));
+      }
+      return 0;
+    }
+
+    // Safe to unwrap as result is either TestExecutionResult.successful() (initial value) or has
+    // a throwable (set in the TestExecutionListener above).
+    Throwable throwable = result.getThrowable().get();
+    if (throwable instanceof ExitCodeException) {
+      // Jazzer found a regular finding and printed it, so just return the exit code.
+      return ((ExitCodeException) throwable).exitCode;
+    } else {
+      // Jazzer didn't report a finding, but an afterAll-type function threw an exception. Report it
+      // as a finding, cleaning up the stack trace.
+      Log.finding(ExceptionUtils.preprocessThrowable(throwable));
+      Log.println("== libFuzzer crashing input ==");
+      printCrashingInput();
+      return JAZZER_FINDING_EXIT_CODE;
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel b/src/main/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel
new file mode 100644
index 0000000..bbb449a
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel
@@ -0,0 +1,41 @@
+load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library")
+load("//bazel:kotlin.bzl", "ktlint")
+
+kt_jvm_library(
+    name = "instrumentor",
+    srcs = [
+        "ClassInstrumentor.kt",
+        "CoverageRecorder.kt",
+        "DescriptorUtils.kt",
+        "DeterministicRandom.kt",
+        "EdgeCoverageInstrumentor.kt",
+        "Hook.kt",
+        "HookInstrumentor.kt",
+        "HookMethodVisitor.kt",
+        "Hooks.kt",
+        "Instrumentor.kt",
+        "StaticMethodStrategy.java",
+        "TraceDataFlowInstrumentor.kt",
+    ],
+    visibility = [
+        "//src/jmh/java/com/code_intelligence/jazzer/instrumentor:__pkg__",
+        "//src/main/java/com/code_intelligence/jazzer/agent:__pkg__",
+        "//src/main/java/com/code_intelligence/jazzer/driver:__pkg__",
+        "//src/test/java/com/code_intelligence/jazzer/instrumentor:__pkg__",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/api:hooks",
+        "//src/main/java/com/code_intelligence/jazzer/runtime:jazzer_bootstrap_compile_only",
+        "//src/main/java/com/code_intelligence/jazzer/utils",
+        "//src/main/java/com/code_intelligence/jazzer/utils:class_name_globber",
+        "//src/main/java/com/code_intelligence/jazzer/utils:log",
+        "@com_github_classgraph_classgraph//:classgraph",
+        "@com_github_jetbrains_kotlin//:kotlin-reflect",
+        "@jazzer_jacoco//:jacoco_internal",
+        "@org_ow2_asm_asm//jar",
+        "@org_ow2_asm_asm_commons//jar",
+        "@org_ow2_asm_asm_tree//jar",
+    ],
+)
+
+ktlint()
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/ClassInstrumentor.kt b/src/main/java/com/code_intelligence/jazzer/instrumentor/ClassInstrumentor.kt
similarity index 76%
rename from agent/src/main/java/com/code_intelligence/jazzer/instrumentor/ClassInstrumentor.kt
rename to src/main/java/com/code_intelligence/jazzer/instrumentor/ClassInstrumentor.kt
index 4c3eabc..a93e29c 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/ClassInstrumentor.kt
+++ b/src/main/java/com/code_intelligence/jazzer/instrumentor/ClassInstrumentor.kt
@@ -20,7 +20,7 @@
     return ((classfileBuffer[6].toInt() and 0xff) shl 8) or (classfileBuffer[7].toInt() and 0xff)
 }
 
-class ClassInstrumentor constructor(bytecode: ByteArray) {
+class ClassInstrumentor(private val internalClassName: String, bytecode: ByteArray) {
 
     var instrumentedBytecode = bytecode
         private set
@@ -31,19 +31,21 @@
             defaultCoverageMap,
             initialEdgeId,
         )
-        instrumentedBytecode = edgeCoverageInstrumentor.instrument(instrumentedBytecode)
+        instrumentedBytecode = edgeCoverageInstrumentor.instrument(internalClassName, instrumentedBytecode)
         return edgeCoverageInstrumentor.numEdges
     }
 
     fun traceDataFlow(instrumentations: Set<InstrumentationType>) {
-        instrumentedBytecode = TraceDataFlowInstrumentor(instrumentations).instrument(instrumentedBytecode)
+        instrumentedBytecode =
+            TraceDataFlowInstrumentor(instrumentations).instrument(internalClassName, instrumentedBytecode)
     }
 
-    fun hooks(hooks: Iterable<Hook>) {
+    fun hooks(hooks: Iterable<Hook>, classWithHooksEnabledField: String?) {
         instrumentedBytecode = HookInstrumentor(
             hooks,
-            java6Mode = extractClassFileMajorVersion(instrumentedBytecode) < 51
-        ).instrument(instrumentedBytecode)
+            java6Mode = extractClassFileMajorVersion(instrumentedBytecode) < 51,
+            classWithHooksEnabledField = classWithHooksEnabledField,
+        ).instrument(internalClassName, instrumentedBytecode)
     }
 
     companion object {
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/CoverageRecorder.kt b/src/main/java/com/code_intelligence/jazzer/instrumentor/CoverageRecorder.kt
similarity index 93%
rename from agent/src/main/java/com/code_intelligence/jazzer/instrumentor/CoverageRecorder.kt
rename to src/main/java/com/code_intelligence/jazzer/instrumentor/CoverageRecorder.kt
index 098cf38..56fb572 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/CoverageRecorder.kt
+++ b/src/main/java/com/code_intelligence/jazzer/instrumentor/CoverageRecorder.kt
@@ -43,10 +43,14 @@
     private val additionalCoverage = mutableSetOf<Int>()
 
     fun recordInstrumentedClass(internalClassName: String, bytecode: ByteArray, firstId: Int, numIds: Int) {
-        if (startTimestamp == null)
+        if (startTimestamp == null) {
             startTimestamp = Instant.now()
+        }
         instrumentedClassInfo[internalClassName] = InstrumentedClassInfo(
-            CRC64.classId(bytecode), firstId, firstId + numIds, bytecode
+            CRC64.classId(bytecode),
+            firstId,
+            firstId + numIds,
+            bytecode,
         )
     }
 
@@ -63,7 +67,8 @@
      * [dumpCoverageReport] dumps a human-readable coverage report of files using any [coveredIds] to [dumpFileName].
      */
     @JvmStatic
-    fun dumpCoverageReport(coveredIds: IntArray, dumpFileName: String) {
+    @JvmOverloads
+    fun dumpCoverageReport(dumpFileName: String, coveredIds: IntArray = CoverageMap.getEverCoveredIds()) {
         File(dumpFileName).bufferedWriter().use { writer ->
             writer.write(computeFileCoverage(coveredIds))
         }
@@ -75,7 +80,7 @@
         return coverage.sourceFiles.joinToString(
             "\n",
             prefix = "Branch coverage:\n",
-            postfix = "\n\n"
+            postfix = "\n\n",
         ) { fileCoverage ->
             val counter = fileCoverage.branchCounter
             val percentage = 100 * counter.coveredRatio
@@ -83,7 +88,7 @@
         } + coverage.sourceFiles.joinToString(
             "\n",
             prefix = "Line coverage:\n",
-            postfix = "\n\n"
+            postfix = "\n\n",
         ) { fileCoverage ->
             val counter = fileCoverage.lineCounter
             val percentage = 100 * counter.coveredRatio
@@ -91,7 +96,7 @@
         } + coverage.sourceFiles.joinToString(
             "\n",
             prefix = "Incompletely covered lines:\n",
-            postfix = "\n\n"
+            postfix = "\n\n",
         ) { fileCoverage ->
             "${fileCoverage.name}: " + (fileCoverage.firstLine..fileCoverage.lastLine).filter {
                 val instructions = fileCoverage.getLine(it).instructionCounter
@@ -114,9 +119,10 @@
      * can be used by the JaCoCo report command to create reports also including not covered files.
      */
     @JvmStatic
-    fun dumpJacocoCoverage(coveredIds: IntArray, dumpFileName: String) {
+    @JvmOverloads
+    fun dumpJacocoCoverage(dumpFileName: String, coveredIds: IntArray = CoverageMap.getEverCoveredIds()) {
         FileOutputStream(dumpFileName).use { outStream ->
-            dumpJacocoCoverage(coveredIds, outStream)
+            dumpJacocoCoverage(outStream, coveredIds)
         }
     }
 
@@ -124,7 +130,7 @@
      * [dumpJacocoCoverage] dumps the JaCoCo coverage of files using any [coveredIds] to [outStream].
      */
     @JvmStatic
-    fun dumpJacocoCoverage(coveredIds: IntArray, outStream: OutputStream) {
+    fun dumpJacocoCoverage(outStream: OutputStream, coveredIds: IntArray) {
         // Return if no class has been instrumented.
         val startTimestamp = startTimestamp ?: return
 
@@ -134,7 +140,7 @@
         val dumpTimestamp = Instant.now()
         val outWriter = ExecutionDataWriter(outStream)
         outWriter.visitSessionInfo(
-            SessionInfo(UUID.randomUUID().toString(), startTimestamp.epochSecond, dumpTimestamp.epochSecond)
+            SessionInfo(UUID.randomUUID().toString(), startTimestamp.epochSecond, dumpTimestamp.epochSecond),
         )
         analyzeJacocoCoverage(coveredIds.toSet()).accept(outWriter)
     }
@@ -192,7 +198,7 @@
                         executionDataStore,
                         coverage,
                         info.bytecode,
-                        internalClassName
+                        internalClassName,
                     )
             }
             coverage
@@ -236,7 +242,7 @@
                                 emptyExecutionDataStore,
                                 coverage,
                                 resource.load(),
-                                classInfo.name.replace('.', '/')
+                                classInfo.name.replace('.', '/'),
                             )
                         }
                     }
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/DescriptorUtils.kt b/src/main/java/com/code_intelligence/jazzer/instrumentor/DescriptorUtils.kt
similarity index 90%
rename from agent/src/main/java/com/code_intelligence/jazzer/instrumentor/DescriptorUtils.kt
rename to src/main/java/com/code_intelligence/jazzer/instrumentor/DescriptorUtils.kt
index 80cbe80..9d02c04 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/DescriptorUtils.kt
+++ b/src/main/java/com/code_intelligence/jazzer/instrumentor/DescriptorUtils.kt
@@ -14,6 +14,21 @@
 
 package com.code_intelligence.jazzer.instrumentor
 
+import org.objectweb.asm.Type
+import java.lang.reflect.Constructor
+import java.lang.reflect.Executable
+import java.lang.reflect.Method
+
+val Class<*>.descriptor: String
+    get() = Type.getDescriptor(this)
+
+val Executable.descriptor: String
+    get() = if (this is Method) {
+        Type.getMethodDescriptor(this)
+    } else {
+        Type.getConstructorDescriptor(this as Constructor<*>?)
+    }
+
 internal fun isPrimitiveType(typeDescriptor: String): Boolean {
     return typeDescriptor in arrayOf("B", "C", "D", "F", "I", "J", "S", "V", "Z")
 }
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/DeterministicRandom.kt b/src/main/java/com/code_intelligence/jazzer/instrumentor/DeterministicRandom.kt
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/instrumentor/DeterministicRandom.kt
rename to src/main/java/com/code_intelligence/jazzer/instrumentor/DeterministicRandom.kt
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/EdgeCoverageInstrumentor.kt b/src/main/java/com/code_intelligence/jazzer/instrumentor/EdgeCoverageInstrumentor.kt
similarity index 96%
rename from agent/src/main/java/com/code_intelligence/jazzer/instrumentor/EdgeCoverageInstrumentor.kt
rename to src/main/java/com/code_intelligence/jazzer/instrumentor/EdgeCoverageInstrumentor.kt
index 8fb3dc2..975f398 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/EdgeCoverageInstrumentor.kt
+++ b/src/main/java/com/code_intelligence/jazzer/instrumentor/EdgeCoverageInstrumentor.kt
@@ -49,7 +49,7 @@
         mv: MethodVisitor,
         edgeId: Int,
         variable: Int,
-        coverageMapInternalClassName: String
+        coverageMapInternalClassName: String,
     )
 
     /**
@@ -97,17 +97,17 @@
             "enlargeIfNeeded",
             methodType(
                 Void::class.javaPrimitiveType,
-                Int::class.javaPrimitiveType
-            )
+                Int::class.javaPrimitiveType,
+            ),
         )
 
-    override fun instrument(bytecode: ByteArray): ByteArray {
+    override fun instrument(internalClassName: String, bytecode: ByteArray): ByteArray {
         val reader = InstrSupport.classReaderFor(bytecode)
         val writer = ClassWriter(reader, 0)
         val version = InstrSupport.getMajorVersion(reader)
         val visitor = EdgeCoverageClassProbesAdapter(
             ClassInstrumenter(edgeCoverageProbeArrayStrategy, edgeCoverageProbeInserterFactory, writer),
-            InstrSupport.needsFrames(version)
+            InstrSupport.needsFrames(version),
         )
         reader.accept(visitor, ClassReader.EXPAND_FRAMES)
         return writer.toByteArray()
@@ -164,7 +164,7 @@
             cpv.visitTotalProbeCount(numEdges)
             // Avoid calling super.visitEnd() as that invokes cpv.visitTotalProbeCount with an
             // incorrect value of `count`.
-            cv.visitEnd()
+            cpv.visitEnd()
         }
     }
 
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/Hook.kt b/src/main/java/com/code_intelligence/jazzer/instrumentor/Hook.kt
similarity index 96%
rename from agent/src/main/java/com/code_intelligence/jazzer/instrumentor/Hook.kt
rename to src/main/java/com/code_intelligence/jazzer/instrumentor/Hook.kt
index ff68ad9..077ab10 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/Hook.kt
+++ b/src/main/java/com/code_intelligence/jazzer/instrumentor/Hook.kt
@@ -18,7 +18,6 @@
 
 import com.code_intelligence.jazzer.api.HookType
 import com.code_intelligence.jazzer.api.MethodHook
-import com.code_intelligence.jazzer.utils.descriptor
 import java.lang.invoke.MethodHandle
 import java.lang.reflect.Method
 import java.lang.reflect.Modifier
@@ -35,7 +34,7 @@
     private val hookClassName: String,
     val hookInternalClassName: String,
     val hookMethodName: String,
-    val hookMethodDescriptor: String
+    val hookMethodDescriptor: String,
 ) {
 
     override fun toString(): String {
@@ -65,7 +64,7 @@
                 hookClassName = hookClassName,
                 hookInternalClassName = hookClassName.replace('.', '/'),
                 hookMethodName = hookMethod.name,
-                hookMethodDescriptor = hookMethod.descriptor
+                hookMethodDescriptor = hookMethod.descriptor,
             )
         }
 
@@ -103,8 +102,8 @@
                             hookMethod.returnType.descriptor in listOf(
                                 java.lang.Object::class.java.descriptor,
                                 potentialHook.targetReturnTypeDescriptor,
-                                potentialHook.targetWrappedReturnTypeDescriptor
-                            )
+                                potentialHook.targetWrappedReturnTypeDescriptor,
+                            ),
                         ) {
                             "$potentialHook: return type must have type Object or match the descriptors ${potentialHook.targetReturnTypeDescriptor} or ${potentialHook.targetWrappedReturnTypeDescriptor}"
                         }
@@ -118,7 +117,7 @@
                 if (potentialHook.targetReturnTypeDescriptor != null) {
                     require(
                         parameterTypes[4] == java.lang.Object::class.java ||
-                            parameterTypes[4].descriptor == potentialHook.targetWrappedReturnTypeDescriptor
+                            parameterTypes[4].descriptor == potentialHook.targetWrappedReturnTypeDescriptor,
                     ) {
                         "$potentialHook: fifth parameter must have type Object or match the descriptor ${potentialHook.targetWrappedReturnTypeDescriptor}"
                     }
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/HookInstrumentor.kt b/src/main/java/com/code_intelligence/jazzer/instrumentor/HookInstrumentor.kt
similarity index 68%
rename from agent/src/main/java/com/code_intelligence/jazzer/instrumentor/HookInstrumentor.kt
rename to src/main/java/com/code_intelligence/jazzer/instrumentor/HookInstrumentor.kt
index 6db7660..3c0d97c 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/HookInstrumentor.kt
+++ b/src/main/java/com/code_intelligence/jazzer/instrumentor/HookInstrumentor.kt
@@ -19,11 +19,15 @@
 import org.objectweb.asm.ClassWriter
 import org.objectweb.asm.MethodVisitor
 
-internal class HookInstrumentor(private val hooks: Iterable<Hook>, private val java6Mode: Boolean) : Instrumentor {
+internal class HookInstrumentor(
+    private val hooks: Iterable<Hook>,
+    private val java6Mode: Boolean,
+    private val classWithHooksEnabledField: String?,
+) : Instrumentor {
 
     private lateinit var random: DeterministicRandom
 
-    override fun instrument(bytecode: ByteArray): ByteArray {
+    override fun instrument(internalClassName: String, bytecode: ByteArray): ByteArray {
         val reader = ClassReader(bytecode)
         val writer = ClassWriter(reader, ClassWriter.COMPUTE_MAXS)
         random = DeterministicRandom("hook", reader.className)
@@ -36,10 +40,21 @@
                 exceptions: Array<String>?,
             ): MethodVisitor? {
                 val mv = cv.visitMethod(access, name, descriptor, signature, exceptions) ?: return null
-                return if (shouldInstrument(access))
-                    makeHookMethodVisitor(access, descriptor, mv, hooks, java6Mode, random)
-                else
+                return if (shouldInstrument(access)) {
+                    makeHookMethodVisitor(
+                        internalClassName,
+                        access,
+                        name,
+                        descriptor,
+                        mv,
+                        hooks,
+                        java6Mode,
+                        random,
+                        classWithHooksEnabledField,
+                    )
+                } else {
                     mv
+                }
             }
         }
         reader.accept(interceptor, ClassReader.EXPAND_FRAMES)
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/HookMethodVisitor.kt b/src/main/java/com/code_intelligence/jazzer/instrumentor/HookMethodVisitor.kt
similarity index 79%
rename from agent/src/main/java/com/code_intelligence/jazzer/instrumentor/HookMethodVisitor.kt
rename to src/main/java/com/code_intelligence/jazzer/instrumentor/HookMethodVisitor.kt
index 1694be5..f5118fd 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/HookMethodVisitor.kt
+++ b/src/main/java/com/code_intelligence/jazzer/instrumentor/HookMethodVisitor.kt
@@ -16,31 +16,71 @@
 
 import com.code_intelligence.jazzer.api.HookType
 import org.objectweb.asm.Handle
+import org.objectweb.asm.Label
 import org.objectweb.asm.MethodVisitor
 import org.objectweb.asm.Opcodes
 import org.objectweb.asm.Type
+import org.objectweb.asm.commons.AnalyzerAdapter
 import org.objectweb.asm.commons.LocalVariablesSorter
 import java.util.concurrent.atomic.AtomicBoolean
 
 internal fun makeHookMethodVisitor(
+    owner: String,
     access: Int,
+    name: String?,
     descriptor: String?,
     methodVisitor: MethodVisitor?,
     hooks: Iterable<Hook>,
     java6Mode: Boolean,
     random: DeterministicRandom,
+    classWithHooksEnabledField: String?,
 ): MethodVisitor {
-    return HookMethodVisitor(access, descriptor, methodVisitor, hooks, java6Mode, random).lvs
+    return HookMethodVisitor(
+        owner,
+        access,
+        name,
+        descriptor,
+        methodVisitor,
+        hooks,
+        java6Mode,
+        random,
+        classWithHooksEnabledField,
+    ).lvs
 }
 
 private class HookMethodVisitor(
+    owner: String,
     access: Int,
+    val name: String?,
     descriptor: String?,
     methodVisitor: MethodVisitor?,
     hooks: Iterable<Hook>,
     private val java6Mode: Boolean,
     private val random: DeterministicRandom,
-) : MethodVisitor(Instrumentor.ASM_API_VERSION, methodVisitor) {
+    private val classWithHooksEnabledField: String?,
+) : MethodVisitor(
+    Instrumentor.ASM_API_VERSION,
+    // AnalyzerAdapter computes stack map frames at every instruction, which is needed for the
+    // conditional hook logic as it adds a conditional jump. Before Java 7, stack map frames were
+    // neither included nor required in class files.
+    //
+    // Note: Delegating to AnalyzerAdapter rather than having AnalyzerAdapter delegate to our
+    // MethodVisitor is unusual. We do this since we insert conditional jumps around method calls,
+    // which requires knowing the stack map both before and after the call. If AnalyzerAdapter
+    // delegated to this MethodVisitor, we would only be able to access the stack map before the
+    // method call in visitMethodInsn.
+    if (classWithHooksEnabledField != null && !java6Mode) {
+        AnalyzerAdapter(
+            owner,
+            access,
+            name,
+            descriptor,
+            methodVisitor,
+        )
+    } else {
+        methodVisitor
+    },
+) {
 
     companion object {
         private val showUnsupportedHookWarning = AtomicBoolean(true)
@@ -58,8 +98,9 @@
 
     private val hooks = hooks.groupBy { hook ->
         var hookKey = "${hook.hookType}#${hook.targetInternalClassName}#${hook.targetMethodName}"
-        if (hook.targetMethodDescriptor != null)
+        if (hook.targetMethodDescriptor != null) {
             hookKey += "#${hook.targetMethodDescriptor}"
+        }
         hookKey
     }
 
@@ -77,6 +118,28 @@
         handleMethodInsn(opcode, owner, methodName, methodDescriptor, isInterface)
     }
 
+    // Transforms a stack map specification from the form used by the JVM and AnalyzerAdapter, where
+    // LONG and DOUBLE values are followed by an additional TOP entry, to the form accepted by
+    // visitFrame, which doesn't expect this additional entry.
+    private fun dropImplicitTop(stack: Collection<Any>?): Array<Any>? {
+        if (stack == null) {
+            return null
+        }
+        val filteredStack = mutableListOf<Any>()
+        var previousElement: Any? = null
+        for (element in stack) {
+            if (element != Opcodes.TOP || (previousElement != Opcodes.DOUBLE && previousElement != Opcodes.LONG)) {
+                filteredStack.add(element)
+            }
+            previousElement = element
+        }
+        return filteredStack.toTypedArray()
+    }
+
+    private fun storeFrame(aa: AnalyzerAdapter?): Pair<Array<Any>?, Array<Any>?>? {
+        return Pair(dropImplicitTop((aa ?: return null).locals), dropImplicitTop(aa.stack))
+    }
+
     fun handleMethodInsn(
         opcode: Int,
         owner: String,
@@ -91,6 +154,39 @@
             return
         }
 
+        val skipHooksLabel = Label()
+        val applyHooksLabel = Label()
+        val useConditionalHooks = classWithHooksEnabledField != null
+        var postCallFrame: Pair<Array<Any>?, Array<Any>?>? = null
+        if (useConditionalHooks) {
+            val preCallFrame = (mv as? AnalyzerAdapter)?.let { storeFrame(it) }
+            // If hooks aren't enabled, skip the hook invocations.
+            mv.visitFieldInsn(
+                Opcodes.GETSTATIC,
+                classWithHooksEnabledField,
+                "hooksEnabled",
+                "Z",
+            )
+            mv.visitJumpInsn(Opcodes.IFNE, applyHooksLabel)
+            mv.visitMethodInsn(opcode, owner, methodName, methodDescriptor, isInterface)
+            postCallFrame = (mv as? AnalyzerAdapter)?.let { storeFrame(it) }
+            mv.visitJumpInsn(Opcodes.GOTO, skipHooksLabel)
+            // Needs a stack map frame as both the successor of an unconditional jump and the target
+            // of a jump.
+            mv.visitLabel(applyHooksLabel)
+            if (preCallFrame != null) {
+                mv.visitFrame(
+                    Opcodes.F_NEW,
+                    preCallFrame.first?.size ?: 0,
+                    preCallFrame.first,
+                    preCallFrame.second?.size ?: 0,
+                    preCallFrame.second,
+                )
+            }
+            // All successor instructions emitted below do not have a stack map frame attached, so
+            // we do not need to emit a NOP to prevent duplicated stack map frames.
+        }
+
         val paramDescriptors = extractParameterTypeDescriptors(methodDescriptor)
         val localObjArr = storeMethodArguments(paramDescriptors)
         // If the method we're hooking is not static there is now a reference to
@@ -151,8 +247,8 @@
                             owner,
                             methodName,
                             methodDescriptor,
-                            isInterface
-                        )
+                            isInterface,
+                        ),
                     ) // push MethodHandle
                 }
                 // Stack layout: ... | MethodHandle (objectref)
@@ -175,7 +271,7 @@
                         hook.hookInternalClassName,
                         hook.hookMethodName,
                         hook.hookMethodDescriptor,
-                        false
+                        false,
                     )
 
                     // Call the original method if this is the last BEFORE hook. If not, the original method will be
@@ -191,6 +287,7 @@
                         mv.visitMethodInsn(opcode, owner, methodName, methodDescriptor, isInterface)
                     }
                 }
+
                 HookType.REPLACE -> {
                     // Call the hook method
                     mv.visitMethodInsn(
@@ -198,7 +295,7 @@
                         hook.hookInternalClassName,
                         hook.hookMethodName,
                         hook.hookMethodDescriptor,
-                        false
+                        false,
                     )
                     // Stack layout: ... | [return value (primitive/objectref)]
                     // Check if we need to process the return value
@@ -218,6 +315,7 @@
                         }
                     }
                 }
+
                 HookType.AFTER -> {
                     // Call the original method before the first AFTER hook
                     if (index == 0 || matchingHooks[index - 1].hookType != HookType.AFTER) {
@@ -248,7 +346,7 @@
                         hook.hookInternalClassName,
                         hook.hookMethodName,
                         hook.hookMethodDescriptor,
-                        false
+                        false,
                     )
                     // Stack layout: ...
                     // Push the return value on the stack after the last AFTER hook if the original method returns a value
@@ -262,13 +360,29 @@
                 }
             }
         }
+        if (useConditionalHooks) {
+            // Needs a stack map frame as the target of a jump.
+            mv.visitLabel(skipHooksLabel)
+            if (postCallFrame != null) {
+                mv.visitFrame(
+                    Opcodes.F_NEW,
+                    postCallFrame.first?.size ?: 0,
+                    postCallFrame.first,
+                    postCallFrame.second?.size ?: 0,
+                    postCallFrame.second,
+                )
+            }
+            // We do not control the next visitor calls, but we must not emit two frames for the
+            // same instruction.
+            mv.visitInsn(Opcodes.NOP)
+        }
     }
 
     private fun isMethodInvocationOp(opcode: Int) = opcode in listOf(
         Opcodes.INVOKEVIRTUAL,
         Opcodes.INVOKEINTERFACE,
         Opcodes.INVOKESTATIC,
-        Opcodes.INVOKESPECIAL
+        Opcodes.INVOKESPECIAL,
     )
 
     private fun findMatchingHooks(owner: String, name: String, descriptor: String): List<Hook> {
@@ -280,7 +394,7 @@
         val replaceHookCount = result.count { it.hookType == HookType.REPLACE }
         check(
             replaceHookCount == 0 ||
-                (replaceHookCount == 1 && result.size == 1)
+                (replaceHookCount == 1 && result.size == 1),
         ) {
             "For a given method, You can either have a single REPLACE hook or BEFORE/AFTER hooks. Found:\n $result"
         }
@@ -297,7 +411,7 @@
                     """WARN: Some hooks could not be applied to class files built for Java 7 or lower.
                       |WARN: Ensure that the fuzz target and its dependencies are compiled with
                       |WARN: -target 8 or higher to identify as many bugs as possible.
-            """.trimMargin()
+                    """.trimMargin(),
                 )
             }
             return true
@@ -393,7 +507,7 @@
             wrappedTypeDescriptor,
             methodName,
             "()$primitiveTypeDescriptor",
-            false
+            false,
         )
     }
 }
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/Hooks.kt b/src/main/java/com/code_intelligence/jazzer/instrumentor/Hooks.kt
similarity index 74%
rename from agent/src/main/java/com/code_intelligence/jazzer/instrumentor/Hooks.kt
rename to src/main/java/com/code_intelligence/jazzer/instrumentor/Hooks.kt
index 66a21ee..a26c0d6 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/Hooks.kt
+++ b/src/main/java/com/code_intelligence/jazzer/instrumentor/Hooks.kt
@@ -17,19 +17,38 @@
 import com.code_intelligence.jazzer.api.MethodHook
 import com.code_intelligence.jazzer.api.MethodHooks
 import com.code_intelligence.jazzer.utils.ClassNameGlobber
-import com.code_intelligence.jazzer.utils.descriptor
+import com.code_intelligence.jazzer.utils.Log
 import io.github.classgraph.ClassGraph
 import io.github.classgraph.ScanResult
+import java.lang.instrument.Instrumentation
 import java.lang.reflect.Method
+import java.util.jar.JarFile
 
 data class Hooks(
     val hooks: List<Hook>,
     val hookClasses: Set<Class<*>>,
-    val additionalHookClassNameGlobber: ClassNameGlobber
+    val additionalHookClassNameGlobber: ClassNameGlobber,
 ) {
 
     companion object {
-        fun loadHooks(vararg hookClassNames: Set<String>): List<Hooks> {
+
+        fun appendHooksToBootstrapClassLoaderSearch(instrumentation: Instrumentation, hookClassNames: Set<String>) {
+            hookClassNames.mapNotNull { hook ->
+                val hookClassFilePath = "/${hook.replace('.', '/')}.class"
+                val hookClassFile = Companion::class.java.getResource(hookClassFilePath) ?: return@mapNotNull null
+                if ("jar" != hookClassFile.protocol) {
+                    return@mapNotNull null
+                }
+                // hookClassFile.file looks as follows:
+                // file:/tmp/ExampleFuzzerHooks_deploy.jar!/com/example/ExampleFuzzerHooks.class
+                hookClassFile.file.removePrefix("file:").takeWhile { it != '!' }
+            }
+                .toSet()
+                .map { JarFile(it) }
+                .forEach { instrumentation.appendToBootstrapClassLoaderSearch(it) }
+        }
+
+        fun loadHooks(excludeHookClassNames: List<String>, vararg hookClassNames: Set<String>): List<Hooks> {
             return ClassGraph()
                 .enableClassInfo()
                 .enableSystemJarsAndModules()
@@ -38,39 +57,38 @@
                 .use { scanResult ->
                     // Capture scanResult in HooksLoader field to not pass it through
                     // all internal hook loading methods.
-                    val loader = HooksLoader(scanResult)
+                    val loader = HooksLoader(scanResult, excludeHookClassNames)
                     hookClassNames.map(loader::load)
                 }
         }
 
-        private class HooksLoader(private val scanResult: ScanResult) {
+        private class HooksLoader(private val scanResult: ScanResult, val excludeHookClassNames: List<String>) {
+
             fun load(hookClassNames: Set<String>): Hooks {
                 val hooksWithHookClasses = hookClassNames.flatMap(::loadHooks)
                 val hooks = hooksWithHookClasses.map { it.first }
                 val hookClasses = hooksWithHookClasses.map { it.second }.toSet()
                 val additionalHookClassNameGlobber = ClassNameGlobber(
                     hooks.flatMap(Hook::additionalClassesToHook),
-                    emptyList()
+                    excludeHookClassNames,
                 )
                 return Hooks(hooks, hookClasses, additionalHookClassNameGlobber)
             }
 
             private fun loadHooks(hookClassName: String): List<Pair<Hook, Class<*>>> {
                 return try {
-                    // Custom hook classes outside the agent jar can not be found by bootstrap
-                    // class loader, so use the system class loader as that will be the main application
-                    // class loader and can access jars on the classpath.
                     // We let the static initializers of hook classes execute so that hooks can run
                     // code before the fuzz target class has been loaded (e.g., register themselves
                     // for the onFuzzTargetReady callback).
-                    val hookClass = Class.forName(hookClassName, true, ClassLoader.getSystemClassLoader())
+                    val hookClass =
+                        Class.forName(hookClassName, true, Companion::class.java.classLoader)
                     loadHooks(hookClass).also {
-                        println("INFO: Loaded ${it.size} hooks from $hookClassName")
+                        Log.info("Loaded ${it.size} hooks from $hookClassName")
                     }.map {
                         it to hookClass
                     }
                 } catch (e: ClassNotFoundException) {
-                    println("WARN: Failed to load hooks from $hookClassName: ${e.printStackTrace()}")
+                    Log.warn("Failed to load hooks from $hookClassName", e)
                     emptyList()
                 }
             }
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/Instrumentor.kt b/src/main/java/com/code_intelligence/jazzer/instrumentor/Instrumentor.kt
similarity index 93%
rename from agent/src/main/java/com/code_intelligence/jazzer/instrumentor/Instrumentor.kt
rename to src/main/java/com/code_intelligence/jazzer/instrumentor/Instrumentor.kt
index 7879384..c6db94c 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/Instrumentor.kt
+++ b/src/main/java/com/code_intelligence/jazzer/instrumentor/Instrumentor.kt
@@ -27,7 +27,7 @@
 }
 
 internal interface Instrumentor {
-    fun instrument(bytecode: ByteArray): ByteArray
+    fun instrument(internalClassName: String, bytecode: ByteArray): ByteArray
 
     fun shouldInstrument(access: Int): Boolean {
         return (access and Opcodes.ACC_ABSTRACT == 0) &&
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/StaticMethodStrategy.java b/src/main/java/com/code_intelligence/jazzer/instrumentor/StaticMethodStrategy.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/instrumentor/StaticMethodStrategy.java
rename to src/main/java/com/code_intelligence/jazzer/instrumentor/StaticMethodStrategy.java
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/TraceDataFlowInstrumentor.kt b/src/main/java/com/code_intelligence/jazzer/instrumentor/TraceDataFlowInstrumentor.kt
similarity index 92%
rename from agent/src/main/java/com/code_intelligence/jazzer/instrumentor/TraceDataFlowInstrumentor.kt
rename to src/main/java/com/code_intelligence/jazzer/instrumentor/TraceDataFlowInstrumentor.kt
index 65f11e5..a46e72d 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/instrumentor/TraceDataFlowInstrumentor.kt
+++ b/src/main/java/com/code_intelligence/jazzer/instrumentor/TraceDataFlowInstrumentor.kt
@@ -14,7 +14,6 @@
 
 package com.code_intelligence.jazzer.instrumentor
 
-import com.code_intelligence.jazzer.runtime.TraceDataFlowNativeCallbacks
 import org.objectweb.asm.ClassReader
 import org.objectweb.asm.ClassWriter
 import org.objectweb.asm.Opcodes
@@ -29,12 +28,14 @@
 import org.objectweb.asm.tree.MethodNode
 import org.objectweb.asm.tree.TableSwitchInsnNode
 
-internal class TraceDataFlowInstrumentor(private val types: Set<InstrumentationType>, callbackClass: Class<*> = TraceDataFlowNativeCallbacks::class.java) : Instrumentor {
+internal class TraceDataFlowInstrumentor(
+    private val types: Set<InstrumentationType>,
+    private val callbackInternalClassName: String = "com/code_intelligence/jazzer/runtime/TraceDataFlowNativeCallbacks",
+) : Instrumentor {
 
-    private val callbackInternalClassName = callbackClass.name.replace('.', '/')
     private lateinit var random: DeterministicRandom
 
-    override fun instrument(bytecode: ByteArray): ByteArray {
+    override fun instrument(internalClassName: String, bytecode: ByteArray): ByteArray {
         val node = ClassNode()
         val reader = ClassReader(bytecode)
         reader.accept(node, 0)
@@ -50,7 +51,6 @@
         return writer.toByteArray()
     }
 
-    @OptIn(ExperimentalUnsignedTypes::class)
     private fun addDataFlowInstrumentation(method: MethodNode) {
         loop@ for (inst in method.instructions.toArray()) {
             when (inst.opcode) {
@@ -61,13 +61,15 @@
                 }
                 Opcodes.IF_ICMPEQ, Opcodes.IF_ICMPNE,
                 Opcodes.IF_ICMPLT, Opcodes.IF_ICMPLE,
-                Opcodes.IF_ICMPGT, Opcodes.IF_ICMPGE -> {
+                Opcodes.IF_ICMPGT, Opcodes.IF_ICMPGE,
+                -> {
                     if (InstrumentationType.CMP !in types) continue@loop
                     method.instructions.insertBefore(inst, intCmpInstrumentation())
                 }
                 Opcodes.IFEQ, Opcodes.IFNE,
                 Opcodes.IFLT, Opcodes.IFLE,
-                Opcodes.IFGT, Opcodes.IFGE -> {
+                Opcodes.IFGT, Opcodes.IFGE,
+                -> {
                     if (InstrumentationType.CMP !in types) continue@loop
                     // The IF* opcodes are often used to branch based on the result of a compare
                     // instruction for a type other than int. The operands of this compare will
@@ -76,8 +78,9 @@
                     // operands will be in {-1, 0, 1}. Skip instrumentation for it.
                     if (inst.previous?.opcode in listOf(Opcodes.DCMPG, Opcodes.DCMPL, Opcodes.FCMPG, Opcodes.DCMPL) ||
                         (inst.previous as? MethodInsnNode)?.name == "traceCmpLongWrapper"
-                    )
+                    ) {
                         continue@loop
+                    }
                     method.instructions.insertBefore(inst, ifInstrumentation())
                 }
                 Opcodes.LOOKUPSWITCH, Opcodes.TABLESWITCH -> {
@@ -88,13 +91,15 @@
                     // sorted by unsigned value.
                     val caseValues = when (inst) {
                         is LookupSwitchInsnNode -> {
-                            if (inst.keys.isEmpty() || (0 <= inst.keys.first() && inst.keys.last() < 256))
+                            if (inst.keys.isEmpty() || (0 <= inst.keys.first() && inst.keys.last() < 256)) {
                                 continue@loop
+                            }
                             inst.keys
                         }
                         is TableSwitchInsnNode -> {
-                            if (0 <= inst.min && inst.max < 256)
+                            if (0 <= inst.min && inst.max < 256) {
                                 continue@loop
+                            }
                             (inst.min..inst.max).filter { caseValue ->
                                 val index = caseValue - inst.min
                                 // Filter out "gap cases".
@@ -117,7 +122,8 @@
                 Opcodes.AALOAD, Opcodes.BALOAD,
                 Opcodes.CALOAD, Opcodes.DALOAD,
                 Opcodes.FALOAD, Opcodes.IALOAD,
-                Opcodes.LALOAD, Opcodes.SALOAD -> {
+                Opcodes.LALOAD, Opcodes.SALOAD,
+                -> {
                     if (InstrumentationType.GEP !in types) continue@loop
                     if (!isConstantIntegerPushInsn(inst.previous)) continue@loop
                     method.instructions.insertBefore(inst, gepLoadInstrumentation())
@@ -227,9 +233,13 @@
     companion object {
         // Low constants (0, 1) are omitted as they create a lot of noise.
         val CONSTANT_INTEGER_PUSH_OPCODES = listOf(
-            Opcodes.BIPUSH, Opcodes.SIPUSH,
+            Opcodes.BIPUSH,
+            Opcodes.SIPUSH,
             Opcodes.LDC,
-            Opcodes.ICONST_2, Opcodes.ICONST_3, Opcodes.ICONST_4, Opcodes.ICONST_5
+            Opcodes.ICONST_2,
+            Opcodes.ICONST_3,
+            Opcodes.ICONST_4,
+            Opcodes.ICONST_5,
         )
 
         data class MethodInfo(val internalClassName: String, val name: String, val returnType: String)
diff --git a/agent/agent_shade_rules b/src/main/java/com/code_intelligence/jazzer/jazzer_shade_rules.jarjar
similarity index 100%
rename from agent/agent_shade_rules
rename to src/main/java/com/code_intelligence/jazzer/jazzer_shade_rules.jarjar
diff --git a/src/main/java/com/code_intelligence/jazzer/junit/AgentConfigurator.java b/src/main/java/com/code_intelligence/jazzer/junit/AgentConfigurator.java
new file mode 100644
index 0000000..1f286a3
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/junit/AgentConfigurator.java
@@ -0,0 +1,72 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.junit;
+
+import static com.code_intelligence.jazzer.junit.Utils.getClassPathBasedInstrumentationFilter;
+import static com.code_intelligence.jazzer.junit.Utils.getLegacyInstrumentationFilter;
+
+import java.io.File;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.jupiter.api.extension.ExtensionContext;
+
+class AgentConfigurator {
+  private static final AtomicBoolean hasBeenConfigured = new AtomicBoolean();
+
+  static void forRegressionTest(ExtensionContext extensionContext) {
+    if (!hasBeenConfigured.compareAndSet(false, true)) {
+      return;
+    }
+
+    applyCommonConfiguration();
+
+    // Add logic to the hook instrumentation that allows us to enable and disable hooks at runtime.
+    System.setProperty("jazzer.internal.conditional_hooks", "true");
+    // Apply all hooks, but no coverage or compare instrumentation.
+    System.setProperty("jazzer.instrumentation_excludes", "**");
+    extensionContext.getConfigurationParameter("jazzer.instrument")
+        .ifPresent(s
+            -> System.setProperty(
+                "jazzer.custom_hook_includes", String.join(File.pathSeparator, s.split(","))));
+  }
+
+  static void forFuzzing(ExtensionContext executionRequest) {
+    if (!hasBeenConfigured.compareAndSet(false, true)) {
+      throw new IllegalStateException("Only a single fuzz test should be executed per fuzzing run");
+    }
+
+    applyCommonConfiguration();
+
+    String instrumentationFilter =
+        executionRequest.getConfigurationParameter("jazzer.instrument")
+            .orElseGet(
+                ()
+                    -> getClassPathBasedInstrumentationFilter(System.getProperty("java.class.path"))
+                           .orElseGet(()
+                                          -> getLegacyInstrumentationFilter(
+                                              executionRequest.getRequiredTestClass())));
+    String filter = String.join(File.pathSeparator, instrumentationFilter.split(","));
+    System.setProperty("jazzer.custom_hook_includes", filter);
+    System.setProperty("jazzer.instrumentation_includes", filter);
+  }
+
+  private static void applyCommonConfiguration() {
+    // Do not hook common IDE and JUnit classes and their dependencies.
+    System.setProperty("jazzer.custom_hook_excludes",
+        String.join(File.pathSeparator, "com.google.testing.junit.**", "com.intellij.**",
+            "org.jetbrains.**", "io.github.classgraph.**", "junit.framework.**", "net.bytebuddy.**",
+            "org.apiguardian.**", "org.assertj.core.**", "org.hamcrest.**", "org.junit.**",
+            "org.opentest4j.**", "org.mockito.**", "org.apache.maven.**", "org.gradle.**"));
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/junit/AgentConfiguringArgumentsProvider.java b/src/main/java/com/code_intelligence/jazzer/junit/AgentConfiguringArgumentsProvider.java
new file mode 100644
index 0000000..e65f028
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/junit/AgentConfiguringArgumentsProvider.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.junit;
+
+import java.util.stream.Stream;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.ArgumentsProvider;
+import org.junit.jupiter.params.support.AnnotationConsumer;
+
+public class AgentConfiguringArgumentsProvider
+    implements ArgumentsProvider, AnnotationConsumer<FuzzTest> {
+  private FuzzTest fuzzTest;
+
+  @Override
+  public void accept(FuzzTest fuzzTest) {
+    this.fuzzTest = fuzzTest;
+  }
+
+  @Override
+  public Stream<? extends Arguments> provideArguments(ExtensionContext extensionContext)
+      throws Exception {
+    // FIXME(fmeum): Calling this here feels like a hack. There should be a lifecycle hook that runs
+    //  before the argument discovery for a ParameterizedTest is kicked off, but I haven't found
+    //  one.
+    FuzzTestExecutor.configureAndInstallAgent(extensionContext, fuzzTest.maxDuration());
+    return Stream.empty();
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/junit/BUILD.bazel b/src/main/java/com/code_intelligence/jazzer/junit/BUILD.bazel
new file mode 100644
index 0000000..3eb8959
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/junit/BUILD.bazel
@@ -0,0 +1,99 @@
+load("@fmeum_rules_jni//jni:defs.bzl", "java_jni_library")
+
+java_library(
+    name = "junit",
+    visibility = ["//deploy:__pkg__"],
+    runtime_deps = [
+        ":fuzz_test",
+    ],
+)
+
+java_library(
+    name = "agent_configurator",
+    srcs = [
+        "AgentConfigurator.java",
+    ],
+    deps = [
+        ":utils",
+        "@maven//:org_junit_jupiter_junit_jupiter_api",
+    ],
+)
+
+java_library(
+    name = "fuzz_test",
+    srcs = [
+        "AgentConfiguringArgumentsProvider.java",
+        "FuzzTest.java",
+        "FuzzTestExtensions.java",
+        "FuzzingArgumentsProvider.java",
+        "SeedArgumentsProvider.java",
+    ],
+    visibility = [
+        "//examples/junit/src/test/java/com/example:__pkg__",
+    ],
+    runtime_deps = [
+        # The JUnit launcher that is part of the Jazzer driver needs this on the classpath
+        # to run an @FuzzTest with JUnit. This will also result in a transitive dependency
+        # in the generated pom file.
+        "@maven//:org_junit_platform_junit_platform_launcher",
+    ],
+    deps = [
+        ":fuzz_test_executor",
+        ":seed_serializer",
+        ":utils",
+        "@maven//:org_junit_jupiter_junit_jupiter_api",
+        "@maven//:org_junit_jupiter_junit_jupiter_params",
+        "@maven//:org_junit_platform_junit_platform_commons",
+    ],
+)
+
+java_jni_library(
+    name = "fuzz_test_executor",
+    srcs = [
+        "FuzzTestExecutor.java",
+    ],
+    native_libs = [
+        "//src/main/native/com/code_intelligence/jazzer/driver:jazzer_driver",
+    ],
+    deps = [
+        ":agent_configurator",
+        ":seed_serializer",
+        ":utils",
+        "//src/main/java/com/code_intelligence/jazzer/agent:agent_installer",
+        "//src/main/java/com/code_intelligence/jazzer/api",
+        "//src/main/java/com/code_intelligence/jazzer/autofuzz",
+        "//src/main/java/com/code_intelligence/jazzer/driver:fuzz_target_holder",
+        "//src/main/java/com/code_intelligence/jazzer/driver:fuzz_target_runner",
+        "//src/main/java/com/code_intelligence/jazzer/driver:opt",
+        "//src/main/java/com/code_intelligence/jazzer/driver/junit:exit_code_exception",
+        "//src/main/java/com/code_intelligence/jazzer/mutation",
+        "//src/main/java/com/code_intelligence/jazzer/utils",
+        "@maven//:org_junit_jupiter_junit_jupiter_api",
+        "@maven//:org_junit_jupiter_junit_jupiter_params",
+        "@maven//:org_junit_platform_junit_platform_commons",
+    ],
+)
+
+java_library(
+    name = "seed_serializer",
+    srcs = ["SeedSerializer.java"],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/api",
+        "//src/main/java/com/code_intelligence/jazzer/autofuzz",
+        "//src/main/java/com/code_intelligence/jazzer/driver:fuzzed_data_provider_impl",
+        "//src/main/java/com/code_intelligence/jazzer/driver:opt",
+        "//src/main/java/com/code_intelligence/jazzer/mutation",
+    ],
+)
+
+java_library(
+    name = "utils",
+    srcs = ["Utils.java"],
+    visibility = ["//src/test/java/com/code_intelligence/jazzer/junit:__pkg__"],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/utils:unsafe_provider",
+        "//src/main/java/com/code_intelligence/jazzer/utils:unsafe_utils",
+        "@maven//:org_junit_jupiter_junit_jupiter_api",
+        "@maven//:org_junit_jupiter_junit_jupiter_params",
+    ],
+)
diff --git a/src/main/java/com/code_intelligence/jazzer/junit/FuzzTest.java b/src/main/java/com/code_intelligence/jazzer/junit/FuzzTest.java
new file mode 100644
index 0000000..041db96
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/junit/FuzzTest.java
@@ -0,0 +1,122 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.junit;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.parallel.ResourceAccessMode;
+import org.junit.jupiter.api.parallel.ResourceLock;
+import org.junit.jupiter.api.parallel.Resources;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ArgumentsSource;
+
+/**
+ * A parameterized test with parameters generated automatically by the Java fuzzer <a
+ * href="https://github.com/CodeIntelligenceTesting/jazzer">Jazzer</a>.
+ *
+ * <h2>Test parameters</h2>
+ *
+ * <p>Methods annotated with {@link FuzzTest} can take either of the following types of parameters:
+ *
+ * <dl>
+ * <dt>{@code byte[]}</dt>
+ * <dd>Raw byte input mutated by the fuzzer. Use this signature when your fuzz test naturally
+ * handles raw bytes (e.g. when fuzzing a binary format parser). This is the most efficient, but
+ * also the least convenient way to write a fuzz test.</dd>
+ *
+ * <dt>{@link com.code_intelligence.jazzer.api.FuzzedDataProvider}</dt>
+ * <dd>Provides convenience methods that generate instances of commonly used Java types from the raw
+ * fuzzer input. This is generally the best way to write fuzz tests.</dd>
+ *
+ * <dt>any non-zero number of parameters of any type</dt>
+ * <dd>In this case, Jazzer will rely on reflection and class path scanning to instantiate concrete
+ * arguments. While convenient and a good way to get started, fuzz tests using this feature will
+ * generally be less efficient than fuzz tests using any of the other possible signatures. Due to
+ * the reliance on class path scanning, any change to the class path may also render previous
+ * findings unreproducible.</dd>
+ * </dl>
+ *
+ * <h2>Test modes</h2>
+ *
+ * A fuzz test can be run in two modes: fuzzing and regression testing.
+ *
+ * <h3>Fuzzing</h3>
+ * <p>When the environment variable {@code JAZZER_FUZZ} is set to any non-empty value, fuzz tests
+ * run in "fuzzing" mode. In this mode, the method annotated with {@link FuzzTest} are invoked
+ * repeatedly with inputs that Jazzer generates and mutates based on feedback obtained from
+ * instrumentation it applies to the test and every class loaded by it.
+ *
+ * <p>When an assertion in the test fails, an exception is thrown but not caught, or Jazzer's
+ * instrumentation detects a security issue (e.g. SQL injection or insecure deserialization), the
+ * fuzz test is reported as failed and the input is collected in the inputs directory for the test
+ * class (see "Regression testing" for details).
+ *
+ * <p>When no issue has been found after the configured {@link FuzzTest#maxDuration()}, the test
+ * passes.
+ *
+ * <p>Only a single fuzz test per test run will be executed in fuzzing mode. All other fuzz tests
+ * will be skipped.
+ *
+ * <h3>Regression testing</h3>
+ * <p>By default, a fuzz test is executed as a regular JUnit {@link ParameterizedTest} running on a
+ * fixed set of inputs. It can be run together with regular unit tests and used to verify that past
+ * findings remain fixed. In IDEs with JUnit 5 integration, it can also be used to conveniently
+ * debug individual findings.
+ *
+ * <p>Fuzz tests are always executed on the empty input as well as all input files contained in the
+ * resource directory called {@code <TestClassName>Inputs} in the current package. For example,
+ * all fuzz tests contained in the class {@code com.example.MyFuzzTests} would run on all files
+ * under {@code src/test/resources/com/example/MyFuzzTestsInputs}.
+ */
+@Target({ElementType.METHOD})
+@Retention(RetentionPolicy.RUNTIME)
+@AgentConfiguringArgumentsProviderArgumentsSource
+@ArgumentsSource(SeedArgumentsProvider.class)
+@FuzzingArgumentsProviderArgumentsSource
+@ExtendWith(FuzzTestExtensions.class)
+// {0} is expanded to the basename of the seed by the ArgumentProvider.
+@ParameterizedTest(name = "{0}")
+@Tag("jazzer")
+// Fuzz tests can't run in parallel with other fuzz tests since the last finding is kept in a global
+// variable.
+// Fuzz tests also can't run in parallel with other non-fuzz tests since method hooks are enabled
+// conditionally based on a global variable.
+@ResourceLock(value = Resources.GLOBAL, mode = ResourceAccessMode.READ_WRITE)
+public @interface FuzzTest {
+  /**
+   * A duration string such as "1h 2m 30s" indicating for how long the fuzz test should be executed
+   * during fuzzing.
+   *
+   * <p>This option has no effect during regression testing.
+   */
+  String maxDuration() default "5m";
+}
+
+// Internal use only.
+// These wrappers are needed only because the container annotation for @ArgumentsSource,
+// @ArgumentsSources, can't be applied to annotations.
+@Target({ElementType.ANNOTATION_TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+@ArgumentsSource(AgentConfiguringArgumentsProvider.class)
+@interface AgentConfiguringArgumentsProviderArgumentsSource {}
+
+@Target({ElementType.ANNOTATION_TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+@ArgumentsSource(FuzzingArgumentsProvider.class)
+@interface FuzzingArgumentsProviderArgumentsSource {}
diff --git a/src/main/java/com/code_intelligence/jazzer/junit/FuzzTestExecutor.java b/src/main/java/com/code_intelligence/jazzer/junit/FuzzTestExecutor.java
new file mode 100644
index 0000000..49252e8
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/junit/FuzzTestExecutor.java
@@ -0,0 +1,282 @@
+// Copyright 2023 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.junit;
+
+import static com.code_intelligence.jazzer.junit.Utils.durationStringToSeconds;
+import static com.code_intelligence.jazzer.junit.Utils.generatedCorpusPath;
+import static com.code_intelligence.jazzer.junit.Utils.inputsDirectoryResourcePath;
+import static com.code_intelligence.jazzer.junit.Utils.inputsDirectorySourcePath;
+
+import com.code_intelligence.jazzer.agent.AgentInstaller;
+import com.code_intelligence.jazzer.driver.FuzzTargetHolder;
+import com.code_intelligence.jazzer.driver.FuzzTargetRunner;
+import com.code_intelligence.jazzer.driver.Opt;
+import com.code_intelligence.jazzer.driver.junit.ExitCodeException;
+import java.io.File;
+import java.io.IOException;
+import java.lang.reflect.Executable;
+import java.lang.reflect.Method;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
+import org.junit.jupiter.api.extension.ReflectiveInvocationContext;
+import org.junit.jupiter.params.provider.ArgumentsSource;
+import org.junit.platform.commons.support.AnnotationSupport;
+
+class FuzzTestExecutor {
+  private static final AtomicBoolean hasBeenPrepared = new AtomicBoolean();
+  private static final AtomicBoolean agentInstalled = new AtomicBoolean(false);
+
+  private final List<String> libFuzzerArgs;
+  private final Path javaSeedsDir;
+  private final boolean isRunFromCommandLine;
+
+  private FuzzTestExecutor(
+      List<String> libFuzzerArgs, Path javaSeedsDir, boolean isRunFromCommandLine) {
+    this.libFuzzerArgs = libFuzzerArgs;
+    this.javaSeedsDir = javaSeedsDir;
+    this.isRunFromCommandLine = isRunFromCommandLine;
+  }
+
+  public static FuzzTestExecutor prepare(ExtensionContext context, String maxDuration)
+      throws IOException {
+    if (!hasBeenPrepared.compareAndSet(false, true)) {
+      throw new IllegalStateException(
+          "FuzzTestExecutor#prepare can only be called once per test run");
+    }
+
+    Class<?> fuzzTestClass = context.getRequiredTestClass();
+    Method fuzzTestMethod = context.getRequiredTestMethod();
+
+    List<ArgumentsSource> allSources = AnnotationSupport.findRepeatableAnnotations(
+        context.getRequiredTestMethod(), ArgumentsSource.class);
+    // Non-empty as it always contains FuzzingArgumentsProvider.
+    ArgumentsSource lastSource = allSources.get(allSources.size() - 1);
+    // Ensure that our ArgumentsProviders run last so that we can record all the seeds generated by
+    // user-provided ones.
+    if (lastSource.value().getPackage() != FuzzTestExecutor.class.getPackage()) {
+      throw new IllegalArgumentException("@FuzzTest must be the last annotation on a fuzz test,"
+          + " but it came after the (meta-)annotation " + lastSource);
+    }
+
+    Path baseDir =
+        Paths.get(context.getConfigurationParameter("jazzer.internal.basedir").orElse(""))
+            .toAbsolutePath();
+
+    List<String> originalLibFuzzerArgs = getLibFuzzerArgs(context);
+    String argv0 = originalLibFuzzerArgs.isEmpty() ? "fake_argv0" : originalLibFuzzerArgs.remove(0);
+
+    ArrayList<String> libFuzzerArgs = new ArrayList<>();
+    libFuzzerArgs.add(argv0);
+
+    // Add passed in corpus directories (and files) at the beginning of the arguments list.
+    // libFuzzer uses the first directory to store discovered inputs, whereas all others are
+    // only used to provide additional seeds and aren't written into.
+    List<String> corpusDirs = originalLibFuzzerArgs.stream()
+                                  .filter(arg -> !arg.startsWith("-"))
+                                  .collect(Collectors.toList());
+    originalLibFuzzerArgs.removeAll(corpusDirs);
+    libFuzzerArgs.addAll(corpusDirs);
+
+    // Use the specified corpus dir, if given, otherwise store the generated corpus in a per-class
+    // directory under the project root, just like cifuzz:
+    // https://github.com/CodeIntelligenceTesting/cifuzz/blob/bf410dcfbafbae2a73cf6c5fbed031cdfe234f2f/internal/cmd/run/run.go#L381
+    // The path is specified relative to the current working directory, which with JUnit is the
+    // project directory.
+    Path generatedCorpusDir = baseDir.resolve(generatedCorpusPath(fuzzTestClass, fuzzTestMethod));
+    Files.createDirectories(generatedCorpusDir);
+    libFuzzerArgs.add(generatedCorpusDir.toAbsolutePath().toString());
+
+    // We can only emit findings into the source tree version of the inputs directory, not e.g. the
+    // copy under Maven's target directory. If it doesn't exist, collect the inputs in the current
+    // working directory, which is usually the project's source root.
+    Optional<Path> findingsDirectory =
+        inputsDirectorySourcePath(fuzzTestClass, fuzzTestMethod, baseDir);
+    if (!findingsDirectory.isPresent()) {
+      context.publishReportEntry(String.format(
+          "Collecting crashing inputs in the project root directory.\nIf you want to keep them "
+              + "organized by fuzz test and automatically run them as regression tests with "
+              + "JUnit Jupiter, create a test resource directory called '%s' in package '%s' "
+              + "and move the files there.",
+          inputsDirectoryResourcePath(fuzzTestClass, fuzzTestMethod),
+          fuzzTestClass.getPackage().getName()));
+    }
+
+    // We prefer the inputs directory on the classpath, if it exists, as that is more reliable than
+    // heuristically looking into the source tree based on the current working directory.
+    Optional<Path> inputsDirectory;
+    URL inputsDirectoryUrl =
+        fuzzTestClass.getResource(inputsDirectoryResourcePath(fuzzTestClass, fuzzTestMethod));
+    if (inputsDirectoryUrl != null && "file".equals(inputsDirectoryUrl.getProtocol())) {
+      // The inputs directory is a regular directory on disk (i.e., the test is not run from a
+      // JAR).
+      try {
+        // Using inputsDirectoryUrl.getFile() fails on Windows.
+        inputsDirectory = Optional.of(Paths.get(inputsDirectoryUrl.toURI()));
+      } catch (URISyntaxException e) {
+        throw new RuntimeException(e);
+      }
+    } else {
+      if (inputsDirectoryUrl != null && !findingsDirectory.isPresent()) {
+        context.publishReportEntry(
+            "When running Jazzer fuzz tests from a JAR rather than class files, the inputs "
+            + "directory isn't used unless it is located under src/test/resources/...");
+      }
+      inputsDirectory = findingsDirectory;
+    }
+
+    // From the second positional argument on, files and directories are used as seeds but not
+    // modified.
+    inputsDirectory.ifPresent(dir -> libFuzzerArgs.add(dir.toAbsolutePath().toString()));
+    Path javaSeedsDir = Files.createTempDirectory("jazzer-java-seeds");
+    libFuzzerArgs.add(javaSeedsDir.toAbsolutePath().toString());
+    libFuzzerArgs.add(String.format("-artifact_prefix=%s%c",
+        findingsDirectory.orElse(baseDir).toAbsolutePath(), File.separatorChar));
+
+    libFuzzerArgs.add("-max_total_time=" + durationStringToSeconds(maxDuration));
+    // Disable libFuzzer's out of memory detection: It is only useful for native library fuzzing,
+    // which we don't support without our native driver, and leads to false positives where it picks
+    // up IntelliJ's memory usage.
+    libFuzzerArgs.add("-rss_limit_mb=0");
+    if (Utils.permissivelyParseBoolean(
+            context.getConfigurationParameter("jazzer.valueprofile").orElse("false"))) {
+      libFuzzerArgs.add("-use_value_profile=1");
+    }
+
+    // Prefer original libFuzzerArgs set via command line by appending them last.
+    libFuzzerArgs.addAll(originalLibFuzzerArgs);
+
+    return new FuzzTestExecutor(libFuzzerArgs, javaSeedsDir, Utils.runFromCommandLine(context));
+  }
+
+  /**
+   * Returns the list of arguments set on the command line.
+   */
+  private static List<String> getLibFuzzerArgs(ExtensionContext extensionContext) {
+    List<String> args = new ArrayList<>();
+    for (int i = 0;; i++) {
+      Optional<String> arg = extensionContext.getConfigurationParameter("jazzer.internal.arg." + i);
+      if (!arg.isPresent()) {
+        break;
+      }
+      args.add(arg.get());
+    }
+    return args;
+  }
+
+  static void configureAndInstallAgent(ExtensionContext extensionContext, String maxDuration)
+      throws IOException {
+    if (!agentInstalled.compareAndSet(false, true)) {
+      return;
+    }
+    if (Utils.isFuzzing(extensionContext)) {
+      FuzzTestExecutor executor = prepare(extensionContext, maxDuration);
+      extensionContext.getRoot().getStore(Namespace.GLOBAL).put(FuzzTestExecutor.class, executor);
+      AgentConfigurator.forFuzzing(extensionContext);
+    } else {
+      AgentConfigurator.forRegressionTest(extensionContext);
+    }
+    AgentInstaller.install(Opt.hooks);
+  }
+
+  static FuzzTestExecutor fromContext(ExtensionContext extensionContext) {
+    return extensionContext.getRoot()
+        .getStore(Namespace.GLOBAL)
+        .get(FuzzTestExecutor.class, FuzzTestExecutor.class);
+  }
+
+  public void addSeed(byte[] bytes) throws IOException {
+    Path seed = Files.createTempFile(javaSeedsDir, "seed", null);
+    Files.write(seed, bytes);
+  }
+
+  @SuppressWarnings("OptionalGetWithoutIsPresent")
+  public Optional<Throwable> execute(
+      ReflectiveInvocationContext<Method> invocationContext, SeedSerializer seedSerializer) {
+    if (seedSerializer instanceof AutofuzzSeedSerializer) {
+      FuzzTargetHolder.fuzzTarget = FuzzTargetHolder.autofuzzFuzzTarget(() -> {
+        // Provide an empty throws declaration to prevent autofuzz from
+        // ignoring the defined test exceptions. All exceptions in tests
+        // should cause them to fail.
+        Map<Executable, Class<?>[]> throwsDeclarations = new HashMap<>(1);
+        throwsDeclarations.put(invocationContext.getExecutable(), new Class[0]);
+
+        com.code_intelligence.jazzer.autofuzz.FuzzTarget.setTarget(
+            new Executable[] {invocationContext.getExecutable()},
+            invocationContext.getTarget().get(), invocationContext.getExecutable().toString(),
+            Collections.emptySet(), throwsDeclarations);
+        return null;
+      });
+    } else {
+      FuzzTargetHolder.fuzzTarget =
+          new FuzzTargetHolder.FuzzTarget(invocationContext.getExecutable(),
+              () -> invocationContext.getTarget().get(), Optional.empty());
+    }
+
+    // Only register a finding handler in case the fuzz test is executed by JUnit.
+    // It short-circuits the handling in FuzzTargetRunner and prevents settings
+    // like --keep_going.
+    AtomicReference<Throwable> atomicFinding = new AtomicReference<>();
+    if (!isRunFromCommandLine) {
+      FuzzTargetRunner.registerFindingHandler(t -> {
+        atomicFinding.set(t);
+        return false;
+      });
+    }
+
+    int exitCode = FuzzTargetRunner.startLibFuzzer(libFuzzerArgs);
+    deleteJavaSeedsDir();
+    Throwable finding = atomicFinding.get();
+    if (finding != null) {
+      return Optional.of(finding);
+    } else if (exitCode != 0) {
+      return Optional.of(
+          new ExitCodeException("Jazzer exited with exit code " + exitCode, exitCode));
+    } else {
+      return Optional.empty();
+    }
+  }
+
+  private void deleteJavaSeedsDir() {
+    // The directory only consists of files, which we need to delete before deleting the directory
+    // itself.
+    try (Stream<Path> entries = Files.list(javaSeedsDir)) {
+      entries.forEach(FuzzTestExecutor::deleteIgnoringErrors);
+    } catch (IOException ignored) {
+    }
+    deleteIgnoringErrors(javaSeedsDir);
+  }
+
+  private static void deleteIgnoringErrors(Path path) {
+    try {
+      Files.deleteIfExists(path);
+    } catch (IOException ignored) {
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/junit/FuzzTestExtensions.java b/src/main/java/com/code_intelligence/jazzer/junit/FuzzTestExtensions.java
new file mode 100644
index 0000000..0e8ceec
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/junit/FuzzTestExtensions.java
@@ -0,0 +1,170 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.junit;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.extension.ConditionEvaluationResult;
+import org.junit.jupiter.api.extension.ExecutionCondition;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
+import org.junit.jupiter.api.extension.InvocationInterceptor;
+import org.junit.jupiter.api.extension.ReflectiveInvocationContext;
+import org.junit.platform.commons.support.AnnotationSupport;
+
+class FuzzTestExtensions implements ExecutionCondition, InvocationInterceptor {
+  private static final String JAZZER_INTERNAL =
+      "com.code_intelligence.jazzer.runtime.JazzerInternal";
+  private static final AtomicReference<Method> fuzzTestMethod = new AtomicReference<>();
+  private static Field lastFindingField;
+  private static Field hooksEnabledField;
+
+  @Override
+  public void interceptTestTemplateMethod(Invocation<Void> invocation,
+      ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext)
+      throws Throwable {
+    FuzzTest fuzzTest =
+        AnnotationSupport.findAnnotation(invocationContext.getExecutable(), FuzzTest.class).get();
+    FuzzTestExecutor.configureAndInstallAgent(extensionContext, fuzzTest.maxDuration());
+    // Skip the invocation of the test method with the special arguments provided by
+    // FuzzTestArgumentsProvider and start fuzzing instead.
+    if (Utils.isMarkedInvocation(invocationContext)) {
+      startFuzzing(invocation, invocationContext, extensionContext);
+    } else {
+      // Blocked by https://github.com/junit-team/junit5/issues/3282:
+      // TODO: The seeds from the input directory are duplicated here as there is no way to
+      //  recognize them.
+      // TODO: Error out if there is a non-Jazzer ArgumentsProvider and the SeedSerializer does not
+      //  support write.
+      if (Utils.isFuzzing(extensionContext)) {
+        // JUnit verifies that the arguments for this invocation are valid.
+        recordSeedForFuzzing(invocationContext.getArguments(), extensionContext);
+      }
+      runWithHooks(invocation);
+    }
+  }
+
+  /**
+   * Mimics the logic of Jazzer's FuzzTargetRunner, which reports findings in the following way:
+   * <ol>
+   *   <li>If a hook used Jazzer#reportFindingFromHook to explicitly report a finding, the last such
+   * finding, as stored in JazzerInternal#lastFinding, is reported. <li>If the fuzz target method
+   * threw a Throwable, that is reported. <li>3. Otherwise, nothing is reported.
+   * </ol>
+   */
+  private static void runWithHooks(Invocation<Void> invocation) throws Throwable {
+    Throwable thrown = null;
+    getLastFindingField().set(null, null);
+    // When running in regression test mode, the agent emits additional bytecode logic in front of
+    // method hook invocations that enables them only while a global variable managed by
+    // withHooksEnabled is true.
+    //
+    // Alternatives considered:
+    // * Using a dedicated class loader for @FuzzTests: First-class support for this isn't
+    //   available in JUnit 5 (https://github.com/junit-team/junit5/issues/201), but
+    //   third-party extensions have done it:
+    //   https://github.com/spring-projects/spring-boot/blob/main/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathExtension.java
+    //   However, as this involves launching a new test run as part of running a test, this
+    //   introduces a number of inconsistencies if applied on the test method rather than test
+    //   class level. For example, @BeforeAll methods will have to be run twice in different class
+    //   loaders, which may not be safe if they are using global resources not separated by class
+    //   loaders (e.g. files).
+    try (AutoCloseable ignored = withHooksEnabled()) {
+      invocation.proceed();
+    } catch (Throwable t) {
+      thrown = t;
+    }
+    Throwable stored = (Throwable) getLastFindingField().get(null);
+    if (stored != null) {
+      throw stored;
+    } else if (thrown != null) {
+      throw thrown;
+    }
+  }
+
+  private static void startFuzzing(Invocation<Void> invocation,
+      ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext)
+      throws Throwable {
+    invocation.skip();
+    Optional<Throwable> throwable =
+        FuzzTestExecutor.fromContext(extensionContext)
+            .execute(invocationContext, getOrCreateSeedSerializer(extensionContext));
+    if (throwable.isPresent()) {
+      throw throwable.get();
+    }
+  }
+
+  private void recordSeedForFuzzing(List<Object> arguments, ExtensionContext extensionContext)
+      throws IOException {
+    SeedSerializer seedSerializer = getOrCreateSeedSerializer(extensionContext);
+    try {
+      FuzzTestExecutor.fromContext(extensionContext)
+          .addSeed(seedSerializer.write(arguments.toArray()));
+    } catch (UnsupportedOperationException ignored) {
+    }
+  }
+
+  @Override
+  public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext extensionContext) {
+    if (!Utils.isFuzzing(extensionContext)) {
+      return ConditionEvaluationResult.enabled(
+          "Regression tests are run instead of fuzzing since JAZZER_FUZZ has not been set to a non-empty value");
+    }
+    // Only fuzz the first @FuzzTest that makes it here.
+    if (FuzzTestExtensions.fuzzTestMethod.compareAndSet(
+            null, extensionContext.getRequiredTestMethod())
+        || extensionContext.getRequiredTestMethod().equals(
+            FuzzTestExtensions.fuzzTestMethod.get())) {
+      return ConditionEvaluationResult.enabled(
+          "Fuzzing " + extensionContext.getRequiredTestMethod());
+    }
+    return ConditionEvaluationResult.disabled(
+        "Only one fuzz test can be run at a time, but multiple tests have been annotated with @FuzzTest");
+  }
+
+  private static SeedSerializer getOrCreateSeedSerializer(ExtensionContext extensionContext) {
+    Method method = extensionContext.getRequiredTestMethod();
+    return extensionContext.getStore(Namespace.create(FuzzTestExtensions.class, method))
+        .getOrComputeIfAbsent(
+            SeedSerializer.class, unused -> SeedSerializer.of(method), SeedSerializer.class);
+  }
+
+  private static Field getLastFindingField() throws ClassNotFoundException, NoSuchFieldException {
+    if (lastFindingField == null) {
+      Class<?> jazzerInternal = Class.forName(JAZZER_INTERNAL);
+      lastFindingField = jazzerInternal.getField("lastFinding");
+    }
+    return lastFindingField;
+  }
+
+  private static Field getHooksEnabledField() throws ClassNotFoundException, NoSuchFieldException {
+    if (hooksEnabledField == null) {
+      Class<?> jazzerInternal = Class.forName(JAZZER_INTERNAL);
+      hooksEnabledField = jazzerInternal.getField("hooksEnabled");
+    }
+    return hooksEnabledField;
+  }
+
+  private static AutoCloseable withHooksEnabled()
+      throws NoSuchFieldException, ClassNotFoundException, IllegalAccessException {
+    Field hooksEnabledField = getHooksEnabledField();
+    hooksEnabledField.setBoolean(null, true);
+    return () -> hooksEnabledField.setBoolean(null, false);
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/junit/FuzzingArgumentsProvider.java b/src/main/java/com/code_intelligence/jazzer/junit/FuzzingArgumentsProvider.java
new file mode 100644
index 0000000..61a8d3f
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/junit/FuzzingArgumentsProvider.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.junit;
+
+import static com.code_intelligence.jazzer.junit.Utils.isFuzzing;
+
+import java.util.stream.Stream;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.ArgumentsProvider;
+
+class FuzzingArgumentsProvider implements ArgumentsProvider {
+  @Override
+  public Stream<? extends Arguments> provideArguments(ExtensionContext extensionContext) {
+    if (!isFuzzing(extensionContext)) {
+      return Stream.empty();
+    }
+
+    // When fuzzing, supply a special set of arguments that our InvocationInterceptor uses as a
+    // sign to start fuzzing.
+    // FIXME: This is a hack that is needed only because there does not seem to be a way to
+    //  communicate out of band that a certain invocation was triggered by a particular argument
+    //  provider. We should get rid of this hack as soon as
+    //  https://github.com/junit-team/junit5/issues/3282 has been addressed.
+    return Stream.of(
+        Utils.getMarkedArguments(extensionContext.getRequiredTestMethod(), "Fuzzing..."));
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/junit/SeedArgumentsProvider.java b/src/main/java/com/code_intelligence/jazzer/junit/SeedArgumentsProvider.java
new file mode 100644
index 0000000..687a1f7
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/junit/SeedArgumentsProvider.java
@@ -0,0 +1,225 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.junit;
+
+import static com.code_intelligence.jazzer.junit.Utils.isFuzzing;
+import static com.code_intelligence.jazzer.junit.Utils.runFromCommandLine;
+import static org.junit.jupiter.api.Named.named;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+import java.io.IOException;
+import java.lang.reflect.Method;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
+import java.nio.file.FileVisitOption;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.AbstractMap.SimpleImmutableEntry;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.BiPredicate;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.ArgumentsProvider;
+
+class SeedArgumentsProvider implements ArgumentsProvider {
+  @Override
+  public Stream<? extends Arguments> provideArguments(ExtensionContext extensionContext)
+      throws IOException {
+    if (runFromCommandLine(extensionContext)) {
+      // libFuzzer always runs on the file-based seeds first anyway and the additional visual
+      // indication provided by test invocations for seeds isn't effective on the command line, so
+      // we skip these invocations.
+      return Stream.empty();
+    }
+
+    Class<?> testClass = extensionContext.getRequiredTestClass();
+    Method testMethod = extensionContext.getRequiredTestMethod();
+
+    Stream<Map.Entry<String, byte[]>> rawSeeds =
+        Stream.of(new SimpleImmutableEntry<>("<empty input>", new byte[0]));
+    rawSeeds = Stream.concat(rawSeeds, walkInputs(testClass, testMethod));
+
+    if (Utils.isCoverageAgentPresent()
+        && Files.isDirectory(Utils.generatedCorpusPath(testClass, testMethod))) {
+      rawSeeds = Stream.concat(rawSeeds,
+          walkInputsInPath(Utils.generatedCorpusPath(testClass, testMethod), Integer.MAX_VALUE));
+    }
+
+    SeedSerializer serializer = SeedSerializer.of(testMethod);
+    return rawSeeds
+        .map(entry -> {
+          Object[] args = serializer.read(entry.getValue());
+          args[0] = named(entry.getKey(), args[0]);
+          return arguments(args);
+        })
+        .onClose(() -> {
+          if (!isFuzzing(extensionContext)) {
+            extensionContext.publishReportEntry(
+                "No fuzzing has been performed, the fuzz test has only been executed on the fixed "
+                + "set of inputs in the seed corpus.\n"
+                + "To start fuzzing, run a test with the environment variable JAZZER_FUZZ set to a "
+                + "non-empty value.");
+          }
+          if (!serializer.allReadsValid()) {
+            extensionContext.publishReportEntry(
+                "Some files in the seed corpus do not match the fuzz target signature.\n"
+                + "This indicates that they were generated with a different signature and may cause "
+                + "issues reproducing previous findings.");
+          }
+        });
+  }
+
+  /**
+   * Used in regression mode to get test cases for the associated {@code testMethod}
+   * This will return a stream of files consisting of:
+   * <ul>
+   * <li>{@code resources/<classpath>/<testClass name>Inputs/*}</li>
+   * <li>{@code resources/<classpath>/<testClass name>Inputs/<testMethod name>/**}</li>
+   * </ul>
+   * Or the equivalent behavior on resources inside a jar file.
+   * <p>
+   * Note that the first {@code <testClass name>Inputs} path will not recursively search all
+   * directories but only gives files in that directory whereas the {@code <testMethod name>}
+   * directory is searched recursively. This allows for multiple tests to share inputs without
+   * needing to explicitly copy them into each test's directory.
+   *
+   * @param testClass the class of the test being run
+   * @param testMethod the test function being run
+   * @return a stream of findings files to use as inputs for the test function
+   */
+  private Stream<Map.Entry<String, byte[]>> walkInputs(Class<?> testClass, Method testMethod)
+      throws IOException {
+    URL classInputsDirUrl = testClass.getResource(Utils.inputsDirectoryResourcePath(testClass));
+
+    if (classInputsDirUrl == null) {
+      return Stream.empty();
+    }
+    URI classInputsDirUri;
+    try {
+      classInputsDirUri = classInputsDirUrl.toURI();
+    } catch (URISyntaxException e) {
+      throw new IOException("Failed to open inputs resource directory: " + classInputsDirUrl, e);
+    }
+    if (classInputsDirUri.getScheme().equals("file")) {
+      // The test is executed from class files, which usually happens when run from inside an IDE.
+      Path classInputsPath = Paths.get(classInputsDirUri);
+
+      return Stream.concat(
+          walkClassInputs(classInputsPath), walkTestInputs(classInputsPath, testMethod));
+
+    } else if (classInputsDirUri.getScheme().equals("jar")) {
+      FileSystem jar = FileSystems.newFileSystem(classInputsDirUri, new HashMap<>());
+      // inputsDirUrl looks like this:
+      // file:/tmp/testdata/ExampleFuzzTest_deploy.jar!/com/code_intelligence/jazzer/junit/testdata/ExampleFuzzTestInputs
+      String pathInJar =
+          classInputsDirUrl.getFile().substring(classInputsDirUrl.getFile().indexOf('!') + 1);
+
+      Path classPathInJar = jar.getPath(pathInJar);
+
+      return Stream
+          .concat(walkClassInputs(classPathInJar), walkTestInputs(classPathInJar, testMethod))
+          .onClose(() -> {
+            try {
+              jar.close();
+            } catch (IOException e) {
+              throw new RuntimeException(e);
+            }
+          });
+    } else {
+      throw new IOException(
+          "Unsupported protocol for inputs resource directory: " + classInputsDirUrl);
+    }
+  }
+
+  /**
+   * Walks over the inputs for the method being tested, recurses into subdirectories
+   * @param classInputsPath the path of the class being tested, used as the base path where the test
+   *     method's directory
+   *                        should be
+   * @param testMethod the method being tested
+   * @return a stream of all files under {@code <classInputsPath>/<testMethod name>}
+   * @throws IOException can be thrown by the underlying call to {@link Files#find}
+   */
+  private static Stream<Map.Entry<String, byte[]>> walkTestInputs(
+      Path classInputsPath, Method testMethod) throws IOException {
+    Path testInputsPath = classInputsPath.resolve(testMethod.getName());
+    try {
+      return walkInputsInPath(testInputsPath, Integer.MAX_VALUE);
+    } catch (NoSuchFileException e) {
+      return Stream.empty();
+    }
+  }
+
+  /**
+   * Walks over the inputs for the class being tested. Does not recurse into subdirectories
+   * @param path the path to search to files
+   * @return a stream of all files (without directories) within {@code path}. If {@code path} is not
+   *     found, an empty
+   *    stream is returned.
+   * @throws IOException can be thrown by the underlying call to {@link Files#find}
+   */
+  private static Stream<Map.Entry<String, byte[]>> walkClassInputs(Path path) throws IOException {
+    try {
+      // using a depth of 1 will get all files within the given path but does not recurse into
+      // subdirectories
+      return walkInputsInPath(path, 1);
+    } catch (NoSuchFileException e) {
+      return Stream.empty();
+    }
+  }
+
+  /**
+   * Gets a sorted stream of all files (without directories) within under the given {@code path}
+   * @param path the path to walk
+   * @param depth the maximum depth of subdirectories to search from within {@code path}. 1
+   *     indicates it should return
+   *              only the files directly in {@code path} and not search any of its subdirectories
+   * @return a stream of file name -> file contents as a raw byte array
+   * @throws IOException can be thrown by the call to {@link Files#find(Path, int, BiPredicate,
+   *     FileVisitOption...)}
+   */
+  private static Stream<Map.Entry<String, byte[]>> walkInputsInPath(Path path, int depth)
+      throws IOException {
+    // @ParameterTest automatically closes Streams and AutoCloseable instances.
+    // noinspection resource
+    return Files
+        .find(path, depth,
+            (fileOrDir, basicFileAttributes)
+                -> !basicFileAttributes.isDirectory(),
+            FileVisitOption.FOLLOW_LINKS)
+        // JUnit identifies individual runs of a `@ParameterizedTest` via their invocation number.
+        // In order to get reproducible behavior e.g. when trying to debug a particular input, all
+        // inputs thus have to be provided in deterministic order.
+        .sorted()
+        .map(file
+            -> new SimpleImmutableEntry<>(
+                file.getFileName().toString(), readAllBytesUnchecked(file)));
+  }
+
+  private static byte[] readAllBytesUnchecked(Path path) {
+    try {
+      return Files.readAllBytes(path);
+    } catch (IOException e) {
+      throw new IllegalStateException(e);
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/junit/SeedSerializer.java b/src/main/java/com/code_intelligence/jazzer/junit/SeedSerializer.java
new file mode 100644
index 0000000..0cebef2
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/junit/SeedSerializer.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.junit;
+
+import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import com.code_intelligence.jazzer.autofuzz.Meta;
+import com.code_intelligence.jazzer.driver.FuzzedDataProviderImpl;
+import com.code_intelligence.jazzer.driver.Opt;
+import com.code_intelligence.jazzer.mutation.ArgumentsMutator;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.lang.reflect.Method;
+import java.util.Optional;
+
+interface SeedSerializer {
+  Object[] read(byte[] bytes);
+  default boolean allReadsValid() {
+    return true;
+  }
+
+  // Implementations can assume that the argument array contains valid arguments for the method that
+  // this instance has been constructed for.
+  byte[] write(Object[] args) throws UnsupportedOperationException;
+
+  /**
+   * Creates specialized {@link SeedSerializer} instances for the following method parameters:
+   * <ul>
+   *   <li>{@code byte[]}
+   *   <li>{@code FuzzDataProvider}
+   *   <li>Any other types will attempt to be created using either Autofuzz or the experimental
+   * mutator framework if {@link Opt}'s {@code experimentalMutator} is set.
+   * </ul>
+   */
+  static SeedSerializer of(Method method) {
+    if (method.getParameterCount() == 0) {
+      throw new IllegalArgumentException(
+          "Methods annotated with @FuzzTest must take at least one parameter");
+    }
+    if (method.getParameterCount() == 1 && method.getParameterTypes()[0] == byte[].class) {
+      return new ByteArraySeedSerializer();
+    } else if (method.getParameterCount() == 1
+        && method.getParameterTypes()[0] == FuzzedDataProvider.class) {
+      return new FuzzedDataProviderSeedSerializer();
+    } else {
+      Optional<ArgumentsMutator> argumentsMutator =
+          Opt.experimentalMutator ? ArgumentsMutator.forMethod(method) : Optional.empty();
+      return argumentsMutator.<SeedSerializer>map(ArgumentsMutatorSeedSerializer::new)
+          .orElseGet(() -> new AutofuzzSeedSerializer(method));
+    }
+  }
+}
+
+final class ByteArraySeedSerializer implements SeedSerializer {
+  @Override
+  public Object[] read(byte[] bytes) {
+    return new Object[] {bytes};
+  }
+
+  @Override
+  public byte[] write(Object[] args) {
+    return (byte[]) args[0];
+  }
+}
+
+final class FuzzedDataProviderSeedSerializer implements SeedSerializer {
+  @Override
+  public Object[] read(byte[] bytes) {
+    return new Object[] {FuzzedDataProviderImpl.withJavaData(bytes)};
+  }
+
+  @Override
+  public byte[] write(Object[] args) throws UnsupportedOperationException {
+    // While we could get the underlying bytes, it's not possible to provide Java seeds for fuzz
+    // tests with a FuzzedDataProvider parameter.
+    throw new UnsupportedOperationException();
+  }
+}
+
+final class ArgumentsMutatorSeedSerializer implements SeedSerializer {
+  private final ArgumentsMutator mutator;
+  private boolean allReadsValid;
+
+  public ArgumentsMutatorSeedSerializer(ArgumentsMutator mutator) {
+    this.mutator = mutator;
+  }
+
+  @Override
+  public Object[] read(byte[] bytes) {
+    allReadsValid &= mutator.read(new ByteArrayInputStream(bytes));
+    return mutator.getArguments();
+  }
+
+  @Override
+  public boolean allReadsValid() {
+    return allReadsValid;
+  }
+
+  @Override
+  public byte[] write(Object[] args) {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    mutator.writeAny(out, args);
+    return out.toByteArray();
+  }
+}
+
+final class AutofuzzSeedSerializer implements SeedSerializer {
+  private final Meta meta;
+  private final Method method;
+
+  public AutofuzzSeedSerializer(Method method) {
+    this.meta = new Meta(method.getDeclaringClass());
+    this.method = method;
+  }
+
+  @Override
+  public Object[] read(byte[] bytes) {
+    try (FuzzedDataProviderImpl data = FuzzedDataProviderImpl.withJavaData(bytes)) {
+      // The Autofuzz FuzzTarget uses data to construct an instance of the test class before
+      // it constructs the fuzz test arguments. We don't need the instance here, but still
+      // generate it as that mutates the FuzzedDataProvider state.
+      meta.consumeNonStatic(data, method.getDeclaringClass());
+      return meta.consumeArguments(data, method, null);
+    }
+  }
+
+  @Override
+  public byte[] write(Object[] args) throws UnsupportedOperationException {
+    throw new UnsupportedOperationException();
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/junit/Utils.java b/src/main/java/com/code_intelligence/jazzer/junit/Utils.java
new file mode 100644
index 0000000..4ab81cd
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/junit/Utils.java
@@ -0,0 +1,305 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.junit;
+
+import static java.util.Arrays.stream;
+import static java.util.Collections.newSetFromMap;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toList;
+import static org.junit.jupiter.api.Named.named;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+import com.code_intelligence.jazzer.utils.UnsafeProvider;
+import com.code_intelligence.jazzer.utils.UnsafeUtils;
+import java.io.File;
+import java.io.IOException;
+import java.lang.invoke.MethodType;
+import java.lang.management.ManagementFactory;
+import java.lang.reflect.Array;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.Proxy;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.ReflectiveInvocationContext;
+import org.junit.jupiter.params.provider.Arguments;
+
+class Utils {
+  /**
+   * Returns the resource path of the inputs directory for a given test class and method. The path
+   * will have the form
+   * {@code <class name>Inputs/<method name>}
+   */
+  static String inputsDirectoryResourcePath(Class<?> testClass, Method testMethod) {
+    return testClass.getSimpleName() + "Inputs"
+        + "/" + testMethod.getName();
+  }
+
+  static String inputsDirectoryResourcePath(Class<?> testClass) {
+    return testClass.getSimpleName() + "Inputs";
+  }
+
+  /**
+   * Returns the file system path of the inputs corpus directory in the source tree, if it exists.
+   * The directory is created if it does not exist, but the test resource directory itself exists.
+   */
+  static Optional<Path> inputsDirectorySourcePath(
+      Class<?> testClass, Method testMethod, Path baseDir) {
+    String inputsResourcePath = Utils.inputsDirectoryResourcePath(testClass, testMethod);
+    // Make the inputs resource path absolute.
+    if (!inputsResourcePath.startsWith("/")) {
+      String inputsPackage = testClass.getPackage().getName().replace('.', '/');
+      inputsResourcePath = "/" + inputsPackage + "/" + inputsResourcePath;
+    }
+
+    // Following the Maven directory layout, we look up the inputs directory under
+    // src/test/resources. This should be correct also for multi-module projects as JUnit is usually
+    // launched in the current module's root directory.
+    Path testResourcesDirectory = baseDir.resolve("src").resolve("test").resolve("resources");
+    Path sourceInputsDirectory = testResourcesDirectory;
+    for (String segment : inputsResourcePath.split("/")) {
+      sourceInputsDirectory = sourceInputsDirectory.resolve(segment);
+    }
+    if (Files.isDirectory(sourceInputsDirectory)) {
+      return Optional.of(sourceInputsDirectory);
+    }
+    // If we can at least find the test resource directory, create the inputs directory.
+    if (!Files.isDirectory(testResourcesDirectory)) {
+      return Optional.empty();
+    }
+    try {
+      return Optional.of(Files.createDirectories(sourceInputsDirectory));
+    } catch (Exception e) {
+      return Optional.empty();
+    }
+  }
+
+  static Path generatedCorpusPath(Class<?> testClass, Method testMethod) {
+    return Paths.get(".cifuzz-corpus", testClass.getName(), testMethod.getName());
+  }
+
+  /**
+   * Returns a heuristic default value for jazzer.instrument based on the test class.
+   */
+  static String getLegacyInstrumentationFilter(Class<?> testClass) {
+    // This is an extremely rough "implementation" of the public suffix list algorithm
+    // (https://publicsuffix.org/): It tries to guess the shortest prefix of the package name that
+    // isn't public. It doesn't use the actual list, but instead assumes that every root segment as
+    // well as "com.github" are public. Examples:
+    // - com.example.Test --> com.example.**
+    // - com.example.foobar.Test --> com.example.**
+    // - com.github.someones.repo.Test --> com.github.someones.**
+    String packageName = testClass.getPackage().getName();
+    String[] packageSegments = packageName.split("\\.");
+    int numSegments = 2;
+    if (packageSegments.length > 2 && packageSegments[0].equals("com")
+        && packageSegments[1].equals("github")) {
+      numSegments = 3;
+    }
+    return Stream.concat(Arrays.stream(packageSegments).limit(numSegments), Stream.of("**"))
+        .collect(joining("."));
+  }
+
+  private static final Pattern CLASSPATH_SPLITTER =
+      Pattern.compile(Pattern.quote(File.pathSeparator));
+
+  /**
+   * Returns a heuristic default value for jazzer.instrument based on the files on the provided
+   * classpath.
+   */
+  static Optional<String> getClassPathBasedInstrumentationFilter(String classPath) {
+    List<Path> includes =
+        CLASSPATH_SPLITTER.splitAsStream(classPath)
+            .map(Paths::get)
+            // We consider classpath entries that are directories rather than jar files to contain
+            // the classes of the current project rather than external dependencies. This is just a
+            // heuristic and breaks with build systems that package all classes in jar files, e.g.
+            // with Bazel.
+            .filter(Files::isDirectory)
+            .flatMap(root -> {
+              HashSet<Path> pkgs = new HashSet<>();
+              try {
+                Files.walkFileTree(root, new SimpleFileVisitor<Path>() {
+                  @Override
+                  public FileVisitResult preVisitDirectory(
+                      Path dir, BasicFileAttributes basicFileAttributes) throws IOException {
+                    try (Stream<Path> entries = Files.list(dir)) {
+                      // If a directory contains a .class file, we add an include filter matching it
+                      // and all subdirectories.
+                      // Special case: If there is a class defined at the root, only the unnamed
+                      // package is included, so continue with the traversal of subdirectories
+                      // to discover additional includes.
+                      if (entries.filter(path -> path.toString().endsWith(".class"))
+                              .anyMatch(Files::isRegularFile)) {
+                        Path pkgPath = root.relativize(dir);
+                        pkgs.add(pkgPath);
+                        if (pkgPath.toString().isEmpty()) {
+                          return FileVisitResult.CONTINUE;
+                        } else {
+                          return FileVisitResult.SKIP_SUBTREE;
+                        }
+                      }
+                    }
+                    return FileVisitResult.CONTINUE;
+                  }
+                });
+              } catch (IOException e) {
+                // This is only a best-effort heuristic anyway, ignore this directory.
+                return Stream.of();
+              }
+              return pkgs.stream();
+            })
+            .distinct()
+            .collect(toList());
+    if (includes.isEmpty()) {
+      return Optional.empty();
+    }
+    return Optional.of(
+        includes.stream()
+            .map(Path::toString)
+            // For classes without a package, only include the unnamed package.
+            .map(path -> path.isEmpty() ? "*" : path.replace(File.separator, ".") + ".**")
+            .sorted()
+            // jazzer.instrument uses ',' as the separator.
+            .collect(joining(",")));
+  }
+
+  private static final Pattern COVERAGE_AGENT_ARG =
+      Pattern.compile("-javaagent:.*(?:intellij-coverage-agent|jacoco).*");
+  static boolean isCoverageAgentPresent() {
+    return ManagementFactory.getRuntimeMXBean().getInputArguments().stream().anyMatch(
+        s -> COVERAGE_AGENT_ARG.matcher(s).matches());
+  }
+
+  private static final boolean IS_FUZZING_ENV =
+      System.getenv("JAZZER_FUZZ") != null && !System.getenv("JAZZER_FUZZ").isEmpty();
+  static boolean isFuzzing(ExtensionContext extensionContext) {
+    return IS_FUZZING_ENV || runFromCommandLine(extensionContext);
+  }
+
+  static boolean runFromCommandLine(ExtensionContext extensionContext) {
+    return extensionContext.getConfigurationParameter("jazzer.internal.commandLine")
+        .map(Boolean::parseBoolean)
+        .orElse(false);
+  }
+
+  /**
+   * Returns true if and only if the value is equal to "true", "1", or "yes" case-insensitively.
+   */
+  static boolean permissivelyParseBoolean(String value) {
+    return value.equalsIgnoreCase("true") || value.equals("1") || value.equalsIgnoreCase("yes");
+  }
+
+  /**
+   * Convert the string to ISO 8601 (https://en.wikipedia.org/wiki/ISO_8601#Durations). We do not
+   * allow for duration units longer than hours, so we can always prepend PT.
+   */
+  static long durationStringToSeconds(String duration) {
+    String isoDuration =
+        "PT" + duration.replace("sec", "s").replace("min", "m").replace("hr", "h").replace(" ", "");
+    return Duration.parse(isoDuration).getSeconds();
+  }
+
+  /**
+   * Creates {@link Arguments} for a single invocation of a parameterized test that can be
+   * identified as having been created in this way by {@link #isMarkedInvocation}.
+   *
+   * @param displayName the display name to assign to every argument
+   */
+  static Arguments getMarkedArguments(Method method, String displayName) {
+    return arguments(stream(method.getParameterTypes())
+                         .map(Utils::getMarkedInstance)
+                         // Wrap in named as toString may crash on marked instances.
+                         .map(arg -> named(displayName, arg))
+                         .toArray(Object[] ::new));
+  }
+
+  /**
+   * @return {@code true} if and only if the arguments for this test method invocation were created
+   * with {@link #getMarkedArguments}
+   */
+  static boolean isMarkedInvocation(ReflectiveInvocationContext<Method> invocationContext) {
+    if (invocationContext.getArguments().stream().anyMatch(Utils::isMarkedInstance)) {
+      if (invocationContext.getArguments().stream().allMatch(Utils::isMarkedInstance)) {
+        return true;
+      }
+      throw new IllegalStateException(
+          "Some, but not all arguments were marked in invocation of " + invocationContext);
+    } else {
+      return false;
+    }
+  }
+
+  private static final ClassValue<Object> uniqueInstanceCache = new ClassValue<Object>() {
+    @Override
+    protected Object computeValue(Class<?> clazz) {
+      return makeMarkedInstance(clazz);
+    }
+  };
+  private static final Set<Object> uniqueInstances = newSetFromMap(new IdentityHashMap<>());
+
+  // Visible for testing.
+  static <T> T getMarkedInstance(Class<T> clazz) {
+    // makeMarkedInstance creates new classes, which is expensive and can cause the JVM to run out
+    // of metaspace. We thus cache the marked instances per class.
+    Object instance = uniqueInstanceCache.get(clazz);
+    uniqueInstances.add(instance);
+    return (T) instance;
+  }
+
+  // Visible for testing.
+  static boolean isMarkedInstance(Object instance) {
+    return uniqueInstances.contains(instance);
+  }
+
+  private static Object makeMarkedInstance(Class<?> clazz) {
+    if (clazz == Class.class) {
+      return new Object() {}.getClass();
+    }
+    if (clazz.isArray()) {
+      return Array.newInstance(clazz.getComponentType(), 0);
+    }
+    if (clazz.isInterface()) {
+      return Proxy.newProxyInstance(
+          Utils.class.getClassLoader(), new Class[] {clazz}, (o, method, objects) -> null);
+    }
+
+    if (clazz.isPrimitive()) {
+      clazz = MethodType.methodType(clazz).wrap().returnType();
+    } else if (Modifier.isAbstract(clazz.getModifiers())) {
+      clazz = UnsafeUtils.defineAnonymousConcreteSubclass(clazz);
+    }
+
+    try {
+      return clazz.cast(UnsafeProvider.getUnsafe().allocateInstance(clazz));
+    } catch (InstantiationException e) {
+      throw new IllegalStateException(e);
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/ArgumentsMutator.java b/src/main/java/com/code_intelligence/jazzer/mutation/ArgumentsMutator.java
new file mode 100644
index 0000000..fabda05
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/ArgumentsMutator.java
@@ -0,0 +1,232 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation;
+
+import static com.code_intelligence.jazzer.mutation.mutator.Mutators.validateAnnotationUsage;
+import static com.code_intelligence.jazzer.mutation.support.InputStreamSupport.extendWithReadExactly;
+import static com.code_intelligence.jazzer.mutation.support.Preconditions.require;
+import static com.code_intelligence.jazzer.mutation.support.StreamSupport.toArrayOrEmpty;
+import static java.lang.String.format;
+import static java.util.Arrays.stream;
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.joining;
+
+import com.code_intelligence.jazzer.mutation.api.MutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.PseudoRandom;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators;
+import com.code_intelligence.jazzer.mutation.combinator.ProductMutator;
+import com.code_intelligence.jazzer.mutation.engine.SeededPseudoRandom;
+import com.code_intelligence.jazzer.mutation.mutator.Mutators;
+import com.code_intelligence.jazzer.mutation.support.InputStreamSupport.ReadExactlyInputStream;
+import com.code_intelligence.jazzer.mutation.support.Preconditions;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UncheckedIOException;
+import java.lang.reflect.AnnotatedType;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.Optional;
+
+public final class ArgumentsMutator {
+  private final Object instance;
+  private final Method method;
+  private final ProductMutator productMutator;
+  private Object[] arguments;
+
+  /**
+   * True if the arguments array has already been passed to a user-provided function or exposed
+   * via {@link #getArguments()} without going through {@link ProductMutator#detach(Object[])}.
+   * In this case the arguments may have been modified externally, which interferes with mutation,
+   * or could have been stored in static state that would be affected by future mutations.
+   * Arguments should either be detached or not be reused after being exposed, which is enforced by
+   * this variable.
+   */
+  private boolean argumentsExposed;
+
+  private ArgumentsMutator(Object instance, Method method, ProductMutator productMutator) {
+    this.instance = instance;
+    this.method = method;
+    this.productMutator = productMutator;
+  }
+
+  private static String prettyPrintMethod(Method method) {
+    return format("%s.%s(%s)", method.getDeclaringClass().getName(), method.getName(),
+        stream(method.getAnnotatedParameterTypes()).map(Object::toString).collect(joining(", ")));
+  }
+
+  public static ArgumentsMutator forInstanceMethodOrThrow(Object instance, Method method) {
+    return forInstanceMethod(Mutators.newFactory(), instance, method)
+        .orElseThrow(()
+                         -> new IllegalArgumentException(
+                             "Failed to construct mutator for " + prettyPrintMethod(method)));
+  }
+
+  public static ArgumentsMutator forStaticMethodOrThrow(Method method) {
+    return forStaticMethod(Mutators.newFactory(), method)
+        .orElseThrow(()
+                         -> new IllegalArgumentException(
+                             "Failed to construct mutator for " + prettyPrintMethod(method)));
+  }
+
+  public static Optional<ArgumentsMutator> forMethod(Method method) {
+    return forMethod(Mutators.newFactory(), null, method);
+  }
+
+  public static Optional<ArgumentsMutator> forInstanceMethod(
+      MutatorFactory mutatorFactory, Object instance, Method method) {
+    require(!isStatic(method), "method must not be static");
+    requireNonNull(instance, "instance must not be null");
+    require(method.getDeclaringClass().isInstance(instance),
+        format("instance is a %s, expected %s", instance.getClass(), method.getDeclaringClass()));
+    return forMethod(mutatorFactory, instance, method);
+  }
+
+  public static Optional<ArgumentsMutator> forStaticMethod(
+      MutatorFactory mutatorFactory, Method method) {
+    require(isStatic(method), "method must be static");
+    return forMethod(mutatorFactory, null, method);
+  }
+
+  public static Optional<ArgumentsMutator> forMethod(
+      MutatorFactory mutatorFactory, Object instance, Method method) {
+    require(method.getParameterCount() > 0, "Can't fuzz method without parameters: " + method);
+    for (AnnotatedType parameter : method.getAnnotatedParameterTypes()) {
+      validateAnnotationUsage(parameter);
+    }
+    return toArrayOrEmpty(
+        stream(method.getAnnotatedParameterTypes()).map(mutatorFactory::tryCreate),
+        SerializingMutator<?>[] ::new)
+        .map(MutatorCombinators::mutateProduct)
+        .map(productMutator -> ArgumentsMutator.create(instance, method, productMutator));
+  }
+
+  private static ArgumentsMutator create(
+      Object instance, Method method, ProductMutator productMutator) {
+    method.setAccessible(true);
+
+    return new ArgumentsMutator(instance, method, productMutator);
+  }
+
+  private static boolean isStatic(Method method) {
+    return Modifier.isStatic(method.getModifiers());
+  }
+
+  /**
+   * @throws UncheckedIOException if the underlying InputStream throws
+   */
+  public void crossOver(InputStream data1, InputStream data2, long seed) {
+    try {
+      Object[] objects1 = productMutator.readExclusive(data1);
+      Object[] objects2 = productMutator.readExclusive(data2);
+      PseudoRandom prng = new SeededPseudoRandom(seed);
+      arguments = productMutator.crossOver(objects1, objects2, prng);
+      argumentsExposed = false;
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+  }
+
+  /**
+   * @return if the given input stream was consumed exactly
+   * @throws UncheckedIOException if the underlying InputStream throws
+   */
+  public boolean read(ByteArrayInputStream data) {
+    try {
+      ReadExactlyInputStream is = extendWithReadExactly(data);
+      arguments = productMutator.readExclusive(is);
+      argumentsExposed = false;
+      return is.isConsumedExactly();
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+  }
+
+  /**
+   * @throws UncheckedIOException if the underlying OutputStream throws
+   */
+  public void write(OutputStream data) {
+    failIfArgumentsExposed();
+    writeAny(data, arguments);
+  }
+
+  /**
+   * @throws UncheckedIOException if the underlying OutputStream throws
+   */
+  public void writeAny(OutputStream data, Object[] args) throws UncheckedIOException {
+    try {
+      productMutator.writeExclusive(args, data);
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+  }
+
+  public void init(long seed) {
+    init(new SeededPseudoRandom(seed));
+  }
+
+  void init(PseudoRandom prng) {
+    arguments = productMutator.init(prng);
+    argumentsExposed = false;
+  }
+
+  public void mutate(long seed) {
+    mutate(new SeededPseudoRandom(seed));
+  }
+
+  void mutate(PseudoRandom prng) {
+    failIfArgumentsExposed();
+    // TODO: Sometimes mutate the entire byte representation of the current value with libFuzzer's
+    //  dictionary and TORC mutations.
+    productMutator.mutate(arguments, prng);
+  }
+
+  public void invoke(boolean detach) throws Throwable {
+    Object[] invokeArguments;
+    if (detach) {
+      invokeArguments = productMutator.detach(arguments);
+    } else {
+      invokeArguments = arguments;
+      argumentsExposed = true;
+    }
+    try {
+      method.invoke(instance, invokeArguments);
+    } catch (IllegalAccessException e) {
+      throw new IllegalStateException("method should have been made accessible", e);
+    } catch (InvocationTargetException e) {
+      throw e.getCause();
+    }
+  }
+
+  public Object[] getArguments() {
+    argumentsExposed = true;
+    return arguments;
+  }
+
+  @Override
+  public String toString() {
+    return "Arguments" + productMutator;
+  }
+
+  private void failIfArgumentsExposed() {
+    Preconditions.check(!argumentsExposed,
+        "Arguments have previously been exposed to user-provided code without calling #detach and may have been modified");
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/BUILD.bazel b/src/main/java/com/code_intelligence/jazzer/mutation/BUILD.bazel
new file mode 100644
index 0000000..9866c2c
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/BUILD.bazel
@@ -0,0 +1,16 @@
+java_library(
+    name = "mutation",
+    srcs = glob(["*.java"]),
+    visibility = [
+        "//visibility:public",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/api",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/annotation",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/api",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/combinator",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/engine",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/mutator",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/support",
+    ],
+)
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/annotation/AppliesTo.java b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/AppliesTo.java
new file mode 100644
index 0000000..4d4c4a8
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/AppliesTo.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.annotation;
+
+import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * A meta-annotation that limits the concrete types an annotation for type usages applies to.
+ */
+@Target(ANNOTATION_TYPE)
+@Retention(RUNTIME)
+public @interface AppliesTo {
+  /**
+   * The meta-annotated annotation can be applied to these classes.
+   */
+  Class<?>[] value() default {};
+
+  /**
+   * The meta-annotated annotation can be applied to subclasses of these classes.
+   */
+  Class<?>[] subClassesOf() default {};
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/annotation/Ascii.java b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/Ascii.java
new file mode 100644
index 0000000..190ada0
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/Ascii.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.annotation;
+
+import static java.lang.annotation.ElementType.TYPE_USE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+@Target(TYPE_USE)
+@Retention(RUNTIME)
+@AppliesTo(String.class)
+public @interface Ascii {}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/annotation/BUILD.bazel b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/BUILD.bazel
new file mode 100644
index 0000000..6d6c4da
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/BUILD.bazel
@@ -0,0 +1,5 @@
+java_library(
+    name = "annotation",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+)
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/annotation/DoubleInRange.java b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/DoubleInRange.java
new file mode 100644
index 0000000..2776587
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/DoubleInRange.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.annotation;
+
+import static java.lang.annotation.ElementType.TYPE_USE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+@Target(TYPE_USE)
+@Retention(RUNTIME)
+@AppliesTo({double.class, Double.class})
+public @interface DoubleInRange {
+  double min() default Double.NEGATIVE_INFINITY;
+  double max() default Double.POSITIVE_INFINITY;
+  boolean allowNaN() default true;
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/annotation/FloatInRange.java b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/FloatInRange.java
new file mode 100644
index 0000000..ec54e02
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/FloatInRange.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.annotation;
+
+import static java.lang.annotation.ElementType.TYPE_USE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+@Target(TYPE_USE)
+@Retention(RUNTIME)
+@AppliesTo({float.class, Float.class})
+public @interface FloatInRange {
+  float min() default Float.NEGATIVE_INFINITY;
+  float max() default Float.POSITIVE_INFINITY;
+  boolean allowNaN() default true;
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/annotation/InRange.java b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/InRange.java
new file mode 100644
index 0000000..a8dc828
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/InRange.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.annotation;
+
+import static java.lang.annotation.ElementType.TYPE_USE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target(TYPE_USE)
+@Retention(RUNTIME)
+@AppliesTo({byte.class, Byte.class, short.class, Short.class, int.class, Integer.class, long.class,
+    Long.class})
+public @interface InRange {
+  long min() default Long.MIN_VALUE;
+
+  long max() default Long.MAX_VALUE;
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/annotation/NotNull.java b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/NotNull.java
new file mode 100644
index 0000000..061eeff
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/NotNull.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.annotation;
+
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.ElementType.TYPE_USE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+@Target({PARAMETER, TYPE_USE})
+@Retention(RUNTIME)
+@AppliesTo(subClassesOf = Object.class)
+public @interface NotNull {}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/annotation/WithLength.java b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/WithLength.java
new file mode 100644
index 0000000..7cee23d
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/WithLength.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.annotation;
+
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.ElementType.TYPE_USE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+@Target(TYPE_USE)
+@Retention(RUNTIME)
+@AppliesTo(byte[].class)
+public @interface WithLength {
+  int min() default 0;
+
+  int max() default 1000;
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/annotation/WithSize.java b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/WithSize.java
new file mode 100644
index 0000000..32233f4
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/WithSize.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.annotation;
+
+import static java.lang.annotation.ElementType.TYPE_USE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+import java.util.List;
+import java.util.Map;
+
+@Target(TYPE_USE)
+@Retention(RUNTIME)
+@AppliesTo({List.class, Map.class})
+public @interface WithSize {
+  int min() default 0;
+
+  int max() default 1000;
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/annotation/WithUtf8Length.java b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/WithUtf8Length.java
new file mode 100644
index 0000000..00b1f46
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/WithUtf8Length.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.annotation;
+
+import static java.lang.annotation.ElementType.TYPE_USE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * An annotation that applies to {@link String} to <strong>limit the length of the UTF-8
+ * encoding</strong> of the string. In practical terms, this means that strings given this
+ * annotation will sometimes have a {@link String#length()} of less than
+ * {@code min} but will never exceed {@code max}. <p> Due to the fact that our String mutator is
+ * backed by the byte array mutator, it's difficult to know how many characters we'll get from the
+ * byte array we get from libfuzzer. Rather than reuse {@link WithLength} for strings which may give
+ * the impression that {@link String#length()} will return a value between {@code min} and {@code
+ * max}, we use this annotation to help make clear that the string consists of between
+ * {@code min} and {@code max} UTF-8 bytes, not necessarily (UTF-16) characters.
+ */
+@Target(TYPE_USE)
+@Retention(RUNTIME)
+@AppliesTo(String.class)
+public @interface WithUtf8Length {
+  int min() default 0;
+
+  int max() default 1000;
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/annotation/proto/AnySource.java b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/proto/AnySource.java
new file mode 100644
index 0000000..9f802df
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/proto/AnySource.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.annotation.proto;
+
+import static java.lang.annotation.ElementType.TYPE_USE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.code_intelligence.jazzer.mutation.annotation.AppliesTo;
+import com.google.protobuf.Message;
+import com.google.protobuf.Message.Builder;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * Controls the mutations of {@link com.google.protobuf.Any} fields in messages of the annotated
+ * type as well as its recursive message fields.
+ */
+@Target(TYPE_USE)
+@Retention(RUNTIME)
+@AppliesTo(subClassesOf = {Message.class, Builder.class})
+public @interface AnySource {
+  /**
+   * A non-empty list of {@link Message}s to use for {@link com.google.protobuf.Any} fields.
+   */
+  Class<? extends Message>[] value();
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/annotation/proto/BUILD.bazel b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/proto/BUILD.bazel
new file mode 100644
index 0000000..8ef6863
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/proto/BUILD.bazel
@@ -0,0 +1,21 @@
+java_library(
+    name = "proto",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":protobuf_runtime_compile_only",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/annotation",
+    ],
+)
+
+java_library(
+    name = "protobuf_runtime_compile_only",
+    # The proto mutator factory detects the presence of Protobuf at runtime and disables itself if
+    # it isn't found. Without something else bringing in the Protobuf runtime, there is no point in
+    # supporting proto mutations.
+    neverlink = True,
+    visibility = ["//src/main/java/com/code_intelligence/jazzer/mutation/mutator/proto:__pkg__"],
+    exports = [
+        "@com_google_protobuf_protobuf_java//jar",
+    ],
+)
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/annotation/proto/WithDefaultInstance.java b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/proto/WithDefaultInstance.java
new file mode 100644
index 0000000..e26d73a
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/annotation/proto/WithDefaultInstance.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.annotation.proto;
+
+import static java.lang.annotation.ElementType.TYPE_USE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.code_intelligence.jazzer.mutation.annotation.AppliesTo;
+import com.google.protobuf.DynamicMessage;
+import com.google.protobuf.Message;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * Provides a default instance to use as the base for mutations of the annotated {@link Message} or
+ * {@link DynamicMessage.Builder}.
+ */
+@Target(TYPE_USE)
+@Retention(RUNTIME)
+@AppliesTo(subClassesOf = {Message.class, Message.Builder.class})
+public @interface WithDefaultInstance {
+  /**
+   * The fully qualified name of a static method (e.g.
+   * {@code com.example.MyClass#getDefaultInstance}) with return type assignable to
+   * {@link com.google.protobuf.Message}, which returns a default instance that mutations should be
+   * based on.
+   */
+  String value();
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/api/BUILD.bazel b/src/main/java/com/code_intelligence/jazzer/mutation/api/BUILD.bazel
new file mode 100644
index 0000000..cd5fe60
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/api/BUILD.bazel
@@ -0,0 +1,9 @@
+java_library(
+    name = "api",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/mutation/support",
+        "@com_google_errorprone_error_prone_annotations//jar",
+    ],
+)
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/api/ChainedMutatorFactory.java b/src/main/java/com/code_intelligence/jazzer/mutation/api/ChainedMutatorFactory.java
new file mode 100644
index 0000000..bf27e81
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/api/ChainedMutatorFactory.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.api;
+
+import static com.code_intelligence.jazzer.mutation.support.StreamSupport.findFirstPresent;
+import static java.util.Arrays.asList;
+import static java.util.Collections.unmodifiableList;
+
+import com.google.errorprone.annotations.CheckReturnValue;
+import java.lang.reflect.AnnotatedType;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * A {@link MutatorFactory} that delegates to the given factories in order.
+ */
+public final class ChainedMutatorFactory extends MutatorFactory {
+  private final List<MutatorFactory> factories;
+
+  /**
+   * Creates a {@link MutatorFactory} that delegates to the given factories in order.
+   *
+   * @param factories a possibly empty collection of factories
+   */
+  public ChainedMutatorFactory(MutatorFactory... factories) {
+    this.factories = unmodifiableList(asList(factories));
+  }
+
+  @Override
+  @CheckReturnValue
+  public Optional<SerializingMutator<?>> tryCreate(AnnotatedType type, MutatorFactory parent) {
+    return findFirstPresent(factories.stream().map(factory -> factory.tryCreate(type, parent)));
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/api/Debuggable.java b/src/main/java/com/code_intelligence/jazzer/mutation/api/Debuggable.java
new file mode 100644
index 0000000..df6f828
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/api/Debuggable.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.api;
+
+import static java.util.Collections.newSetFromMap;
+import static java.util.Objects.requireNonNull;
+
+import java.util.IdentityHashMap;
+import java.util.Set;
+import java.util.function.Predicate;
+
+public interface Debuggable {
+  /**
+   * Returns a string representation of the object that is meant to be used to make assertions about
+   * its structure in tests.
+   *
+   * @param isInCycle evaluates to {@code true} if a cycle has been detected during recursive calls
+   *                  of this function. Must be called at most once with {@code this} as the single
+   *                  argument. Implementing classes that know that their current instance can never
+   *                  be contained in recursive substructures need not call this method.
+   */
+  String toDebugString(Predicate<Debuggable> isInCycle);
+
+  /**
+   * Returns a string representation of the given {@link Debuggable} that is meant to be used to
+   * make assertions about its structure in tests.
+   */
+  static String getDebugString(Debuggable debuggable) {
+    Set<Debuggable> seen = newSetFromMap(new IdentityHashMap<>());
+    return debuggable.toDebugString(child -> !seen.add(requireNonNull(child)));
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/api/Detacher.java b/src/main/java/com/code_intelligence/jazzer/mutation/api/Detacher.java
new file mode 100644
index 0000000..d927e50
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/api/Detacher.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.api;
+
+import com.google.errorprone.annotations.CheckReturnValue;
+
+/**
+ * Knows how to clone a {@code T} such that it shares no mutable state with the original.
+ */
+@FunctionalInterface
+public interface Detacher<T> {
+  /**
+   * Returns an equal instance that shares no mutable state with {@code value}.
+   *
+   * <p>Implementations
+   * <ul>
+   *   <li>MUST return an instance that {@link Object#equals(Object)} the argument;
+   *   <li>MUST return an instance that cannot be used to mutate the state of the argument through
+   *   its API (ignoring uses of {@link sun.misc.Unsafe});
+   *   <li>MUST return an instance that is not affected by any changes to the original value made
+   *   by any mutator;</li>
+   *   <li>MUST be accepted by mutator methods just like the original value;</li>
+   *   <li>MAY return the argument itself if it is deeply immutable.
+   * </ul>
+   *
+   * @param value the instance to detach
+   * @return an equal instance that shares no mutable state with {@code value}
+   */
+  @CheckReturnValue T detach(T value);
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/api/InPlaceMutator.java b/src/main/java/com/code_intelligence/jazzer/mutation/api/InPlaceMutator.java
new file mode 100644
index 0000000..cf1b243
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/api/InPlaceMutator.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.api;
+
+/**
+ * Knows how to initialize and mutate (parts of) an existing object of type {@code T} in place and
+ * how to incorporate (cross over) parts of another object of the same type.
+ *
+ * <p>Certain types, such as immutable and primitive types, can not be mutated in place. For
+ * example, {@link java.util.List} can be mutated in place whereas {@link String} and {@code int}
+ * can't. In such cases, use {@link ValueMutator} instead.
+ *
+ * <p>Implementations
+ * <ul>
+ *   <li>MAY weakly associate mutable state with the identity (not equality class) of objects they
+ *   have been passed as arguments or returned from initialization functions;
+ *   <li>MAY assume that they are only passed arguments that they have initialized or mutated;</li>
+ *   <li>SHOULD use {@link com.code_intelligence.jazzer.mutation.support.WeakIdentityHashMap} for
+ *   this purpose;
+ *   <li>MUST otherwise be deeply immutable;
+ *   <li>SHOULD override {@link Object#toString()} to return {@code
+ * Debuggable.getDebugString(this)}.
+ * </ul>
+ *
+ * @param <T> the reference type this mutator operates on
+ */
+public interface InPlaceMutator<T> extends Debuggable {
+  /**
+   * Implementations
+   * <ul>
+   *   <li>MUST accept any mutable instance of {@code T}, not just those it creates itself.
+   *   <li>SHOULD, when called repeatedly, initialize the object in ways that are likely to be
+   *   distinct.
+   * </ul>
+   */
+  void initInPlace(T reference, PseudoRandom prng);
+
+  /**
+   * Implementations
+   * <ul>
+   *   <li>MUST ensure that {@code reference} does not {@link Object#equals(Object)} the state it
+   * had prior to the call (if possible);
+   *   <li>MUST accept any mutable instance of {@code T}, not just those it creates itself.
+   *   <li>SHOULD, when called repeatedly, be able to eventually reach any valid state of the part
+   * of {@code T} governed by this mutator;
+   * </ul>
+   */
+  void mutateInPlace(T reference, PseudoRandom prng);
+
+  /**
+   * Implementations
+   * <ul>
+   *   <li>MUST ensure that {@code reference} does not {@link Object#equals(Object)} the state it
+   * had prior to the call (if possible);
+   *   <li>MUST accept any mutable instance of {@code T}, not just those it creates itself.
+   *   <li>MUST NOT mutate {@code otherReference}</li>
+   * </ul>
+   */
+  void crossOverInPlace(T reference, T otherReference, PseudoRandom prng);
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/api/MutatorFactory.java b/src/main/java/com/code_intelligence/jazzer/mutation/api/MutatorFactory.java
new file mode 100644
index 0000000..6477128
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/api/MutatorFactory.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.api;
+
+import static com.code_intelligence.jazzer.mutation.support.Preconditions.require;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.asAnnotatedType;
+import static java.lang.String.format;
+
+import com.google.errorprone.annotations.CheckReturnValue;
+import java.lang.reflect.AnnotatedType;
+import java.util.Optional;
+
+/**
+ * Instances of this class are not required to be thread safe, but are generally lightweight and can
+ * thus be created as needed.
+ */
+public abstract class MutatorFactory {
+  public final boolean canMutate(AnnotatedType type) {
+    return tryCreate(type).isPresent();
+  }
+
+  public final <T> SerializingMutator<T> createOrThrow(Class<T> clazz) {
+    return (SerializingMutator<T>) createOrThrow(asAnnotatedType(clazz));
+  }
+
+  public final SerializingMutator<?> createOrThrow(AnnotatedType type) {
+    Optional<SerializingMutator<?>> maybeMutator = tryCreate(type);
+    require(maybeMutator.isPresent(), "Failed to create mutator for " + type);
+    return maybeMutator.get();
+  }
+
+  public final SerializingInPlaceMutator<?> createInPlaceOrThrow(AnnotatedType type) {
+    Optional<SerializingInPlaceMutator<?>> maybeMutator = tryCreateInPlace(type);
+    require(maybeMutator.isPresent(), "Failed to create mutator for " + type);
+    return maybeMutator.get();
+  }
+
+  /**
+   * Tries to create a mutator for {@code type} and, if successful, asserts that it is an instance
+   * of {@link SerializingInPlaceMutator}.
+   */
+  public final Optional<SerializingInPlaceMutator<?>> tryCreateInPlace(AnnotatedType type) {
+    return tryCreate(type).map(mutator -> {
+      require(mutator instanceof InPlaceMutator<?>,
+          format("Mutator for %s is not in-place: %s", type, mutator.getClass()));
+      return (SerializingInPlaceMutator<?>) mutator;
+    });
+  }
+
+  @CheckReturnValue
+  public final Optional<SerializingMutator<?>> tryCreate(AnnotatedType type) {
+    return tryCreate(type, this);
+  }
+
+  /**
+   * Attempt to create a {@link SerializingMutator} for the given type.
+   *
+   * @param type the type to mutate
+   * @param factory the factory to use when creating submutators
+   * @return a {@link SerializingMutator} for the given {@code type}, or {@link Optional#empty()}
+   * if this factory can't create such mutators
+   */
+  @CheckReturnValue
+  public abstract Optional<SerializingMutator<?>> tryCreate(
+      AnnotatedType type, MutatorFactory factory);
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/api/PseudoRandom.java b/src/main/java/com/code_intelligence/jazzer/mutation/api/PseudoRandom.java
new file mode 100644
index 0000000..3755a7b
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/api/PseudoRandom.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.api;
+
+import com.google.errorprone.annotations.DoNotMock;
+import java.util.List;
+import java.util.function.Supplier;
+
+@DoNotMock("Use TestSupport#mockPseudoRandom instead")
+public interface PseudoRandom {
+  /**
+   * @return a uniformly random {@code boolean}
+   */
+  boolean choice();
+
+  /**
+   * @return a {@code boolean} that is {@code true} with probability {@code 1/inverseFrequencyTrue}
+   */
+  boolean trueInOneOutOf(int inverseFrequencyTrue);
+
+  /**
+   * @throws IllegalArgumentException if {@code array.length == 0}
+   * @return an element from the given array at uniformly random index
+   */
+  <T> T pickIn(T[] array);
+
+  /**
+   * @throws IllegalArgumentException if {@code array.length == 0}
+   * @return an element from the given List at uniformly random index
+   */
+  <T> T pickIn(List<T> array);
+
+  /**
+   * @throws IllegalArgumentException if {@code array.length == 0}
+   * @return a uniformly random index valid for the given array
+   */
+  <T> int indexIn(T[] array);
+
+  /**
+   * @throws IllegalArgumentException if {@code list.size() == 0}
+   * @return a uniformly random index valid for the given list
+   */
+  <T> int indexIn(List<T> list);
+
+  /**
+   * Prefer {@link #indexIn(Object[])} and {@link #indexIn(List)}.
+   *
+   * @throws IllegalArgumentException if {@code range < 1}
+   * @return a uniformly random index in the range {@code [0, range-1]}
+   */
+  int indexIn(int range);
+
+  /**
+   * @throws IllegalArgumentException if {@code array.length < 2}
+   * @return a uniformly random index valid for the given array and different from
+   * {@code currentIndex}
+   */
+  <T> int otherIndexIn(T[] array, int currentIndex);
+
+  /**
+   * @throws IllegalArgumentException if {@code length < 2}
+   * @return a uniformly random {@code int} in the closed range {@code [0, length)} that is
+   *     different from {@code currentIndex}
+   */
+  int otherIndexIn(int range, int currentIndex);
+
+  /**
+   * @return a uniformly random {@code int} in the closed range
+   * {@code [lowerInclusive, upperInclusive]}.
+   */
+  int closedRange(int lowerInclusive, int upperInclusive);
+
+  /**
+   * @return a uniformly random {@code long} in the closed range
+   * {@code [lowerInclusive, upperInclusive]}.
+   */
+  long closedRange(long lowerInclusive, long upperInclusive);
+
+  /**
+   * @return a uniformly random {@code float} in the closed range
+   * {@code [lowerInclusive, upperInclusive]}.
+   */
+  float closedRange(float lowerInclusive, float upperInclusive);
+
+  /**
+   * @return a uniformly random {@code double} in the closed range
+   * {@code [lowerInclusive, upperInclusive]}.
+   */
+  double closedRange(double lowerInclusive, double upperInclusive);
+
+  /**
+   * @return a random value in the closed range [0, upperInclusive] that is heavily biased towards
+   *     being small
+   */
+  int closedRangeBiasedTowardsSmall(int upperInclusive);
+
+  /**
+   * @return a random value in the closed range [lowerInclusive, upperInclusive] that is heavily
+   *     biased towards being small
+   */
+  int closedRangeBiasedTowardsSmall(int lowerInclusive, int upperInclusive);
+
+  /**
+   * Fills the given array with random bytes.
+   */
+  void bytes(byte[] bytes);
+
+  /**
+   * Use the given supplier to produce a value with probability {@code 1/inverseSupplierFrequency},
+   * otherwise randomly return one of the given values.
+   *
+   * @return value produced by the supplier or one of the given values
+   */
+  <T> T pickValue(T value, T otherValue, Supplier<T> supplier, int inverseSupplierFrequency);
+
+  /**
+   * Returns a pseudorandom {@code long} value.
+   *
+   * @return a pseudorandom {@code long} value
+   */
+  long nextLong();
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/api/Serializer.java b/src/main/java/com/code_intelligence/jazzer/mutation/api/Serializer.java
new file mode 100644
index 0000000..b948b17
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/api/Serializer.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.api;
+
+import static com.code_intelligence.jazzer.mutation.support.InputStreamSupport.extendWithZeros;
+
+import com.google.errorprone.annotations.CheckReturnValue;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * Serializes and deserializes values of type {@code T>} to and from (in-memory or on disk) corpus
+ * entries.
+ *
+ * <p>Binary representations must by default be self-delimiting. For variable-length types, the
+ * {@link #readExclusive(InputStream)} and {@link #writeExclusive(Object, OutputStream)} methods can
+ * optionally be overriden to implement more compact representations that align with existing binary
+ * corpus entries. For example, a {@code Serializer<byte[]>} could implement these optional methods
+ * to read and write the raw bytes without preceding length information whenever it is used in an
+ * already delimited context.
+ */
+public interface Serializer<T> extends Detacher<T> {
+  /**
+   * Reads a {@code T} from an endless stream that is eventually 0.
+   *
+   * <p>Implementations
+   * <ul>
+   *   <li>MUST not attempt to consume the entire stream;
+   *   <li>MUST return a valid {@code T} and not throw for any (even garbage) stream;
+   *   <li>SHOULD short-circuit the creation of nested structures upon reading null bytes.
+   * </ul>
+   *
+   * @param in an endless stream that eventually only reads null bytes
+   * @return a {@code T} constructed from the bytes read
+   * @throws IOException declared, but must not be thrown by implementations unless methods called
+   *                     on {@code in} do
+   */
+  @CheckReturnValue T read(DataInputStream in) throws IOException;
+
+  /**
+   * Writes a {@code T} to a stream in such a way that an equal object can be recovered from the
+   * written bytes via {@link #read(DataInputStream)}.
+   *
+   * <p>Since {@link #read(DataInputStream)} is called with an endless stream, the binary
+   * representation MUST be self-delimiting. For example, when writing out a list, first write its
+   * length.
+   *
+   * @param value the value to write
+   * @param out   the stream to write to
+   * @throws IOException declared, but must not be thrown by implementations unless methods called
+   *                     on {@code out} do
+   */
+  void write(T value, DataOutputStream out) throws IOException;
+
+  /**
+   * Reads a {@code T} from a finite stream, potentially using a simpler representation than that
+   * read by {@link #read(DataInputStream)}.
+   *
+   * <p>The default implementations call extends the stream with null bytes and then calls
+   * {@link #read(DataInputStream)}.
+   *
+   * <p>Implementations
+   * <ul>
+   *   <li>MUST return a valid {@code T} and not throw for any (even garbage) stream;
+   *   <li>SHOULD short-circuit the creation of nested structures upon reading null bytes;
+   *   <li>SHOULD naturally consume the entire stream.
+   * </ul>
+   *
+   * @param in a finite stream
+   * @return a {@code T} constructed from the bytes read
+   * @throws IOException declared, but must not be thrown by implementations unless methods called
+   *                     on {@code in} do
+   */
+  @CheckReturnValue
+  default T readExclusive(InputStream in) throws IOException {
+    return read(new DataInputStream(extendWithZeros(in)));
+  }
+
+  /**
+   * Writes a {@code T} to a stream in such a way that an equal object can be recovered from the
+   * written bytes via {@link #readExclusive(InputStream)}.
+   *
+   * <p>The default implementations calls through to {@link #read(DataInputStream)} and should only
+   * be overriden if {@link #readExclusive(InputStream)} is.
+   *
+   * <p>As opposed to {@link #read(DataInputStream)}, {@link #readExclusive(InputStream)} is called
+   * with a finite stream. The binary representation of a {@code T} value thus does not have to be
+   * self-delimiting, which can allow for simpler representations. For example, a {@code byte[]} can
+   * be written to the stream without prepending its length.
+   *
+   * @param value the value to write
+   * @param out   the stream to write to
+   * @throws IOException declared, but must not be thrown by implementations unless methods called
+   *                     on {@code out} do
+   */
+  default void writeExclusive(T value, OutputStream out) throws IOException {
+    write(value, new DataOutputStream(out));
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/api/SerializingInPlaceMutator.java b/src/main/java/com/code_intelligence/jazzer/mutation/api/SerializingInPlaceMutator.java
new file mode 100644
index 0000000..b7bc4d4
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/api/SerializingInPlaceMutator.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.api;
+
+import static com.code_intelligence.jazzer.mutation.support.ExceptionSupport.asUnchecked;
+
+import com.google.errorprone.annotations.ForOverride;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Combines an {@link InPlaceMutator} with a {@link Serializer} for objects of type {@code T}.
+ *
+ * <p>If {@code T} can't be mutated in place, implement {@link SerializingMutator} instead.
+ *
+ * <p>Implementing classes SHOULD be declared final.
+ */
+public abstract class SerializingInPlaceMutator<T>
+    extends SerializingMutator<T> implements InPlaceMutator<T> {
+  // ByteArrayInputStream#close is documented as being a no-op, so it is safe to reuse an instance
+  // here.
+  // TODO: Introduce a dedicated empty InputStream implementation.
+  private static final InputStream emptyInputStream = new ByteArrayInputStream(new byte[0]);
+
+  /**
+   * Constructs a default instance of {@code T}.
+   *
+   * <p>The returned value is immediately passed to {@link #initInPlace(Object, PseudoRandom)}.
+   *
+   * <p>Implementing classes SHOULD provide a more efficient implementation.
+   *
+   * @return a default instance of {@code T}
+   */
+  @ForOverride
+  protected T makeDefaultInstance() {
+    try {
+      return readExclusive(emptyInputStream);
+    } catch (IOException e) {
+      throw asUnchecked(e);
+    }
+  }
+
+  @Override
+  public final T init(PseudoRandom prng) {
+    T value = makeDefaultInstance();
+    initInPlace(value, prng);
+    return value;
+  }
+
+  @Override
+  public final T mutate(T value, PseudoRandom prng) {
+    mutateInPlace(value, prng);
+    return value;
+  }
+
+  @Override
+  public final T crossOver(T value, T otherValue, PseudoRandom prng) {
+    crossOverInPlace(value, otherValue, prng);
+    return value;
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/api/SerializingMutator.java b/src/main/java/com/code_intelligence/jazzer/mutation/api/SerializingMutator.java
new file mode 100644
index 0000000..58b2a49
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/api/SerializingMutator.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.api;
+
+import com.google.errorprone.annotations.DoNotMock;
+
+/**
+ * Combines a {@link ValueMutator} with a {@link Serializer} for objects of type {@code T}.
+ *
+ * <p>Implementing classes SHOULD be declared final.
+ *
+ * <p>This is the default fully-featured mutator type. If {@code T} can be mutated fully in place,
+ * consider implementing the more versatile {@link SerializingInPlaceMutator} instead.
+ */
+@DoNotMock("Use TestSupport#mockMutator instead")
+public abstract class SerializingMutator<T> implements Serializer<T>, ValueMutator<T> {
+  @Override
+  public final String toString() {
+    return Debuggable.getDebugString(this);
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/api/ValueMutator.java b/src/main/java/com/code_intelligence/jazzer/mutation/api/ValueMutator.java
new file mode 100644
index 0000000..aa2b551
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/api/ValueMutator.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.api;
+
+import com.google.errorprone.annotations.CheckReturnValue;
+
+/**
+ * Knows how to initialize and mutate objects of type {@code T} and how to incorporate
+ * (cross over) parts of another object of the same type.
+ *
+ * <p>Certain types can be mutated fully in place. In such cases, prefer implementing the more
+ * versatile {@link InPlaceMutator} instead.
+ *
+ * <p>Implementations
+ * <ul>
+ *   <li>MAY weakly associate mutable state with the identity (not equality class) of objects they
+ *   have been passed as arguments or returned from initialization functions;
+ *   <li>MAY assume that they are only passed arguments that they have initialized or mutated;</li>
+ *   <li>SHOULD use {@link com.code_intelligence.jazzer.mutation.support.WeakIdentityHashMap} for
+ *   this purpose;
+ *   <li>MUST otherwise be deeply immutable;
+ *   <li>SHOULD override {@link Object#toString()} to return {@code
+ * Debuggable.getDebugString(this)}.
+ * </ul>
+ *
+ * @param <T> the type this mutator operates on
+ */
+public interface ValueMutator<T> extends Debuggable {
+  /**
+   * Implementations
+   * <ul>
+   *   <li>SHOULD, when called repeatedly, return a low amount of duplicates.
+   * </ul>
+   *
+   * @return an instance of {@code T}
+   */
+  @CheckReturnValue T init(PseudoRandom prng);
+
+  /**
+   * Implementations
+   * <ul>
+   *   <li>MUST return a value that does not {@link Object#equals(Object)} the argument (if
+   * possible);
+   *   <li>SHOULD, when called repeatedly, be able to eventually return any valid value of
+   * type {@code T};
+   *   <li>MAY mutate the argument.
+   * </ul>
+   */
+  @CheckReturnValue T mutate(T value, PseudoRandom prng);
+
+  /**
+   * Implementations
+   * <ul>
+   *   <li>MUST return a value that does not {@link Object#equals(Object)} the arguments (if
+   * possible);
+   *   <li>MAY mutate {@code value}.
+   *   <li>MUST NOT mutate {@code otherValue}.
+   * </ul>
+   */
+  @CheckReturnValue T crossOver(T value, T otherValue, PseudoRandom prng);
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/combinator/BUILD.bazel b/src/main/java/com/code_intelligence/jazzer/mutation/combinator/BUILD.bazel
new file mode 100644
index 0000000..639a3d0
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/combinator/BUILD.bazel
@@ -0,0 +1,11 @@
+java_library(
+    name = "combinator",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/mutation/api",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/support",
+        "@com_github_jhalterman_typetools//:typetools",
+        "@com_google_errorprone_error_prone_type_annotations//jar",
+    ],
+)
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/combinator/MutatorCombinators.java b/src/main/java/com/code_intelligence/jazzer/mutation/combinator/MutatorCombinators.java
new file mode 100644
index 0000000..18fa028
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/combinator/MutatorCombinators.java
@@ -0,0 +1,516 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.combinator;
+
+import static com.code_intelligence.jazzer.mutation.support.Preconditions.require;
+import static com.code_intelligence.jazzer.mutation.support.Preconditions.requireNonNullElements;
+import static java.util.Arrays.stream;
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.joining;
+
+import com.code_intelligence.jazzer.mutation.api.Debuggable;
+import com.code_intelligence.jazzer.mutation.api.InPlaceMutator;
+import com.code_intelligence.jazzer.mutation.api.PseudoRandom;
+import com.code_intelligence.jazzer.mutation.api.Serializer;
+import com.code_intelligence.jazzer.mutation.api.SerializingInPlaceMutator;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.google.errorprone.annotations.ImmutableTypeParameter;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Arrays;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+import java.util.function.ToIntFunction;
+import net.jodah.typetools.TypeResolver;
+
+public final class MutatorCombinators {
+  // Inverse frequency in which value mutator should be used in cross over.
+  private final static int INVERSE_PICK_VALUE_SUPPLIER_FREQUENCY = 100;
+
+  private MutatorCombinators() {}
+
+  public static <T, R> InPlaceMutator<T> mutateProperty(
+      Function<T, R> getter, SerializingMutator<R> mutator, BiConsumer<T, R> setter) {
+    requireNonNull(getter);
+    requireNonNull(mutator);
+    requireNonNull(setter);
+    return new InPlaceMutator<T>() {
+      @Override
+      public void initInPlace(T reference, PseudoRandom prng) {
+        setter.accept(reference, mutator.init(prng));
+      }
+
+      @Override
+      public void mutateInPlace(T reference, PseudoRandom prng) {
+        setter.accept(reference, mutator.mutate(getter.apply(reference), prng));
+      }
+
+      @Override
+      public void crossOverInPlace(T reference, T otherReference, PseudoRandom prng) {
+        // Most of the time cross over of properties should use one of the
+        // given values and only seldom use the property type specific cross
+        // over function. Other mutator combinators delegate to this one and
+        // don't cross over values themselves.
+        R referenceValue = getter.apply(reference);
+        R otherReferenceValue = getter.apply(otherReference);
+        R crossedOver = prng.pickValue(referenceValue, otherReferenceValue,
+            ()
+                -> mutator.crossOver(referenceValue, otherReferenceValue, prng),
+            INVERSE_PICK_VALUE_SUPPLIER_FREQUENCY);
+        if (crossedOver == otherReferenceValue) {
+          // If otherReference was picked, it needs to be detached as mutating
+          // it is prohibited in cross over.
+          crossedOver = mutator.detach(crossedOver);
+        }
+        setter.accept(reference, crossedOver);
+      }
+
+      @Override
+      public String toDebugString(Predicate<Debuggable> isInCycle) {
+        Class<?> owningType =
+            TypeResolver.resolveRawArguments(Function.class, getter.getClass())[0];
+        return owningType.getSimpleName() + "." + mutator.toDebugString(isInCycle);
+      }
+
+      @Override
+      public String toString() {
+        return Debuggable.getDebugString(this);
+      }
+    };
+  }
+
+  public static <T, R> InPlaceMutator<T> mutateViaView(
+      Function<T, R> map, InPlaceMutator<R> mutator) {
+    requireNonNull(map);
+    requireNonNull(mutator);
+    return new InPlaceMutator<T>() {
+      @Override
+      public void initInPlace(T reference, PseudoRandom prng) {
+        mutator.initInPlace(map.apply(reference), prng);
+      }
+
+      @Override
+      public void mutateInPlace(T reference, PseudoRandom prng) {
+        mutator.mutateInPlace(map.apply(reference), prng);
+      }
+
+      @Override
+      public void crossOverInPlace(T reference, T otherReference, PseudoRandom prng) {
+        mutator.crossOverInPlace(map.apply(reference), map.apply(otherReference), prng);
+      }
+
+      @Override
+      public String toDebugString(Predicate<Debuggable> isInCycle) {
+        Class<?> owningType = TypeResolver.resolveRawArguments(Function.class, map.getClass())[0];
+        return owningType.getSimpleName() + " via " + mutator.toDebugString(isInCycle);
+      }
+
+      @Override
+      public String toString() {
+        return Debuggable.getDebugString(this);
+      }
+    };
+  }
+
+  /**
+   * Combines multiple in-place mutators for different parts of a {@code T} into one that picks one
+   * at random whenever it mutates.
+   *
+   * <p>Calling this method with no arguments returns a no-op mutator that may decrease fuzzing
+   * efficiency.
+   */
+  @SafeVarargs
+  public static <T> InPlaceMutator<T> combine(InPlaceMutator<T>... partialMutators) {
+    requireNonNullElements(partialMutators);
+    if (partialMutators.length == 0) {
+      return new InPlaceMutator<T>() {
+        @Override
+        public void initInPlace(T reference, PseudoRandom prng) {}
+
+        @Override
+        public void mutateInPlace(T reference, PseudoRandom prng) {}
+
+        @Override
+        public void crossOverInPlace(T reference, T otherReference, PseudoRandom prng) {}
+
+        @Override
+        public String toDebugString(Predicate<Debuggable> isInCycle) {
+          return "{<empty>}";
+        }
+
+        @Override
+        public String toString() {
+          return Debuggable.getDebugString(this);
+        }
+      };
+    }
+
+    final InPlaceMutator<T>[] mutators = Arrays.copyOf(partialMutators, partialMutators.length);
+    return new InPlaceMutator<T>() {
+      @Override
+      public void initInPlace(T reference, PseudoRandom prng) {
+        for (InPlaceMutator<T> mutator : mutators) {
+          mutator.initInPlace(reference, prng);
+        }
+      }
+
+      @Override
+      public void mutateInPlace(T reference, PseudoRandom prng) {
+        mutators[prng.indexIn(mutators)].mutateInPlace(reference, prng);
+      }
+
+      @Override
+      public void crossOverInPlace(T reference, T otherReference, PseudoRandom prng) {
+        for (InPlaceMutator<T> mutator : mutators) {
+          mutator.crossOverInPlace(reference, otherReference, prng);
+        }
+      }
+
+      @Override
+      public String toDebugString(Predicate<Debuggable> isInCycle) {
+        return stream(mutators)
+            .map(mutator -> mutator.toDebugString(isInCycle))
+            .collect(joining(", ", "{", "}"));
+      }
+
+      @Override
+      public String toString() {
+        return Debuggable.getDebugString(this);
+      }
+    };
+  }
+
+  public static <T, R> SerializingMutator<R> mutateThenMap(
+      SerializingMutator<T> mutator, Function<T, R> map, Function<R, T> inverse) {
+    return new PostComposedMutator<T, R>(mutator, map, inverse) {};
+  }
+
+  public static <T, R> SerializingMutator<R> mutateThenMap(SerializingMutator<T> mutator,
+      Function<T, R> map, Function<R, T> inverse, Function<Predicate<Debuggable>, String> debug) {
+    return new PostComposedMutator<T, R>(mutator, map, inverse) {
+      @Override
+      public String toDebugString(Predicate<Debuggable> isInCycle) {
+        return debug.apply(isInCycle);
+      }
+    };
+  }
+
+  public static <T, @ImmutableTypeParameter R> SerializingMutator<R> mutateThenMapToImmutable(
+      SerializingMutator<T> mutator, Function<T, R> map, Function<R, T> inverse) {
+    return new PostComposedMutator<T, R>(mutator, map, inverse) {
+      @Override
+      public R detach(R value) {
+        return value;
+      }
+    };
+  }
+
+  public static <T, @ImmutableTypeParameter R> SerializingMutator<R> mutateThenMapToImmutable(
+      SerializingMutator<T> mutator, Function<T, R> map, Function<R, T> inverse,
+      Function<Predicate<Debuggable>, String> debug) {
+    return new PostComposedMutator<T, R>(mutator, map, inverse) {
+      @Override
+      public R detach(R value) {
+        return value;
+      }
+
+      @Override
+      public String toDebugString(Predicate<Debuggable> isInCycle) {
+        return debug.apply(isInCycle);
+      }
+    };
+  }
+
+  public static SerializingMutator<Integer> mutateIndices(int length) {
+    require(length > 1, "There should be at least two indices to choose from");
+    return new SerializingMutator<Integer>() {
+      @Override
+      public Integer read(DataInputStream in) throws IOException {
+        return Math.floorMod(in.readInt(), length);
+      }
+
+      @Override
+      public void write(Integer value, DataOutputStream out) throws IOException {
+        out.writeInt(value);
+      }
+
+      @Override
+      public Integer detach(Integer value) {
+        return value;
+      }
+
+      @Override
+      public Integer init(PseudoRandom prng) {
+        return prng.closedRange(0, length - 1);
+      }
+
+      @Override
+      public Integer mutate(Integer value, PseudoRandom prng) {
+        return prng.otherIndexIn(length, value);
+      }
+
+      @Override
+      public Integer crossOver(Integer value, Integer otherValue, PseudoRandom prng) {
+        return prng.choice() ? value : otherValue;
+      }
+
+      @Override
+      public String toDebugString(Predicate<Debuggable> isInCycle) {
+        return "mutateIndices(" + length + ")";
+      }
+    };
+  }
+
+  /**
+   * Combines multiple mutators for potentially different types into one that mutates an
+   * {@code Object[]} containing one instance per mutator.
+   */
+  @SuppressWarnings("rawtypes")
+  public static ProductMutator mutateProduct(SerializingMutator... mutators) {
+    return new ProductMutator(mutators);
+  }
+
+  /**
+   * Mutates a sum type (e.g. a Protobuf oneof) in place, preferring to mutate the current state
+   * but occasionally switching to a different state.
+   * @param getState a function that returns the current state of the sum type as an index into
+   *                 {@code perStateMutators}, or -1 if the state is indeterminate.
+   * @param perStateMutators the mutators for each state
+   * @return a mutator that mutates the sum type in place
+   */
+  @SafeVarargs
+  public static <T> InPlaceMutator<T> mutateSumInPlace(
+      ToIntFunction<T> getState, InPlaceMutator<T>... perStateMutators) {
+    final InPlaceMutator<T>[] mutators = Arrays.copyOf(perStateMutators, perStateMutators.length);
+    return new InPlaceMutator<T>() {
+      @Override
+      public void initInPlace(T reference, PseudoRandom prng) {
+        mutators[prng.indexIn(mutators)].initInPlace(reference, prng);
+      }
+
+      @Override
+      public void mutateInPlace(T reference, PseudoRandom prng) {
+        int currentState = getState.applyAsInt(reference);
+        if (currentState == -1) {
+          // The value is in an indeterminate state, initialize it.
+          initInPlace(reference, prng);
+        } else if (prng.trueInOneOutOf(100) && mutators.length > 1) {
+          // Initialize to a different state.
+          mutators[prng.otherIndexIn(mutators, currentState)].initInPlace(reference, prng);
+        } else {
+          // Mutate within the current state.
+          mutators[currentState].mutateInPlace(reference, prng);
+        }
+      }
+
+      @Override
+      public void crossOverInPlace(T reference, T otherReference, PseudoRandom prng) {
+        // Try to cross over in current state and leave state changes to the mutate step.
+        int currentState = getState.applyAsInt(reference);
+        int otherState = getState.applyAsInt(otherReference);
+        if (currentState == -1) {
+          // If reference is not initialized to a concrete state yet, try to do so in
+          // the state of other reference, as that's at least some progress.
+          if (otherState == -1) {
+            // If both states are indeterminate, cross over can not be performed.
+            return;
+          }
+          mutators[otherState].initInPlace(reference, prng);
+        } else if (currentState == otherState) {
+          mutators[currentState].crossOverInPlace(reference, otherReference, prng);
+        }
+      }
+
+      @Override
+      public String toDebugString(Predicate<Debuggable> isInCycle) {
+        return stream(mutators)
+            .map(mutator -> mutator.toDebugString(isInCycle))
+            .collect(joining(" | "));
+      }
+    };
+  }
+
+  /**
+   * @return a mutator that behaves identically to the provided one except that its {@link
+   * InPlaceMutator#initInPlace(Object, PseudoRandom)} is a no-op
+   */
+  public static <T> InPlaceMutator<T> withoutInit(InPlaceMutator<T> mutator) {
+    return new InPlaceMutator<T>() {
+      @Override
+      public void initInPlace(T reference, PseudoRandom prng) {
+        // Intentionally left empty.
+      }
+
+      @Override
+      public String toDebugString(Predicate<Debuggable> isInCycle) {
+        return "WithoutInit(" + mutator.toDebugString(isInCycle) + ")";
+      }
+
+      @Override
+      public void mutateInPlace(T reference, PseudoRandom prng) {
+        mutator.mutateInPlace(reference, prng);
+      }
+
+      @Override
+      public void crossOverInPlace(T reference, T otherReference, PseudoRandom prng) {
+        mutator.crossOverInPlace(reference, otherReference, prng);
+      }
+    };
+  }
+
+  /**
+   * Constructs a mutator that always returns the provided fixed value.
+   *
+   * <p>Note: This mutator explicitly breaks the contract of the init and mutate methods. Use
+   * sparingly as it may harm the overall effectivity of the mutator.
+   */
+  public static <@ImmutableTypeParameter T> SerializingMutator<T> fixedValue(T value) {
+    return new SerializingMutator<T>() {
+      @Override
+      public String toDebugString(Predicate<Debuggable> isInCycle) {
+        return "FixedValue(" + value + ")";
+      }
+
+      @Override
+      public T read(DataInputStream in) {
+        return value;
+      }
+
+      @Override
+      public void write(T value, DataOutputStream out) {}
+
+      @Override
+      public T detach(T value) {
+        return value;
+      }
+
+      @Override
+      public T init(PseudoRandom prng) {
+        return value;
+      }
+
+      @Override
+      public T mutate(T value, PseudoRandom prng) {
+        return value;
+      }
+
+      @Override
+      public T crossOver(T value, T otherValue, PseudoRandom prng) {
+        return value;
+      }
+    };
+  }
+
+  /**
+   * Assembles the parameters into a full implementation of {@link SerializingInPlaceMutator<T>}:
+   *
+   * @param registerSelf        a callback that will receive the uninitialized mutator instance
+   *                            before {@code lazyMutator} is invoked. For simple cases this can
+   *                            just do nothing, but it is needed to implement mutators for
+   *                            structures that are self-referential (e.g. Protobuf message A having
+   *                            a field of type A).
+   * @param makeDefaultInstance constructs a mutable default instance of {@code T}
+   * @param serializer          implementation of the {@link Serializer<T>} part
+   * @param lazyMutator         supplies the implementation of the {@link InPlaceMutator<T>} part.
+   *                            This is guaranteed to be invoked exactly once and only after
+   *                            {@code registerSelf}.
+   */
+  public static <T> SerializingInPlaceMutator<T> assemble(
+      Consumer<SerializingInPlaceMutator<T>> registerSelf, Supplier<T> makeDefaultInstance,
+      Serializer<T> serializer, Supplier<InPlaceMutator<T>> lazyMutator) {
+    return new DelegatingSerializingInPlaceMutator<>(
+        registerSelf, makeDefaultInstance, serializer, lazyMutator);
+  }
+
+  private static class DelegatingSerializingInPlaceMutator<T> extends SerializingInPlaceMutator<T> {
+    private final Supplier<T> makeDefaultInstance;
+    private final Serializer<T> serializer;
+    private final InPlaceMutator<T> mutator;
+
+    private DelegatingSerializingInPlaceMutator(Consumer<SerializingInPlaceMutator<T>> registerSelf,
+        Supplier<T> makeDefaultInstance, Serializer<T> serializer,
+        Supplier<InPlaceMutator<T>> lazyMutator) {
+      requireNonNull(makeDefaultInstance);
+      requireNonNull(serializer);
+
+      registerSelf.accept(this);
+      this.makeDefaultInstance = makeDefaultInstance;
+      this.serializer = serializer;
+      this.mutator = lazyMutator.get();
+    }
+
+    @Override
+    public void initInPlace(T reference, PseudoRandom prng) {
+      mutator.initInPlace(reference, prng);
+    }
+
+    @Override
+    public void mutateInPlace(T reference, PseudoRandom prng) {
+      mutator.mutateInPlace(reference, prng);
+    }
+
+    @Override
+    public void crossOverInPlace(T reference, T otherReference, PseudoRandom prng) {
+      mutator.crossOverInPlace(reference, otherReference, prng);
+    }
+
+    @Override
+    protected T makeDefaultInstance() {
+      return makeDefaultInstance.get();
+    }
+
+    @Override
+    public T read(DataInputStream in) throws IOException {
+      return serializer.read(in);
+    }
+
+    @Override
+    public void write(T value, DataOutputStream out) throws IOException {
+      serializer.write(value, out);
+    }
+
+    @Override
+    public T readExclusive(InputStream in) throws IOException {
+      return serializer.readExclusive(in);
+    }
+
+    @Override
+    public void writeExclusive(T value, OutputStream out) throws IOException {
+      serializer.writeExclusive(value, out);
+    }
+
+    @Override
+    public T detach(T value) {
+      return serializer.detach(value);
+    }
+
+    @Override
+    public String toDebugString(Predicate<Debuggable> isInCycle) {
+      if (isInCycle.test(this)) {
+        return "(cycle)";
+      } else {
+        return mutator.toDebugString(isInCycle);
+      }
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/combinator/PostComposedMutator.java b/src/main/java/com/code_intelligence/jazzer/mutation/combinator/PostComposedMutator.java
new file mode 100644
index 0000000..ae8f97c
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/combinator/PostComposedMutator.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.combinator;
+
+import static java.util.Objects.requireNonNull;
+
+import com.code_intelligence.jazzer.mutation.api.Debuggable;
+import com.code_intelligence.jazzer.mutation.api.PseudoRandom;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import net.jodah.typetools.TypeResolver;
+
+abstract class PostComposedMutator<T, R> extends SerializingMutator<R> {
+  private final SerializingMutator<T> mutator;
+  private final Function<T, R> map;
+  private final Function<R, T> inverse;
+
+  PostComposedMutator(SerializingMutator<T> mutator, Function<T, R> map, Function<R, T> inverse) {
+    this.mutator = requireNonNull(mutator);
+    this.map = requireNonNull(map);
+    this.inverse = requireNonNull(inverse);
+  }
+
+  @Override
+  public R detach(R value) {
+    return map.apply(mutator.detach(inverse.apply(value)));
+  }
+
+  @Override
+  public final R init(PseudoRandom prng) {
+    return map.apply(mutator.init(prng));
+  }
+
+  @Override
+  public final R mutate(R value, PseudoRandom prng) {
+    return map.apply(mutator.mutate(inverse.apply(value), prng));
+  }
+
+  @Override
+  public R crossOver(R value, R otherValue, PseudoRandom prng) {
+    return map.apply(mutator.crossOver(inverse.apply(value), inverse.apply(otherValue), prng));
+  }
+
+  @Override
+  public final R read(DataInputStream in) throws IOException {
+    return map.apply(mutator.read(in));
+  }
+
+  @Override
+  public final void write(R value, DataOutputStream out) throws IOException {
+    mutator.write(inverse.apply(value), out);
+  }
+
+  @Override
+  public final R readExclusive(InputStream in) throws IOException {
+    return map.apply(mutator.readExclusive(in));
+  }
+
+  @Override
+  public final void writeExclusive(R value, OutputStream out) throws IOException {
+    mutator.writeExclusive(inverse.apply(value), out);
+  }
+
+  @Override
+  public String toDebugString(Predicate<Debuggable> isInCycle) {
+    Class<?> returnType = TypeResolver.resolveRawArguments(Function.class, map.getClass())[1];
+    return mutator.toDebugString(isInCycle) + " -> " + returnType.getSimpleName();
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/combinator/ProductMutator.java b/src/main/java/com/code_intelligence/jazzer/mutation/combinator/ProductMutator.java
new file mode 100644
index 0000000..9057fd3
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/combinator/ProductMutator.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.combinator;
+
+import static com.code_intelligence.jazzer.mutation.support.InputStreamSupport.extendWithZeros;
+import static com.code_intelligence.jazzer.mutation.support.Preconditions.require;
+import static com.code_intelligence.jazzer.mutation.support.Preconditions.requireNonNullElements;
+import static java.util.Arrays.stream;
+import static java.util.stream.Collectors.joining;
+
+import com.code_intelligence.jazzer.mutation.api.Debuggable;
+import com.code_intelligence.jazzer.mutation.api.PseudoRandom;
+import com.code_intelligence.jazzer.mutation.api.SerializingInPlaceMutator;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Arrays;
+import java.util.function.Predicate;
+
+@SuppressWarnings({"unchecked", "rawtypes"})
+public final class ProductMutator extends SerializingInPlaceMutator<Object[]> {
+  // Inverse frequency in which product type mutators should be used in cross over.
+  private final static int INVERSE_PICK_VALUE_SUPPLIER_FREQUENCY = 100;
+
+  private final SerializingMutator[] mutators;
+
+  ProductMutator(SerializingMutator[] mutators) {
+    requireNonNullElements(mutators);
+    require(mutators.length > 0, "mutators must not be empty");
+    this.mutators = Arrays.copyOf(mutators, mutators.length);
+  }
+
+  @Override
+  public Object[] read(DataInputStream in) throws IOException {
+    Object[] value = new Object[mutators.length];
+    for (int i = 0; i < mutators.length; i++) {
+      value[i] = mutators[i].read(in);
+    }
+    return value;
+  }
+
+  @Override
+  public Object[] readExclusive(InputStream in) throws IOException {
+    Object[] value = new Object[mutators.length];
+    int lastIndex = mutators.length - 1;
+    DataInputStream endlessData = new DataInputStream(extendWithZeros(in));
+    for (int i = 0; i < lastIndex; i++) {
+      value[i] = mutators[i].read(endlessData);
+    }
+    value[lastIndex] = mutators[lastIndex].readExclusive(in);
+    return value;
+  }
+
+  @Override
+  public void write(Object[] value, DataOutputStream out) throws IOException {
+    for (int i = 0; i < mutators.length; i++) {
+      mutators[i].write(value[i], out);
+    }
+  }
+
+  @Override
+  public void writeExclusive(Object[] value, OutputStream out) throws IOException {
+    DataOutputStream dataOut = new DataOutputStream(out);
+    int lastIndex = mutators.length - 1;
+    for (int i = 0; i < lastIndex; i++) {
+      mutators[i].write(value[i], dataOut);
+    }
+    mutators[lastIndex].writeExclusive(value[lastIndex], out);
+  }
+
+  @Override
+  protected Object[] makeDefaultInstance() {
+    return new Object[mutators.length];
+  }
+
+  @Override
+  public void initInPlace(Object[] reference, PseudoRandom prng) {
+    for (int i = 0; i < mutators.length; i++) {
+      reference[i] = mutators[i].init(prng);
+    }
+  }
+
+  @Override
+  public void mutateInPlace(Object[] reference, PseudoRandom prng) {
+    int i = prng.indexIn(mutators);
+    reference[i] = mutators[i].mutate(reference[i], prng);
+  }
+
+  @Override
+  public void crossOverInPlace(Object[] reference, Object[] otherReference, PseudoRandom prng) {
+    for (int i = 0; i < mutators.length; i++) {
+      SerializingMutator mutator = mutators[i];
+      Object value = reference[i];
+      Object otherValue = otherReference[i];
+      Object crossedOver = prng.pickValue(value, otherValue,
+          () -> mutator.crossOver(value, otherValue, prng), INVERSE_PICK_VALUE_SUPPLIER_FREQUENCY);
+      if (crossedOver == otherReference) {
+        // If otherReference was picked, it needs to be detached as mutating
+        // it is prohibited in cross over.
+        crossedOver = mutator.detach(crossedOver);
+      }
+      reference[i] = crossedOver;
+    }
+  }
+
+  @Override
+  public Object[] detach(Object[] value) {
+    Object[] clone = new Object[mutators.length];
+    for (int i = 0; i < mutators.length; i++) {
+      clone[i] = mutators[i].detach(value[i]);
+    }
+    return clone;
+  }
+
+  @Override
+  public String toDebugString(Predicate<Debuggable> isInCycle) {
+    return stream(mutators)
+        .map(mutator -> mutator.toDebugString(isInCycle))
+        .collect(joining(", ", "[", "]"));
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/engine/BUILD.bazel b/src/main/java/com/code_intelligence/jazzer/mutation/engine/BUILD.bazel
new file mode 100644
index 0000000..50bc180
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/engine/BUILD.bazel
@@ -0,0 +1,12 @@
+java_library(
+    name = "engine",
+    srcs = glob(["*.java"]),
+    visibility = [
+        "//src/main/java/com/code_intelligence/jazzer/mutation:__pkg__",
+        "//src/test/java/com/code_intelligence/jazzer/mutation:__subpackages__",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/mutation/api",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/support",
+    ],
+)
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/engine/SeededPseudoRandom.java b/src/main/java/com/code_intelligence/jazzer/mutation/engine/SeededPseudoRandom.java
new file mode 100644
index 0000000..515f345
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/engine/SeededPseudoRandom.java
@@ -0,0 +1,288 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.engine;
+
+import static com.code_intelligence.jazzer.mutation.support.Preconditions.require;
+
+import com.code_intelligence.jazzer.mutation.api.PseudoRandom;
+import com.code_intelligence.jazzer.mutation.support.Preconditions;
+import com.code_intelligence.jazzer.mutation.support.RandomSupport;
+import java.util.List;
+import java.util.SplittableRandom;
+import java.util.function.Supplier;
+
+public final class SeededPseudoRandom implements PseudoRandom {
+  // We use SplittableRandom instead of Random since it doesn't incur unnecessary synchronization
+  // overhead and uses a much better RNG under the hood that can generate all long values.
+  private final SplittableRandom random;
+
+  public SeededPseudoRandom(long seed) {
+    this.random = new SplittableRandom(seed);
+  }
+
+  @Override
+  public boolean choice() {
+    return random.nextBoolean();
+  }
+
+  @Override
+  public boolean trueInOneOutOf(int inverseFrequencyTrue) {
+    // Ensure that the outcome of the choice isn't fixed.
+    require(inverseFrequencyTrue >= 2);
+    return indexIn(inverseFrequencyTrue) == 0;
+  }
+
+  @Override
+  public <T> T pickIn(T[] array) {
+    return array[indexIn(array.length)];
+  }
+
+  @Override
+  public <T> T pickIn(List<T> list) {
+    return list.get(indexIn(list.size()));
+  }
+
+  @Override
+  public <T> int indexIn(T[] array) {
+    return indexIn(array.length);
+  }
+
+  @Override
+  public <T> int indexIn(List<T> list) {
+    return indexIn(list.size());
+  }
+
+  @Override
+  public int indexIn(int range) {
+    require(range >= 1);
+    // TODO: Replace random.nextInt(length) with the fast version of
+    //  https://lemire.me/blog/2016/06/30/fast-random-shuffling/, which avoids a modulo operation.
+    //  It's slightly more biased for large bounds, but indices and choices tend to be small and
+    //  are generated frequently (e.g. when picking a submutator).
+    return random.nextInt(range);
+  }
+
+  @Override
+  public <T> int otherIndexIn(T[] array, int currentIndex) {
+    return otherIndexIn(array.length, currentIndex);
+  }
+
+  @Override
+  public int otherIndexIn(int range, int currentIndex) {
+    int otherIndex = currentIndex + closedRange(1, range - 1);
+    if (otherIndex < range) {
+      return otherIndex;
+    } else {
+      return otherIndex - range;
+    }
+  }
+
+  @Override
+  public int closedRange(int lowerInclusive, int upperInclusive) {
+    require(lowerInclusive <= upperInclusive);
+    int range = upperInclusive - lowerInclusive + 1;
+    if (range > 0) {
+      return lowerInclusive + random.nextInt(range);
+    } else {
+      // The interval [lowerInclusive, upperInclusive] covers at least half of the
+      // [Integer.MIN_VALUE, Integer.MAX_VALUE] range, fall back to rejection sampling with an
+      // expected number of samples <= 2.
+      int r;
+      do {
+        r = random.nextInt();
+      } while (r < lowerInclusive);
+      return r;
+    }
+  }
+
+  @Override
+  public long closedRange(long lowerInclusive, long upperInclusive) {
+    require(lowerInclusive <= upperInclusive);
+    if (upperInclusive < Long.MAX_VALUE) {
+      // upperInclusive + 1 <= Long.MAX_VALUE
+      return random.nextLong(lowerInclusive, upperInclusive + 1);
+    } else if (lowerInclusive > 0) {
+      // upperInclusive + 1 - lowerInclusive <= Long.MAX_VALUE
+      return lowerInclusive + random.nextLong(upperInclusive + 1 - lowerInclusive);
+    } else {
+      // The interval [lowerInclusive, Long.MAX_VALUE] covers at least half of the
+      // [Long.MIN_VALUE, Long.MAX_VALUE] range, fall back to rejection sampling with an expected
+      // number of samples <= 2.
+      long r;
+      do {
+        r = random.nextLong();
+      } while (r < lowerInclusive);
+      return r;
+    }
+  }
+
+  // This function always returns a finite value
+  @Override
+  public float closedRange(float lowerInclusive, float upperInclusive) {
+    require(lowerInclusive <= upperInclusive);
+    if (lowerInclusive == upperInclusive) {
+      require(Double.isFinite(lowerInclusive));
+      return lowerInclusive;
+    }
+    // Special case: [Float.NEGATIVE_INFINITY, -Float.MAX_VALUE]
+    if (lowerInclusive == Float.NEGATIVE_INFINITY && upperInclusive == -Float.MAX_VALUE)
+      return -Float.MAX_VALUE;
+    // Special case: [Float.MAX_VALUE, Float.POSITIVE_INFINITY]
+    if (lowerInclusive == Float.MAX_VALUE && upperInclusive == Float.POSITIVE_INFINITY)
+      return Float.MAX_VALUE;
+    float limitedLower =
+        lowerInclusive == Float.NEGATIVE_INFINITY ? -Float.MAX_VALUE : lowerInclusive;
+    float limitedUpper =
+        upperInclusive == Float.POSITIVE_INFINITY ? Float.MAX_VALUE : upperInclusive;
+
+    // nextDouble(start, bound) is exclusive of bound, so we use Math.nextUp to extend the bound to
+    // the next representable double. The maximal possible range of a float is always finite when
+    // represented as a double. Therefore, we can safely use nextDouble and convert it to a float.
+    return (float) random.nextDouble((double) limitedLower, Math.nextUp((double) limitedUpper));
+  }
+
+  // This function always returns a finite value
+  @Override
+  public double closedRange(double lowerInclusive, double upperInclusive) {
+    require(lowerInclusive <= upperInclusive);
+    if (lowerInclusive == upperInclusive) {
+      require(Double.isFinite(lowerInclusive));
+      return lowerInclusive;
+    }
+    // Special case: [Double.NEGATIVE_INFINITY, -Double.MAX_VALUE]
+    if (lowerInclusive == Double.NEGATIVE_INFINITY && upperInclusive == -Double.MAX_VALUE)
+      return -Double.MAX_VALUE;
+    // Special case: [Double.MAX_VALUE, Double.POSITIVE_INFINITY)
+    if (lowerInclusive == Double.MAX_VALUE && upperInclusive == Double.POSITIVE_INFINITY)
+      return Double.MAX_VALUE;
+
+    // nextDouble(start, bound) cannot deal with infinite values, so we need to limit them
+    double limitedLower =
+        lowerInclusive == Double.NEGATIVE_INFINITY ? -Double.MAX_VALUE : lowerInclusive;
+    double limitedUpper =
+        upperInclusive == Double.POSITIVE_INFINITY ? Double.MAX_VALUE : upperInclusive;
+
+    // After limiting, the range may contain only a single value: return that
+    if (limitedLower == limitedUpper)
+      return limitedLower;
+
+    // random.nextDouble() is exclusive of the upper bound. To include the upper bound,
+    // we extend the bound to the next double value by using Math.nextUp(limitedUpper).
+    double nextUpper =
+        (limitedUpper == Double.MAX_VALUE) ? limitedUpper : Math.nextUp(limitedUpper);
+
+    // This, however, leads to a problem when the upper bound is Double.MAX_VALUE, because the next
+    // double after that is Double.POSITIVE_INFINITY. This case is treated the same as infinite
+    // range case, in the else branch.
+    boolean couldExtendRange = nextUpper != limitedUpper;
+
+    // nextDouble(start, bound) can only deal with finite ranges
+    if (Double.isFinite(nextUpper - limitedLower) && couldExtendRange) {
+      double result = random.nextDouble(limitedLower, nextUpper);
+      // Clamp random.nextDouble() to the upper bound.
+      // This is a workaround for RandomSupport.nextDouble() that causes it to
+      // return values greater than upper bound.
+      // See https://bugs.openjdk.org/browse/JDK-8281183 for a list of affected JDK versions.
+      if (result > limitedUpper)
+        result = limitedUpper;
+      return result;
+    } else {
+      // Ranges that exceeds the maximum representable double value, or ranges that could not be
+      // extended scale a random n from range [0; 1] onto the range [limitLower, limitUpper]
+      // limitedLower * (1 - n) + limitedUpper * n            - is the same as:
+      // limitedLower + (limitedUpper - limitedLower) * n
+      // limitedLower + range * n
+      double n = random.nextDouble(0.0, Math.nextUp(1.0));
+      return limitedLower * (1 - n) + limitedUpper * n;
+    }
+  }
+
+  @Override
+  public void bytes(byte[] bytes) {
+    RandomSupport.nextBytes(random, bytes);
+  }
+
+  @Override
+  public int closedRangeBiasedTowardsSmall(int upperInclusive) {
+    if (upperInclusive == 0) {
+      return 0;
+    }
+    Preconditions.require(upperInclusive > 0);
+    // Modified from (Apache-2.0)
+    // https://github.com/abseil/abseil-cpp/blob/2927340217c37328319b5869285a6dcdbc13e7a7/absl/random/zipf_distribution.h
+    // by inlining the values v = 1 and q = 2.
+    final double kd = upperInclusive;
+    final double hxm = zipf_h(kd + 0.5);
+    final double h0x5 = -1.0 / 1.5;
+    final double elogv_q = 1.0;
+    final double hx0_minus_hxm = (h0x5 - elogv_q) - hxm;
+    final double s = 0.46153846153846123;
+    double k;
+    while (true) {
+      final double v = random.nextDouble();
+      final double u = hxm + v * hx0_minus_hxm;
+      final double x = zipf_hinv(u);
+      k = Math.floor(x + 0.5);
+      if (k > kd) {
+        continue;
+      }
+      if (k - x <= s) {
+        break;
+      }
+      final double h = zipf_h(k + 0.5);
+      final double r = zipf_pow_negative_q(1.0 + k);
+      if (u >= h - r) {
+        break;
+      }
+    }
+    return (int) k;
+  }
+
+  @Override
+  public int closedRangeBiasedTowardsSmall(int lowerInclusive, int upperInclusive) {
+    return lowerInclusive + closedRangeBiasedTowardsSmall(upperInclusive - lowerInclusive);
+  }
+
+  private static double zipf_h(double x) {
+    return -1.0 / (x + 1.0);
+  }
+
+  private static double zipf_hinv(double x) {
+    return -1.0 + -1.0 / x;
+  }
+
+  private static double zipf_pow_negative_q(double x) {
+    return 1.0 / (x * x);
+  }
+
+  @Override
+  public <T> T pickValue(
+      T value, T otherValue, Supplier<T> supplier, int inverseSupplierFrequency) {
+    if (trueInOneOutOf(inverseSupplierFrequency)) {
+      return supplier.get();
+    } else if (choice()) {
+      return value;
+    } else {
+      return otherValue;
+    }
+  }
+
+  @Override
+  public long nextLong() {
+    return random.nextLong();
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/BUILD.bazel b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/BUILD.bazel
new file mode 100644
index 0000000..b922a86
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/BUILD.bazel
@@ -0,0 +1,14 @@
+java_library(
+    name = "mutator",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/api",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/annotation",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/api",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/mutator/collection",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/mutator/proto",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/support",
+    ],
+)
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/Mutators.java b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/Mutators.java
new file mode 100644
index 0000000..fcc4d7e
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/Mutators.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator;
+
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.visitAnnotatedType;
+import static java.lang.String.format;
+import static java.util.Arrays.stream;
+import static java.util.stream.Collectors.joining;
+
+import com.code_intelligence.jazzer.mutation.annotation.AppliesTo;
+import com.code_intelligence.jazzer.mutation.api.ChainedMutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.MutatorFactory;
+import com.code_intelligence.jazzer.mutation.mutator.collection.CollectionMutators;
+import com.code_intelligence.jazzer.mutation.mutator.lang.LangMutators;
+import com.code_intelligence.jazzer.mutation.mutator.proto.ProtoMutators;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.AnnotatedType;
+
+public final class Mutators {
+  private Mutators() {}
+
+  public static MutatorFactory newFactory() {
+    return new ChainedMutatorFactory(
+        LangMutators.newFactory(), CollectionMutators.newFactory(), ProtoMutators.newFactory());
+  }
+
+  /**
+   * Throws an exception if any annotation on {@code type} violates the restrictions of its
+   * {@link AppliesTo} meta-annotation.
+   */
+  public static void validateAnnotationUsage(AnnotatedType type) {
+    visitAnnotatedType(type, (clazz, annotations) -> {
+      outer:
+        for (Annotation annotation : annotations) {
+          AppliesTo appliesTo = annotation.annotationType().getAnnotation(AppliesTo.class);
+          if (appliesTo == null) {
+            continue;
+          }
+          for (Class<?> allowedClass : appliesTo.value()) {
+            if (allowedClass == clazz) {
+              continue outer;
+            }
+          }
+          for (Class<?> allowedSuperClass : appliesTo.subClassesOf()) {
+            if (allowedSuperClass.isAssignableFrom(clazz)) {
+              continue outer;
+            }
+          }
+
+          String helpText = "";
+          if (appliesTo.value().length != 0) {
+            helpText = stream(appliesTo.value()).map(Class::getName).collect(joining(", "));
+          }
+          if (appliesTo.subClassesOf().length != 0) {
+            if (!helpText.isEmpty()) {
+              helpText += "as well as ";
+            }
+            helpText += "subclasses of ";
+            helpText += stream(appliesTo.subClassesOf()).map(Class::getName).collect(joining(", "));
+          }
+          // Use the simple name as our annotations live in a single package.
+          throw new IllegalArgumentException(format("%s does not apply to %s, only applies to %s",
+              annotation.annotationType().getSimpleName(), clazz.getName(), helpText));
+        }
+    });
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/collection/BUILD.bazel b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/collection/BUILD.bazel
new file mode 100644
index 0000000..288b700
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/collection/BUILD.bazel
@@ -0,0 +1,13 @@
+java_library(
+    name = "collection",
+    srcs = glob(["*.java"]),
+    visibility = [
+        "//src/main/java/com/code_intelligence/jazzer/mutation/mutator:__pkg__",
+        "//src/test/java/com/code_intelligence/jazzer/mutation/mutator:__subpackages__",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/mutation/annotation",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/api",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/support",
+    ],
+)
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/collection/ChunkCrossOvers.java b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/collection/ChunkCrossOvers.java
new file mode 100644
index 0000000..c124f51
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/collection/ChunkCrossOvers.java
@@ -0,0 +1,226 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.collection;
+
+import com.code_intelligence.jazzer.mutation.api.PseudoRandom;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+final class ChunkCrossOvers {
+  private ChunkCrossOvers() {}
+
+  static <T> void insertChunk(List<T> list, List<T> otherList, int maxSize, PseudoRandom prng) {
+    int maxChunkSize = Math.min(maxSize - list.size(), Math.min(list.size(), otherList.size()));
+    withChunk(list, otherList, maxChunkSize, prng,
+        (fromPos, toPos, chunk) -> { list.addAll(toPos, chunk); });
+  }
+
+  static <T> void overwriteChunk(List<T> list, List<T> otherList, PseudoRandom prng) {
+    int maxChunkSize = Math.min(list.size(), otherList.size());
+    withChunkElements(list, otherList, maxChunkSize, prng, list::set);
+  }
+
+  static <T> void crossOverChunk(
+      List<T> list, List<T> otherList, SerializingMutator<T> elementMutator, PseudoRandom prng) {
+    int maxChunkSize = Math.min(list.size(), otherList.size());
+    withChunkElements(list, otherList, maxChunkSize, prng, (toPos, element) -> {
+      list.set(toPos, elementMutator.crossOver(list.get(toPos), element, prng));
+    });
+  }
+
+  @FunctionalInterface
+  private interface ChunkListOperation<T> {
+    void apply(int fromPos, int toPos, List<T> chunk);
+  }
+
+  @FunctionalInterface
+  private interface ChunkListElementOperation<T> {
+    void apply(int toPos, T chunk);
+  }
+
+  static private <T> void withChunk(List<T> list, List<T> otherList, int maxChunkSize,
+      PseudoRandom prng, ChunkListOperation<T> operation) {
+    if (maxChunkSize == 0) {
+      return;
+    }
+    int chunkSize = prng.closedRangeBiasedTowardsSmall(1, maxChunkSize);
+    int fromPos = prng.closedRange(0, otherList.size() - chunkSize);
+    int toPos = prng.closedRange(0, list.size() - chunkSize);
+    List<T> chunk = otherList.subList(fromPos, fromPos + chunkSize);
+    operation.apply(fromPos, toPos, chunk);
+  }
+
+  static private <T> void withChunkElements(List<T> list, List<T> otherList, int maxChunkSize,
+      PseudoRandom prng, ChunkListElementOperation<T> operation) {
+    withChunk(list, otherList, maxChunkSize, prng, (fromPos, toPos, chunk) -> {
+      for (int i = 0; i < chunk.size(); i++) {
+        operation.apply(toPos + i, chunk.get(i));
+      }
+    });
+  }
+
+  static <K, V> void insertChunk(
+      Map<K, V> map, Map<K, V> otherMap, int maxSize, PseudoRandom prng) {
+    int originalSize = map.size();
+    int maxChunkSize = Math.min(maxSize - originalSize, otherMap.size());
+    withChunk(map, otherMap, maxChunkSize, prng, (fromIterator, toIterator, chunkSize) -> {
+      // insertChunk only inserts new entries and does not overwrite existing
+      // ones. As skipping those entries would lead to fewer insertions than
+      // requested, loop over the rest of the map to fill the chunk.
+      while (map.size() < originalSize + chunkSize && fromIterator.hasNext()) {
+        Entry<K, V> entry = fromIterator.next();
+        if (!map.containsKey(entry.getKey())) {
+          map.put(entry.getKey(), entry.getValue());
+        }
+      }
+    });
+  }
+
+  static <K, V> void overwriteChunk(Map<K, V> map, Map<K, V> otherMap, PseudoRandom prng) {
+    int maxChunkSize = Math.min(map.size(), otherMap.size());
+    withChunk(map, otherMap, maxChunkSize, prng, (fromIterator, toIterator, chunkSize) -> {
+      // As keys can not be overwritten, only removed and new ones added, this
+      // cross over overwrites the values. Removal of keys is handled by the
+      // removeChunk mutation. Value equality is not checked here.
+      for (int i = 0; i < chunkSize; i++) {
+        Entry<K, V> from = fromIterator.next();
+        Entry<K, V> to = toIterator.next();
+        to.setValue(from.getValue());
+      }
+    });
+  }
+
+  static <K, V> void crossOverChunk(Map<K, V> map, Map<K, V> otherMap,
+      SerializingMutator<K> keyMutator, SerializingMutator<V> valueMutator, PseudoRandom prng) {
+    if (prng.choice()) {
+      crossOverChunkKeys(map, otherMap, keyMutator, prng);
+    } else {
+      crossOverChunkValues(map, otherMap, valueMutator, prng);
+    }
+  }
+
+  private static <K, V> void crossOverChunkKeys(
+      Map<K, V> map, Map<K, V> otherMap, SerializingMutator<K> keyMutator, PseudoRandom prng) {
+    int maxChunkSize = Math.min(map.size(), otherMap.size());
+    withChunk(map, otherMap, maxChunkSize, prng, (fromIterator, toIterator, chunkSize) -> {
+      Map<K, V> entriesToAdd = new LinkedHashMap<>(chunkSize);
+      for (int i = 0; i < chunkSize; i++) {
+        Entry<K, V> to = toIterator.next();
+        Entry<K, V> from = fromIterator.next();
+
+        // The entry has to be removed from the map before the cross-over, as
+        // mutating its key could cause problems in subsequent lookups.
+        // Furthermore, no new entries may be added while using the iterator,
+        // so crossed-over keys are collected for later addition.
+        K key = to.getKey();
+        V value = to.getValue();
+        toIterator.remove();
+
+        // As cross-overs do not guarantee to mutate the given object, no
+        // checks if the crossed over key already exists in the map are
+        // performed. This potentially overwrites existing entries or
+        // generates equal keys.
+        // In case of cross over this behavior is acceptable.
+        K newKey = keyMutator.crossOver(key, from.getKey(), prng);
+
+        // Prevent null keys, as those are not allowed in some map implementations.
+        if (newKey != null) {
+          entriesToAdd.put(newKey, value);
+        }
+      }
+      map.putAll(entriesToAdd);
+    });
+  }
+
+  private static <K, V> void crossOverChunkValues(
+      Map<K, V> map, Map<K, V> otherMap, SerializingMutator<V> valueMutator, PseudoRandom prng) {
+    int maxChunkSize = Math.min(map.size(), otherMap.size());
+    withChunkElements(map, otherMap, maxChunkSize, prng, (fromEntry, toEntry) -> {
+      // As cross-overs do not guarantee to mutate the given object, no
+      // checks if a new value is produced are performed.
+      V newValue = valueMutator.crossOver(toEntry.getValue(), fromEntry.getValue(), prng);
+
+      // The cross-over could have already mutated value, but explicitly set it
+      // through the iterator to be sure.
+      toEntry.setValue(newValue);
+    });
+  }
+
+  @FunctionalInterface
+  private interface ChunkMapOperation<K, V> {
+    void apply(Iterator<Entry<K, V>> fromIterator, Iterator<Entry<K, V>> toIterator, int chunkSize);
+  }
+
+  @FunctionalInterface
+  private interface ChunkMapElementOperation<K, V> {
+    void apply(Entry<K, V> fromEntry, Entry<K, V> toEntry);
+  }
+
+  static <K, V> void withChunk(Map<K, V> map, Map<K, V> otherMap, int maxChunkSize,
+      PseudoRandom prng, ChunkMapOperation<K, V> operation) {
+    int chunkSize = prng.closedRangeBiasedTowardsSmall(1, maxChunkSize);
+    int fromChunkOffset = prng.closedRange(0, otherMap.size() - chunkSize);
+    int toChunkOffset = prng.closedRange(0, map.size() - chunkSize);
+    Iterator<Entry<K, V>> fromIterator = otherMap.entrySet().iterator();
+    for (int i = 0; i < fromChunkOffset; i++) {
+      fromIterator.next();
+    }
+    Iterator<Entry<K, V>> toIterator = map.entrySet().iterator();
+    for (int i = 0; i < toChunkOffset; i++) {
+      toIterator.next();
+    }
+    operation.apply(fromIterator, toIterator, chunkSize);
+  }
+
+  static <K, V> void withChunkElements(Map<K, V> map, Map<K, V> otherMap, int maxChunkSize,
+      PseudoRandom prng, ChunkMapElementOperation<K, V> operation) {
+    withChunk(map, otherMap, maxChunkSize, prng, (fromIterator, toIterator, chunkSize) -> {
+      for (int i = 0; i < chunkSize; i++) {
+        operation.apply(fromIterator.next(), toIterator.next());
+      }
+    });
+  }
+
+  public enum CrossOverAction {
+    INSERT_CHUNK,
+    OVERWRITE_CHUNK,
+    CROSS_OVER_CHUNK,
+    NOOP;
+
+    public static CrossOverAction pickRandomCrossOverAction(
+        Collection<?> reference, Collection<?> otherReference, int maxSize, PseudoRandom prng) {
+      List<CrossOverAction> actions = new ArrayList<>();
+      if (reference.size() < maxSize && !otherReference.isEmpty()) {
+        actions.add(INSERT_CHUNK);
+      }
+      if (!reference.isEmpty() && !otherReference.isEmpty()) {
+        actions.add(OVERWRITE_CHUNK);
+        actions.add(CROSS_OVER_CHUNK);
+      }
+      if (actions.isEmpty()) {
+        return NOOP; // prevent NPE
+      }
+      return prng.pickIn(actions);
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/collection/ChunkMutations.java b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/collection/ChunkMutations.java
new file mode 100644
index 0000000..d526028
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/collection/ChunkMutations.java
@@ -0,0 +1,243 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.collection;
+
+import com.code_intelligence.jazzer.mutation.api.PseudoRandom;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.code_intelligence.jazzer.mutation.api.ValueMutator;
+import com.code_intelligence.jazzer.mutation.support.Preconditions;
+import java.util.AbstractList;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+
+// Based on (Apache-2.0)
+// https://github.com/google/fuzztest/blob/f81257ed70ec7b9c191b633588cb6e39c42da5e4/fuzztest/internal/domains/container_mutation_helpers.h
+@SuppressWarnings("unchecked")
+final class ChunkMutations {
+  private static final int MAX_FAILED_INSERTION_ATTEMPTS = 100;
+
+  private ChunkMutations() {}
+
+  static <T> void deleteRandomChunk(List<T> list, int minSize, PseudoRandom prng) {
+    int oldSize = list.size();
+    int minFinalSize = Math.max(minSize, oldSize / 2);
+    int chunkSize = prng.closedRangeBiasedTowardsSmall(1, oldSize - minFinalSize);
+    int chunkOffset = prng.closedRange(0, oldSize - chunkSize);
+
+    list.subList(chunkOffset, chunkOffset + chunkSize).clear();
+  }
+
+  static <T> void deleteRandomChunk(Collection<T> collection, int minSize, PseudoRandom prng) {
+    int oldSize = collection.size();
+    int minFinalSize = Math.max(minSize, oldSize / 2);
+    int chunkSize = prng.closedRangeBiasedTowardsSmall(1, oldSize - minFinalSize);
+    int chunkOffset = prng.closedRange(0, oldSize - chunkSize);
+
+    Iterator<T> it = collection.iterator();
+    for (int i = 0; i < chunkOffset; i++) {
+      it.next();
+    }
+    for (int i = chunkOffset; i < chunkOffset + chunkSize; i++) {
+      it.next();
+      it.remove();
+    }
+  }
+
+  static <T> void insertRandomChunk(
+      List<T> list, int maxSize, SerializingMutator<T> elementMutator, PseudoRandom prng) {
+    int oldSize = list.size();
+    int chunkSize = prng.closedRangeBiasedTowardsSmall(1, maxSize - oldSize);
+    int chunkOffset = prng.closedRange(0, oldSize);
+
+    T baseElement = elementMutator.init(prng);
+    T[] chunk = (T[]) new Object[chunkSize];
+    for (int i = 0; i < chunk.length; i++) {
+      chunk[i] = elementMutator.detach(baseElement);
+    }
+    // ArrayList#addAll relies on Collection#toArray, but Arrays#asList returns a List whose
+    // toArray() always makes a copy. We avoid this by using a custom list implementation.
+    list.addAll(chunkOffset, new ArraySharingList<>(chunk));
+  }
+
+  static <T> boolean insertRandomChunk(Set<T> set, Consumer<T> addIfNew, int maxSize,
+      ValueMutator<T> elementMutator, PseudoRandom prng) {
+    int oldSize = set.size();
+    int chunkSize = prng.closedRangeBiasedTowardsSmall(1, maxSize - oldSize);
+    return growBy(set, addIfNew, chunkSize, () -> elementMutator.init(prng));
+  }
+
+  static <T> void mutateRandomChunk(List<T> list, ValueMutator<T> mutator, PseudoRandom prng) {
+    int size = list.size();
+    int chunkSize = prng.closedRangeBiasedTowardsSmall(1, size);
+    int chunkOffset = prng.closedRange(0, size - chunkSize);
+
+    for (int i = chunkOffset; i < chunkOffset + chunkSize; i++) {
+      list.set(i, mutator.mutate(list.get(i), prng));
+    }
+  }
+
+  static <K, V, KW, VW> boolean mutateRandomKeysChunk(
+      Map<K, V> map, SerializingMutator<K> keyMutator, PseudoRandom prng) {
+    int originalSize = map.size();
+    int chunkSize = prng.closedRangeBiasedTowardsSmall(1, originalSize);
+    int chunkOffset = prng.closedRange(0, originalSize - chunkSize);
+
+    // To ensure that mutating keys actually results in the set of keys changing and not just their
+    // values (which is what #mutateRandomValuesChunk is for), we keep the keys to mutate in the
+    // map, try to add new keys (that are therefore distinct from the keys to mutate) and only
+    // remove the successfully mutated keys in the end.
+    ArrayDeque<KW> keysToMutate = new ArrayDeque<>(chunkSize);
+    ArrayDeque<VW> values = new ArrayDeque<>(chunkSize);
+    ArrayList<K> keysToRemove = new ArrayList<>(chunkSize);
+    Iterator<Map.Entry<K, V>> it = map.entrySet().iterator();
+    for (int i = 0; i < chunkOffset; i++) {
+      it.next();
+    }
+    for (int i = chunkOffset; i < chunkOffset + chunkSize; i++) {
+      Map.Entry<K, V> entry = it.next();
+      // ArrayDeque cannot hold null elements, which requires us to replace null with a sentinel.
+      // Also detach the key as keys may be mutable and mutation could destroy them.
+      keysToMutate.add(boxNull(keyMutator.detach(entry.getKey())));
+      values.add(boxNull(entry.getValue()));
+      keysToRemove.add(entry.getKey());
+    }
+
+    Consumer<K> addIfNew = key -> {
+      int sizeBeforeAdd = map.size();
+      map.putIfAbsent(key, unboxNull(values.peekFirst()));
+      // The mutated key was new, try to mutate and add the next in line.
+      if (map.size() > sizeBeforeAdd) {
+        keysToMutate.removeFirst();
+        values.removeFirst();
+      }
+    };
+    Supplier<K> nextCandidate = () -> {
+      // Mutate the next candidate in the queue.
+      K candidate = keyMutator.mutate(unboxNull(keysToMutate.removeFirst()), prng);
+      keysToMutate.addFirst(boxNull(candidate));
+      return candidate;
+    };
+
+    growBy(map.keySet(), addIfNew, chunkSize, nextCandidate);
+    // Remove the original keys that were successfully mutated into new keys. Since the original
+    // keys have been kept in the map up to this point, all keys added were successfully mutated to
+    // be unequal to the original keys.
+    int grownBy = map.size() - originalSize;
+    keysToRemove.stream().limit(grownBy).forEach(map::remove);
+    return grownBy > 0;
+  }
+
+  public static <K, V> void mutateRandomValuesChunk(
+      Map<K, V> map, ValueMutator<V> valueMutator, PseudoRandom prng) {
+    Collection<Map.Entry<K, V>> collection = map.entrySet();
+    int oldSize = collection.size();
+    int chunkSize = prng.closedRangeBiasedTowardsSmall(1, oldSize);
+    int chunkOffset = prng.closedRange(0, oldSize - chunkSize);
+
+    Iterator<Map.Entry<K, V>> it = collection.iterator();
+    for (int i = 0; i < chunkOffset; i++) {
+      it.next();
+    }
+    for (int i = chunkOffset; i < chunkOffset + chunkSize; i++) {
+      Entry<K, V> entry = it.next();
+      entry.setValue(valueMutator.mutate(entry.getValue(), prng));
+    }
+  }
+
+  static <T> boolean growBy(
+      Set<T> set, Consumer<T> addIfNew, int delta, Supplier<T> candidateSupplier) {
+    int oldSize = set.size();
+    Preconditions.require(delta >= 0);
+
+    final int targetSize = oldSize + delta;
+    int remainingAttempts = MAX_FAILED_INSERTION_ATTEMPTS;
+    int currentSize = set.size();
+    while (currentSize < targetSize) {
+      // If addIfNew fails, the size of set will not increase.
+      addIfNew.accept(candidateSupplier.get());
+      int newSize = set.size();
+      if (newSize == currentSize && remainingAttempts-- == 0) {
+        return false;
+      } else {
+        currentSize = newSize;
+      }
+    }
+    return true;
+  }
+
+  private static final Object BOXED_NULL = new Object();
+
+  private static <T, TW> TW boxNull(T object) {
+    return object != null ? (TW) object : (TW) BOXED_NULL;
+  }
+
+  private static <T, TW> T unboxNull(TW object) {
+    return object != BOXED_NULL ? (T) object : null;
+  }
+
+  public enum MutationAction {
+    DELETE_CHUNK,
+    INSERT_CHUNK,
+    MUTATE_CHUNK;
+
+    public static MutationAction pickRandomMutationAction(
+        Collection<?> c, int minSize, int maxSize, PseudoRandom prng) {
+      List<MutationAction> actions = new ArrayList<>();
+      if (c.size() > minSize) {
+        actions.add(DELETE_CHUNK);
+      }
+      if (c.size() < maxSize) {
+        actions.add(INSERT_CHUNK);
+      }
+      if (!c.isEmpty()) {
+        actions.add(MUTATE_CHUNK);
+      }
+      return prng.pickIn(actions);
+    }
+  }
+
+  private static final class ArraySharingList<T> extends AbstractList<T> {
+    private final T[] array;
+
+    ArraySharingList(T[] array) {
+      this.array = array;
+    }
+
+    @Override
+    public T get(int i) {
+      return array[i];
+    }
+
+    @Override
+    public int size() {
+      return array.length;
+    }
+
+    @Override
+    public Object[] toArray() {
+      return array;
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/collection/CollectionMutators.java b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/collection/CollectionMutators.java
new file mode 100644
index 0000000..cf819f1
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/collection/CollectionMutators.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.collection;
+
+import com.code_intelligence.jazzer.mutation.api.ChainedMutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.MutatorFactory;
+
+public final class CollectionMutators {
+  private CollectionMutators() {}
+
+  public static MutatorFactory newFactory() {
+    return new ChainedMutatorFactory(new ListMutatorFactory(), new MapMutatorFactory());
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/collection/ListMutatorFactory.java b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/collection/ListMutatorFactory.java
new file mode 100644
index 0000000..86ecbd4
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/collection/ListMutatorFactory.java
@@ -0,0 +1,167 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.collection;
+
+import static com.code_intelligence.jazzer.mutation.mutator.collection.ChunkCrossOvers.CrossOverAction.pickRandomCrossOverAction;
+import static com.code_intelligence.jazzer.mutation.mutator.collection.ChunkCrossOvers.crossOverChunk;
+import static com.code_intelligence.jazzer.mutation.mutator.collection.ChunkCrossOvers.insertChunk;
+import static com.code_intelligence.jazzer.mutation.mutator.collection.ChunkCrossOvers.overwriteChunk;
+import static com.code_intelligence.jazzer.mutation.mutator.collection.ChunkMutations.MutationAction.pickRandomMutationAction;
+import static com.code_intelligence.jazzer.mutation.mutator.collection.ChunkMutations.deleteRandomChunk;
+import static com.code_intelligence.jazzer.mutation.mutator.collection.ChunkMutations.insertRandomChunk;
+import static com.code_intelligence.jazzer.mutation.mutator.collection.ChunkMutations.mutateRandomChunk;
+import static com.code_intelligence.jazzer.mutation.support.Preconditions.require;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.parameterTypeIfParameterized;
+import static java.lang.Math.min;
+import static java.lang.String.format;
+
+import com.code_intelligence.jazzer.mutation.annotation.WithSize;
+import com.code_intelligence.jazzer.mutation.api.Debuggable;
+import com.code_intelligence.jazzer.mutation.api.MutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.PseudoRandom;
+import com.code_intelligence.jazzer.mutation.api.SerializingInPlaceMutator;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.code_intelligence.jazzer.mutation.support.RandomSupport;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.lang.reflect.AnnotatedType;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+
+final class ListMutatorFactory extends MutatorFactory {
+  @Override
+  public Optional<SerializingMutator<?>> tryCreate(AnnotatedType type, MutatorFactory factory) {
+    Optional<WithSize> withSize = Optional.ofNullable(type.getAnnotation(WithSize.class));
+    int minSize = withSize.map(WithSize::min).orElse(ListMutator.DEFAULT_MIN_SIZE);
+    int maxSize = withSize.map(WithSize::max).orElse(ListMutator.DEFAULT_MAX_SIZE);
+    return parameterTypeIfParameterized(type, List.class)
+        .flatMap(factory::tryCreate)
+        .map(elementMutator -> new ListMutator<>(elementMutator, minSize, maxSize));
+  }
+
+  private static final class ListMutator<T> extends SerializingInPlaceMutator<List<T>> {
+    private static final int DEFAULT_MIN_SIZE = 0;
+    private static final int DEFAULT_MAX_SIZE = 1000;
+
+    private final SerializingMutator<T> elementMutator;
+    private final int minSize;
+    private final int maxSize;
+
+    ListMutator(SerializingMutator<T> elementMutator, int minSize, int maxSize) {
+      this.elementMutator = elementMutator;
+      this.minSize = minSize;
+      this.maxSize = maxSize;
+      require(maxSize >= 1, format("WithSize#max=%d needs to be greater than 0", maxSize));
+      require(minSize >= 0, format("WithSize#min=%d needs to be positive", minSize));
+      require(minSize <= maxSize,
+          format("WithSize#min=%d needs to be smaller or equal than WithSize#max=%d", minSize,
+              maxSize));
+    }
+
+    @Override
+    public List<T> read(DataInputStream in) throws IOException {
+      int size = RandomSupport.clamp(in.readInt(), minSize, maxSize);
+      ArrayList<T> list = new ArrayList<>(size);
+      for (int i = 0; i < size; i++) {
+        list.add(elementMutator.read(in));
+      }
+      return list;
+    }
+
+    @Override
+    public void write(List<T> list, DataOutputStream out) throws IOException {
+      out.writeInt(list.size());
+      for (T element : list) {
+        elementMutator.write(element, out);
+      }
+    }
+
+    @Override
+    protected List<T> makeDefaultInstance() {
+      return new ArrayList<>(maxInitialSize());
+    }
+
+    @Override
+    public void initInPlace(List<T> list, PseudoRandom prng) {
+      int targetSize = prng.closedRange(minInitialSize(), maxInitialSize());
+      list.clear();
+      for (int i = 0; i < targetSize; i++) {
+        list.add(elementMutator.init(prng));
+      }
+    }
+
+    @Override
+    public void mutateInPlace(List<T> list, PseudoRandom prng) {
+      switch (pickRandomMutationAction(list, minSize, maxSize, prng)) {
+        case DELETE_CHUNK:
+          deleteRandomChunk(list, minSize, prng);
+          break;
+        case INSERT_CHUNK:
+          insertRandomChunk(list, maxSize, elementMutator, prng);
+          break;
+        case MUTATE_CHUNK:
+          mutateRandomChunk(list, elementMutator, prng);
+          break;
+        default:
+          throw new IllegalStateException("unsupported action");
+      }
+    }
+
+    @Override
+    public void crossOverInPlace(List<T> reference, List<T> otherReference, PseudoRandom prng) {
+      // These cross-over functions don't remove entries, that is handled by
+      // the appropriate mutations on the result.
+      switch (pickRandomCrossOverAction(reference, otherReference, maxSize, prng)) {
+        case INSERT_CHUNK:
+          insertChunk(reference, otherReference, maxSize, prng);
+          break;
+        case OVERWRITE_CHUNK:
+          overwriteChunk(reference, otherReference, prng);
+          break;
+        case CROSS_OVER_CHUNK:
+          crossOverChunk(reference, otherReference, elementMutator, prng);
+          break;
+        default:
+          // Both lists are empty or could otherwise not be crossed over.
+      }
+    }
+
+    @Override
+    public List<T> detach(List<T> value) {
+      return value.stream()
+          .map(elementMutator::detach)
+          .collect(Collectors.toCollection(() -> new ArrayList<>(value.size())));
+    }
+
+    @Override
+    public String toDebugString(Predicate<Debuggable> isInCycle) {
+      return "List<" + elementMutator.toDebugString(isInCycle) + ">";
+    }
+
+    private int minInitialSize() {
+      return minSize;
+    }
+
+    private int maxInitialSize() {
+      return min(maxSize, minSize + 1);
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/collection/MapMutatorFactory.java b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/collection/MapMutatorFactory.java
new file mode 100644
index 0000000..fca8b5c
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/collection/MapMutatorFactory.java
@@ -0,0 +1,205 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.collection;
+
+import static com.code_intelligence.jazzer.mutation.mutator.collection.ChunkCrossOvers.CrossOverAction.pickRandomCrossOverAction;
+import static com.code_intelligence.jazzer.mutation.mutator.collection.ChunkCrossOvers.crossOverChunk;
+import static com.code_intelligence.jazzer.mutation.mutator.collection.ChunkCrossOvers.insertChunk;
+import static com.code_intelligence.jazzer.mutation.mutator.collection.ChunkCrossOvers.overwriteChunk;
+import static com.code_intelligence.jazzer.mutation.mutator.collection.ChunkMutations.MutationAction.pickRandomMutationAction;
+import static com.code_intelligence.jazzer.mutation.mutator.collection.ChunkMutations.deleteRandomChunk;
+import static com.code_intelligence.jazzer.mutation.mutator.collection.ChunkMutations.growBy;
+import static com.code_intelligence.jazzer.mutation.mutator.collection.ChunkMutations.insertRandomChunk;
+import static com.code_intelligence.jazzer.mutation.mutator.collection.ChunkMutations.mutateRandomKeysChunk;
+import static com.code_intelligence.jazzer.mutation.mutator.collection.ChunkMutations.mutateRandomValuesChunk;
+import static com.code_intelligence.jazzer.mutation.support.Preconditions.check;
+import static com.code_intelligence.jazzer.mutation.support.Preconditions.require;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.parameterTypesIfParameterized;
+import static java.lang.Math.min;
+import static java.lang.String.format;
+import static java.util.stream.Collectors.toMap;
+
+import com.code_intelligence.jazzer.mutation.annotation.WithSize;
+import com.code_intelligence.jazzer.mutation.api.Debuggable;
+import com.code_intelligence.jazzer.mutation.api.MutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.PseudoRandom;
+import com.code_intelligence.jazzer.mutation.api.SerializingInPlaceMutator;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.code_intelligence.jazzer.mutation.support.RandomSupport;
+import com.code_intelligence.jazzer.mutation.support.StreamSupport;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.AnnotatedType;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+
+final class MapMutatorFactory extends MutatorFactory {
+  @Override
+  public Optional<SerializingMutator<?>> tryCreate(AnnotatedType type, MutatorFactory factory) {
+    return parameterTypesIfParameterized(type, Map.class)
+        .map(parameterTypes
+            -> parameterTypes.stream()
+                   .map(factory::tryCreate)
+                   .flatMap(StreamSupport::getOrEmpty)
+                   .collect(Collectors.toList()))
+        .map(elementMutators -> {
+          check(elementMutators.size() == 2);
+          int min = MapMutator.DEFAULT_MIN_SIZE;
+          int max = MapMutator.DEFAULT_MAX_SIZE;
+          for (Annotation annotation : type.getDeclaredAnnotations()) {
+            if (annotation instanceof WithSize) {
+              WithSize withSize = (WithSize) annotation;
+              min = withSize.min();
+              max = withSize.max();
+            }
+          }
+          return new MapMutator<>(elementMutators.get(0), elementMutators.get(1), min, max);
+        });
+  }
+
+  private static final class MapMutator<K, V> extends SerializingInPlaceMutator<Map<K, V>> {
+    private static final int DEFAULT_MIN_SIZE = 0;
+    private static final int DEFAULT_MAX_SIZE = 1000;
+
+    private final SerializingMutator<K> keyMutator;
+    private final SerializingMutator<V> valueMutator;
+    private final int minSize;
+    private final int maxSize;
+
+    MapMutator(SerializingMutator<K> keyMutator, SerializingMutator<V> valueMutator, int minSize,
+        int maxSize) {
+      this.keyMutator = keyMutator;
+      this.valueMutator = valueMutator;
+      this.minSize = Math.max(minSize, DEFAULT_MIN_SIZE);
+      this.maxSize = Math.min(maxSize, DEFAULT_MAX_SIZE);
+
+      require(maxSize >= 1, format("WithSize#max=%d needs to be greater than 0", maxSize));
+      // TODO: Add support for min > 0 to map. If min > 0, then #read can fail to construct
+      //       sufficiently many distinct keys, but the mutation framework currently doesn't offer
+      //       a way to handle this situation gracefully. It is also not clear what behavior users
+      //       could reasonably expect in this situation in both regression test and fuzzing mode.
+      require(minSize == 0, "@WithSize#min != 0 is not yet supported for Map");
+    }
+
+    @Override
+    public Map<K, V> read(DataInputStream in) throws IOException {
+      int size = RandomSupport.clamp(in.readInt(), minSize, maxSize);
+      Map<K, V> map = new LinkedHashMap<>(size);
+      for (int i = 0; i < size; i++) {
+        map.put(keyMutator.read(in), valueMutator.read(in));
+      }
+      // map may have less than size entries due to the potential for duplicates, but this is fine
+      // as we currently assert that minSize == 0.
+      return map;
+    }
+
+    @Override
+    public void write(Map<K, V> map, DataOutputStream out) throws IOException {
+      out.writeInt(map.size());
+      for (Map.Entry<K, V> entry : map.entrySet()) {
+        keyMutator.write(entry.getKey(), out);
+        valueMutator.write(entry.getValue(), out);
+      }
+    }
+
+    @Override
+    protected Map<K, V> makeDefaultInstance() {
+      // Use a LinkedHashMap to ensure deterministic iteration order, which makes chunk-based
+      // mutations deterministic. The additional overhead compared to HashMap should be minimal.
+      return new LinkedHashMap<>(maxInitialSize());
+    }
+
+    @Override
+    public void initInPlace(Map<K, V> map, PseudoRandom prng) {
+      int targetSize = prng.closedRange(minInitialSize(), maxInitialSize());
+      map.clear();
+      growBy(map.keySet(),
+          key
+          -> map.putIfAbsent(key, valueMutator.init(prng)),
+          targetSize, () -> keyMutator.init(prng));
+      if (map.size() < minSize) {
+        throw new IllegalStateException(String.format(
+            "Failed to create %d distinct elements of type %s to satisfy the @WithSize#minSize constraint on Map",
+            minSize, keyMutator));
+      }
+    }
+
+    @Override
+    public void mutateInPlace(Map<K, V> map, PseudoRandom prng) {
+      switch (pickRandomMutationAction(map.keySet(), minSize, maxSize, prng)) {
+        case DELETE_CHUNK:
+          deleteRandomChunk(map.keySet(), minSize, prng);
+          break;
+        case INSERT_CHUNK:
+          insertRandomChunk(map.keySet(),
+              key -> map.putIfAbsent(key, valueMutator.init(prng)), maxSize, keyMutator, prng);
+          break;
+        case MUTATE_CHUNK:
+          if (prng.choice() || !mutateRandomKeysChunk(map, keyMutator, prng)) {
+            mutateRandomValuesChunk(map, valueMutator, prng);
+          }
+          break;
+        default:
+          throw new IllegalStateException("unsupported action");
+      }
+    }
+
+    @Override
+    public void crossOverInPlace(Map<K, V> reference, Map<K, V> otherReference, PseudoRandom prng) {
+      switch (
+          pickRandomCrossOverAction(reference.keySet(), otherReference.keySet(), maxSize, prng)) {
+        case INSERT_CHUNK:
+          insertChunk(reference, otherReference, maxSize, prng);
+          break;
+        case OVERWRITE_CHUNK:
+          overwriteChunk(reference, otherReference, prng);
+          break;
+        case CROSS_OVER_CHUNK:
+          crossOverChunk(reference, otherReference, keyMutator, valueMutator, prng);
+          break;
+        default:
+          // Both maps are empty or could otherwise not be crossed over.
+      }
+    }
+
+    @Override
+    public Map<K, V> detach(Map<K, V> value) {
+      return value.entrySet().stream().collect(toMap(entry
+          -> keyMutator.detach(entry.getKey()),
+          entry -> valueMutator.detach(entry.getValue())));
+    }
+
+    @Override
+    public String toDebugString(Predicate<Debuggable> isInCycle) {
+      return "Map<" + keyMutator.toDebugString(isInCycle) + ","
+          + valueMutator.toDebugString(isInCycle) + ">";
+    }
+
+    private int minInitialSize() {
+      return minSize;
+    }
+
+    private int maxInitialSize() {
+      return min(maxSize, minSize + 1);
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/BUILD.bazel b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/BUILD.bazel
new file mode 100644
index 0000000..5b234ce
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/BUILD.bazel
@@ -0,0 +1,16 @@
+java_library(
+    name = "lang",
+    srcs = glob(["*.java"]),
+    visibility = [
+        "//src/main/java/com/code_intelligence/jazzer/mutation/mutator:__pkg__",
+        "//src/test/java/com/code_intelligence/jazzer/mutation/mutator:__subpackages__",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/mutation/annotation",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/api",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/combinator",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/mutator/libfuzzer",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/support",
+        "@com_google_errorprone_error_prone_annotations//jar",
+    ],
+)
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/BooleanMutatorFactory.java b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/BooleanMutatorFactory.java
new file mode 100644
index 0000000..a7dda97
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/BooleanMutatorFactory.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.lang;
+
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.findFirstParentIfClass;
+
+import com.code_intelligence.jazzer.mutation.api.Debuggable;
+import com.code_intelligence.jazzer.mutation.api.MutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.PseudoRandom;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.google.errorprone.annotations.Immutable;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.lang.reflect.AnnotatedType;
+import java.util.Optional;
+import java.util.function.Predicate;
+
+final class BooleanMutatorFactory extends MutatorFactory {
+  @Override
+  public Optional<SerializingMutator<?>> tryCreate(AnnotatedType type, MutatorFactory factory) {
+    return findFirstParentIfClass(type, boolean.class, Boolean.class)
+        .map(parent -> BooleanMutator.INSTANCE);
+  }
+
+  @Immutable
+  private static final class BooleanMutator extends SerializingMutator<Boolean> {
+    private static final BooleanMutator INSTANCE = new BooleanMutator();
+
+    @Override
+    public Boolean read(DataInputStream in) throws IOException {
+      return in.readBoolean();
+    }
+
+    @Override
+    public void write(Boolean value, DataOutputStream out) throws IOException {
+      out.writeBoolean(value);
+    }
+
+    @Override
+    public Boolean init(PseudoRandom prng) {
+      return prng.choice();
+    }
+
+    @Override
+    public Boolean mutate(Boolean value, PseudoRandom prng) {
+      return !value;
+    }
+
+    @Override
+    public Boolean crossOver(Boolean value, Boolean otherValue, PseudoRandom prng) {
+      return prng.choice() ? value : otherValue;
+    }
+
+    @Override
+    public String toDebugString(Predicate<Debuggable> isInLoop) {
+      return "Boolean";
+    }
+
+    @Override
+    public Boolean detach(Boolean value) {
+      return value;
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/ByteArrayMutatorFactory.java b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/ByteArrayMutatorFactory.java
new file mode 100644
index 0000000..cdd0d88
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/ByteArrayMutatorFactory.java
@@ -0,0 +1,227 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.lang;
+
+import static com.code_intelligence.jazzer.mutation.support.InputStreamSupport.readAllBytes;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.findFirstParentIfClass;
+
+import com.code_intelligence.jazzer.mutation.annotation.WithLength;
+import com.code_intelligence.jazzer.mutation.api.Debuggable;
+import com.code_intelligence.jazzer.mutation.api.MutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.PseudoRandom;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.code_intelligence.jazzer.mutation.mutator.libfuzzer.LibFuzzerMutator;
+import com.code_intelligence.jazzer.mutation.support.RandomSupport;
+import com.google.errorprone.annotations.Immutable;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.lang.reflect.AnnotatedType;
+import java.util.Arrays;
+import java.util.Optional;
+import java.util.function.Predicate;
+
+final class ByteArrayMutatorFactory extends MutatorFactory {
+  @Override
+  public Optional<SerializingMutator<?>> tryCreate(AnnotatedType type, MutatorFactory factory) {
+    Optional<WithLength> withLength = Optional.ofNullable(type.getAnnotation(WithLength.class));
+    int minLength = withLength.map(WithLength::min).orElse(ByteArrayMutator.DEFAULT_MIN_LENGTH);
+    int maxLength = withLength.map(WithLength::max).orElse(ByteArrayMutator.DEFAULT_MAX_LENGTH);
+
+    return findFirstParentIfClass(type, byte[].class)
+        .map(parent -> new ByteArrayMutator(minLength, maxLength));
+  }
+
+  @Immutable
+  private static final class ByteArrayMutator extends SerializingMutator<byte[]> {
+    private static final int DEFAULT_MIN_LENGTH = 0;
+    private static final int DEFAULT_MAX_LENGTH = 1000;
+
+    private final int minLength;
+
+    private final int maxLength;
+
+    private ByteArrayMutator(int min, int max) {
+      this.minLength = min;
+      this.maxLength = max;
+    }
+
+    @Override
+    public byte[] read(DataInputStream in) throws IOException {
+      int length = RandomSupport.clamp(in.readInt(), minLength, maxLength);
+      byte[] bytes = new byte[length];
+      in.readFully(bytes);
+      return bytes;
+    }
+
+    @Override
+    public byte[] readExclusive(InputStream in) throws IOException {
+      return readAllBytes(in);
+    }
+
+    @Override
+    public void write(byte[] value, DataOutputStream out) throws IOException {
+      out.writeInt(value.length);
+      out.write(value);
+    }
+
+    @Override
+    public void writeExclusive(byte[] value, OutputStream out) throws IOException {
+      out.write(value);
+    }
+
+    @Override
+    public byte[] detach(byte[] value) {
+      return Arrays.copyOf(value, value.length);
+    }
+
+    @Override
+    public byte[] init(PseudoRandom prng) {
+      int len = prng.closedRange(minInitialSize(), maxInitialSize());
+      byte[] bytes = new byte[len];
+      prng.bytes(bytes);
+      return bytes;
+    }
+
+    private int minInitialSize() {
+      return minLength;
+    }
+
+    private int maxInitialSize() {
+      // Allow some variation in length, but keep the initial elements well within reach of each
+      // other via a single mutation based on a Table of Recent Compares (ToRC) entry, which is
+      // currently limited to 64 bytes.
+      // Compared to List<T>, byte arrays can't result in recursive type hierarchies and thus don't
+      // to limit their expected initial size to be <= 1.
+      return Math.min(minLength + 16, maxLength);
+    }
+
+    @Override
+    public byte[] mutate(byte[] value, PseudoRandom prng) {
+      int maxLengthIncrease = maxLength - value.length;
+      byte[] mutated = LibFuzzerMutator.mutateDefault(value, maxLengthIncrease);
+      return enforceLength(mutated);
+    }
+
+    private byte[] enforceLength(byte[] mutated) {
+      // if the mutated array libfuzzer returns is too long or short, we truncate or extend it
+      // respectively. if we extend it, then copyOf will fill leftover bytes with 0
+      if (mutated.length > maxLength) {
+        return Arrays.copyOf(mutated, maxLength);
+      } else if (mutated.length < minLength) {
+        return Arrays.copyOf(mutated, minLength);
+      } else {
+        return mutated;
+      }
+    }
+
+    @Override
+    public byte[] crossOver(byte[] value, byte[] otherValue, PseudoRandom prng) {
+      // Passed in values are expected to already honor the min/max length constraints.
+      // As there does not seem to be an easy way to call libFuzzer's internal cross over
+      // algorithm, it is re-implemented in native Java. The algorithm is based on:
+      // https://github.com/llvm/llvm-project/blob/main/compiler-rt/lib/fuzzer/FuzzerMutate.cpp#L440
+      // https://github.com/llvm/llvm-project/blob/main/compiler-rt/lib/fuzzer/FuzzerCrossOver.cpp#L19
+      //
+
+      if (value.length == 0 || otherValue.length == 0) {
+        return value;
+      }
+
+      // TODO: Measure if this is fast enough.
+      byte[] out = null;
+      while (out == null) {
+        switch (prng.indexIn(3)) {
+          case 0:
+            out = intersect(value, otherValue, prng);
+            break;
+          case 1:
+            out = insertPart(value, otherValue, prng);
+            break;
+          case 2:
+            out = overwritePart(value, otherValue, prng);
+            break;
+          default:
+            throw new AssertionError("Invalid cross over function.");
+        }
+      }
+      return enforceLength(out);
+    }
+
+    private static byte[] intersect(byte[] value, byte[] otherValue, PseudoRandom prng) {
+      int maxOutSize = prng.closedRange(0, Math.min(value.length, otherValue.length));
+      byte[] out = new byte[maxOutSize];
+      int outPos = 0;
+      int valuePos = 0;
+      int otherValuePos = 0;
+      boolean usingFirstValue = true;
+      while (outPos < out.length) {
+        if (usingFirstValue && valuePos < value.length) {
+          int extraSize = rndArraycopy(value, valuePos, out, outPos, prng);
+          outPos += extraSize;
+          valuePos += extraSize;
+        } else if (!usingFirstValue && otherValuePos < otherValue.length) {
+          int extraSize = rndArraycopy(otherValue, otherValuePos, out, outPos, prng);
+          outPos += extraSize;
+          otherValuePos += extraSize;
+        }
+        usingFirstValue = !usingFirstValue;
+      }
+      return out;
+    }
+
+    private static int rndArraycopy(
+        byte[] val, int valPos, byte[] out, int outPos, PseudoRandom prng) {
+      int outSizeLeft = out.length - outPos;
+      int inSizeLeft = val.length - valPos;
+      int maxExtraSize = Math.min(outSizeLeft, inSizeLeft);
+      int extraSize = prng.closedRange(0, maxExtraSize);
+      System.arraycopy(val, valPos, out, outPos, extraSize);
+      return extraSize;
+    }
+
+    private static byte[] insertPart(byte[] value, byte[] otherValue, PseudoRandom prng) {
+      int copySize = prng.closedRange(1, otherValue.length);
+      int f = otherValue.length - copySize;
+      int fromPos = f == 0 ? 0 : prng.indexIn(f);
+      int toPos = prng.indexIn(value.length);
+      int tailSize = value.length - toPos;
+
+      byte[] out = new byte[value.length + copySize];
+      System.arraycopy(value, 0, out, 0, toPos);
+      System.arraycopy(otherValue, fromPos, out, toPos, copySize);
+      System.arraycopy(value, toPos, out, toPos + copySize, tailSize);
+      return out;
+    }
+
+    private static byte[] overwritePart(byte[] value, byte[] otherValue, PseudoRandom prng) {
+      int toPos = prng.indexIn(value.length);
+      int copySize = Math.min(prng.closedRange(1, value.length - toPos), otherValue.length);
+      int f = otherValue.length - copySize;
+      int fromPos = f == 0 ? 0 : prng.indexIn(f);
+      System.arraycopy(otherValue, fromPos, value, toPos, copySize);
+      return value;
+    }
+
+    @Override
+    public String toDebugString(Predicate<Debuggable> isInCycle) {
+      return "byte[]";
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/EnumMutatorFactory.java b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/EnumMutatorFactory.java
new file mode 100644
index 0000000..80b9e6c
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/EnumMutatorFactory.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.lang;
+
+import static com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators.mutateIndices;
+import static com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators.mutateThenMap;
+import static com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators.mutateThenMapToImmutable;
+import static com.code_intelligence.jazzer.mutation.support.Preconditions.require;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.asSubclassOrEmpty;
+
+import com.code_intelligence.jazzer.mutation.api.Debuggable;
+import com.code_intelligence.jazzer.mutation.api.MutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import java.lang.reflect.AnnotatedType;
+import java.util.Optional;
+import java.util.function.Predicate;
+
+final class EnumMutatorFactory extends MutatorFactory {
+  @Override
+  public Optional<SerializingMutator<?>> tryCreate(AnnotatedType type, MutatorFactory factory) {
+    return asSubclassOrEmpty(type, Enum.class).map(parent -> {
+      require(((Class<Enum<?>>) type.getType()).getEnumConstants().length > 1,
+          String.format(
+              "%s defines less than two enum constants and can't be mutated. Use a constant instead.",
+              parent));
+      Enum<?>[] values = ((Class<Enum<?>>) type.getType()).getEnumConstants();
+      return mutateThenMap(mutateIndices(values.length),
+          (index)
+              -> values[index],
+          Enum::ordinal, (Predicate<Debuggable> inCycle) -> "Enum<" + parent.getSimpleName() + ">");
+    });
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/FloatingPointMutatorFactory.java b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/FloatingPointMutatorFactory.java
new file mode 100644
index 0000000..17890a9
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/FloatingPointMutatorFactory.java
@@ -0,0 +1,597 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.lang;
+
+import static com.code_intelligence.jazzer.mutation.support.Preconditions.require;
+import static java.lang.String.format;
+
+import com.code_intelligence.jazzer.mutation.annotation.DoubleInRange;
+import com.code_intelligence.jazzer.mutation.annotation.FloatInRange;
+import com.code_intelligence.jazzer.mutation.api.Debuggable;
+import com.code_intelligence.jazzer.mutation.api.MutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.PseudoRandom;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.code_intelligence.jazzer.mutation.mutator.libfuzzer.LibFuzzerMutator;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.AnnotatedType;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.DoubleFunction;
+import java.util.function.Predicate;
+import java.util.stream.DoubleStream;
+
+final class FloatingPointMutatorFactory extends MutatorFactory {
+  @SuppressWarnings("unchecked")
+  private static final DoubleFunction<Double>[] mathFunctions =
+      new DoubleFunction[] {Math::acos, Math::asin, Math::atan, Math::cbrt, Math::ceil, Math::cos,
+          Math::cosh, Math::exp, Math::expm1, Math::floor, Math::log, Math::log10, Math::log1p,
+          Math::rint, Math::sin, Math::sinh, Math::sqrt, Math::tan, Math::tanh, Math::toDegrees,
+          Math::toRadians, n -> n * 0.5, n -> n * 2.0, n -> n * 0.333333333333333, n -> n * 3.0};
+
+  @Override
+  public Optional<SerializingMutator<?>> tryCreate(AnnotatedType type, MutatorFactory factory) {
+    if (!(type.getType() instanceof Class)) {
+      return Optional.empty();
+    }
+    Class<?> clazz = (Class<?>) type.getType();
+
+    if (clazz == float.class || clazz == Float.class) {
+      return Optional.of(
+          new FloatMutator(type, Float.NEGATIVE_INFINITY, Float.POSITIVE_INFINITY, true));
+    } else if (clazz == double.class || clazz == Double.class) {
+      return Optional.of(
+          new DoubleMutator(type, Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, true));
+    } else {
+      return Optional.empty();
+    }
+  }
+
+  static final class FloatMutator extends SerializingMutator<Float> {
+    private static final int EXPONENT_INITIAL_BIT = 23;
+    private static final int MANTISSA_MASK = 0x7fffff;
+    private static final int EXPONENT_MASK = 0xff;
+    private static final int MANTISSA_RANDOM_WALK_RANGE = 1000;
+    private static final int EXPONENT_RANDOM_WALK_RANGE = Float.MAX_EXPONENT;
+    private static final int INVERSE_FREQUENCY_SPECIAL_VALUE = 1000;
+
+    // Visible for testing.
+    final float minValue;
+    final float maxValue;
+    final boolean allowNaN;
+    private final float[] specialValues;
+
+    FloatMutator(AnnotatedType type, float defaultMinValueForType, float defaultMaxValueForType,
+        boolean defaultAllowNaN) {
+      float minValue = defaultMinValueForType;
+      float maxValue = defaultMaxValueForType;
+      boolean allowNaN = defaultAllowNaN;
+      // InRange is not repeatable, so the loop body will apply at most once.
+      for (Annotation annotation : type.getAnnotations()) {
+        if (annotation instanceof FloatInRange) {
+          FloatInRange floatInRange = (FloatInRange) annotation;
+          minValue = floatInRange.min();
+          maxValue = floatInRange.max();
+          allowNaN = floatInRange.allowNaN();
+        }
+      }
+
+      require(minValue <= maxValue,
+          format("[%f, %f] is not a valid interval: %s", minValue, maxValue, type));
+      require(minValue != maxValue,
+          format(
+              "[%f, %f] can not be mutated, use a constant instead: %s", minValue, maxValue, type));
+      this.minValue = minValue;
+      this.maxValue = maxValue;
+      this.allowNaN = allowNaN;
+      this.specialValues = collectSpecialValues(minValue, maxValue);
+    }
+
+    private float[] collectSpecialValues(float minValue, float maxValue) {
+      // stream of floats
+      List<Double> specialValues =
+          DoubleStream
+              .of(Float.NEGATIVE_INFINITY, Float.POSITIVE_INFINITY, 0.0f, -0.0f, Float.NaN,
+                  Float.MAX_VALUE, Float.MIN_VALUE, -Float.MAX_VALUE, -Float.MIN_VALUE,
+                  this.minValue, this.maxValue)
+              .filter(n -> (n >= minValue && n <= maxValue) || allowNaN && Double.isNaN(n))
+              .distinct()
+              .sorted()
+              .collect(ArrayList::new, ArrayList::add, ArrayList::addAll);
+
+      float[] specialValuesArray = new float[specialValues.size()];
+      for (int i = 0; i < specialValues.size(); i++) {
+        specialValuesArray[i] = (float) (double) specialValues.get(i);
+      }
+      return specialValuesArray;
+    }
+
+    public float mutateWithLibFuzzer(float value) {
+      return LibFuzzerMutator.mutateDefault(value, this, 0);
+    }
+
+    @Override
+    public Float init(PseudoRandom prng) {
+      if (prng.choice()) {
+        return specialValues[prng.closedRange(0, specialValues.length - 1)];
+      } else {
+        return prng.closedRange(minValue, maxValue);
+      }
+    }
+
+    @Override
+    public Float mutate(Float value, PseudoRandom prng) {
+      float result;
+      // small chance to return a special value
+      if (prng.trueInOneOutOf(INVERSE_FREQUENCY_SPECIAL_VALUE)) {
+        result = specialValues[prng.closedRange(0, specialValues.length - 1)];
+      } else {
+        switch (prng.closedRange(0, 5)) {
+          case 0:
+            result = mutateWithBitFlip(value, prng);
+            break;
+          case 1:
+            result = mutateExponent(value, prng);
+            break;
+          case 2:
+            result = mutateMantissa(value, prng);
+            break;
+          case 3:
+            result = mutateWithMathematicalFn(value, prng);
+            break;
+          case 4:
+            result = mutateWithLibFuzzer(value);
+            break;
+          case 5: // random in range cannot exceed the given bounds (and cannot be NaN)
+            result = prng.closedRange(minValue, maxValue);
+            break;
+          default:
+            throw new IllegalStateException("Unknown mutation case");
+        }
+      }
+      result = forceInRange(result, minValue, maxValue, allowNaN);
+
+      // Repeating values are not allowed.
+      if (Float.compare(result, value) == 0) {
+        if (Float.isNaN(result)) {
+          return prng.closedRange(minValue, maxValue);
+        } else { // Change the value to the neighboring float.
+          if (result > minValue && result < maxValue) {
+            return prng.choice() ? Math.nextAfter(result, Float.NEGATIVE_INFINITY)
+                                 : Math.nextAfter(result, Float.POSITIVE_INFINITY);
+          } else if (result > minValue) {
+            return Math.nextAfter(result, Float.NEGATIVE_INFINITY);
+          } else
+            return Math.nextAfter(result, Float.POSITIVE_INFINITY);
+        }
+      }
+
+      return result;
+    }
+
+    static float forceInRange(float value, float minValue, float maxValue, boolean allowNaN) {
+      if ((value >= minValue && value <= maxValue) || (Float.isNaN(value) && allowNaN))
+        return value;
+
+      // Clamp infinite values
+      if (value == Float.POSITIVE_INFINITY)
+        return maxValue;
+      if (value == Float.NEGATIVE_INFINITY)
+        return minValue;
+
+      // From here on limits should be finite
+      float finiteMax = Math.min(Float.MAX_VALUE, maxValue);
+      float finiteMin = Math.max(-Float.MAX_VALUE, minValue);
+
+      // If NaN was allowed, it was handled above. Replace it by the midpoint of the range.
+      if (Float.isNaN(value))
+        return finiteMin * 0.5f + finiteMax * 0.5f;
+
+      float range = finiteMax - finiteMin;
+      if (range == 0f)
+        return finiteMin;
+
+      float diff = value - finiteMin;
+
+      if (Float.isFinite(diff) && Float.isFinite(range)) {
+        return finiteMin + Math.abs(diff % range);
+      }
+
+      // diff, range, or both are infinite: divide both by 2, reduce, and multiply by 2.
+      float halfDiff = value * 0.5f - finiteMin * 0.5f;
+
+      return finiteMin + (halfDiff % (finiteMax * 0.5f - finiteMin * 0.5f)) * 2.0f;
+    }
+
+    public float mutateWithMathematicalFn(float value, PseudoRandom prng) {
+      double result = prng.pickIn(mathFunctions).apply(value);
+      return (float) result;
+    }
+
+    private float mutateWithBitFlip(float value, PseudoRandom prng) {
+      int bits = Float.floatToRawIntBits(value);
+      int bitToFlip = prng.closedRange(0, 31);
+      bits ^= 1L << bitToFlip;
+      return Float.intBitsToFloat(bits);
+    }
+
+    private float mutateExponent(float value, PseudoRandom prng) {
+      int bits = Float.floatToRawIntBits(value);
+      int exponent = ((bits >> EXPONENT_INITIAL_BIT) & EXPONENT_MASK)
+          + prng.closedRange(0, EXPONENT_RANDOM_WALK_RANGE);
+      bits = (bits & ~(EXPONENT_MASK << EXPONENT_INITIAL_BIT))
+          | ((exponent % EXPONENT_MASK) << EXPONENT_INITIAL_BIT);
+      return Float.intBitsToFloat(bits);
+    }
+
+    private float mutateMantissa(float value, PseudoRandom prng) {
+      int bits = Float.floatToRawIntBits(value);
+
+      int mantissa = bits & MANTISSA_MASK;
+      switch (prng.closedRange(0, 2)) {
+        case 0: // +
+          mantissa =
+              (mantissa + prng.closedRange(-MANTISSA_RANDOM_WALK_RANGE, MANTISSA_RANDOM_WALK_RANGE))
+              % MANTISSA_MASK;
+          break;
+        case 1: // *
+          mantissa =
+              (mantissa * prng.closedRange(-MANTISSA_RANDOM_WALK_RANGE, MANTISSA_RANDOM_WALK_RANGE))
+              % MANTISSA_MASK;
+          break;
+        case 2: // /
+          int divisor = prng.closedRange(2, MANTISSA_RANDOM_WALK_RANGE);
+          if (prng.choice()) {
+            divisor = -divisor;
+          }
+          mantissa = (mantissa / divisor);
+          break;
+        default:
+          throw new IllegalStateException("Unknown mutation case for mantissa");
+      }
+      bits = (bits & ~MANTISSA_MASK) | mantissa;
+      return Float.intBitsToFloat(bits);
+    }
+
+    @Override
+    public Float crossOver(Float value, Float otherValue, PseudoRandom prng) {
+      float result;
+      switch (prng.closedRange(0, 2)) {
+        case 0:
+          result = crossOverMean(value, otherValue);
+          break;
+        case 1:
+          result = crossOverExponent(value, otherValue);
+          break;
+        case 2:
+          result = crossOverMantissa(value, otherValue);
+          break;
+        default:
+          throw new IllegalStateException("Unknown mutation case");
+      }
+      return forceInRange(result, minValue, maxValue, allowNaN);
+    }
+
+    private float crossOverMean(float value, float otherValue) {
+      return (float) ((((double) value) + ((double) otherValue)) / 2.0);
+    }
+
+    private float crossOverExponent(float value, float otherValue) {
+      int bits = Float.floatToRawIntBits(value);
+      int otherExponent =
+          Float.floatToRawIntBits(otherValue) & (EXPONENT_MASK << EXPONENT_INITIAL_BIT);
+      int bitsWithOtherExponent = (bits & ~(EXPONENT_MASK << EXPONENT_INITIAL_BIT)) | otherExponent;
+      return Float.intBitsToFloat(bitsWithOtherExponent);
+    }
+
+    private float crossOverMantissa(float value, float otherValue) {
+      int bits = Float.floatToRawIntBits(value);
+      int otherMantissa = Float.floatToRawIntBits(otherValue) & MANTISSA_MASK;
+      int bitsWithOtherMantissa = (bits & ~MANTISSA_MASK) | otherMantissa;
+      return Float.intBitsToFloat(bitsWithOtherMantissa);
+    }
+
+    @Override
+    public Float read(DataInputStream in) throws IOException {
+      return forceInRange(in.readFloat(), minValue, maxValue, allowNaN);
+    }
+
+    @Override
+    public void write(Float value, DataOutputStream out) throws IOException {
+      out.writeFloat(value);
+    }
+
+    @Override
+    public Float detach(Float value) {
+      return value;
+    }
+
+    @Override
+    public String toDebugString(Predicate<Debuggable> isInCycle) {
+      return "Float";
+    }
+  }
+
+  static final class DoubleMutator extends SerializingMutator<Double> {
+    private static final long MANTISSA_RANDOM_WALK_RANGE = 1000;
+    private static final int EXPONENT_RANDOM_WALK_RANGE = Double.MAX_EXPONENT;
+    private static final int INVERSE_FREQUENCY_SPECIAL_VALUE = 1000;
+    private static final long MANTISSA_MASK = 0xfffffffffffffL;
+    private static final long EXPONENT_MASK = 0x7ffL;
+    private static final int EXPONENT_INITIAL_BIT = 52;
+
+    // Visible for testing
+    final double minValue;
+    final double maxValue;
+    final boolean allowNaN;
+    private final double[] specialValues;
+
+    DoubleMutator(AnnotatedType type, double defaultMinValueForType, double defaultMaxValueForType,
+        boolean defaultAllowNaN) {
+      double minValue = defaultMinValueForType;
+      double maxValue = defaultMaxValueForType;
+      boolean allowNaN = defaultAllowNaN;
+      // InRange is not repeatable, so the loop body will apply at most once.
+      for (Annotation annotation : type.getAnnotations()) {
+        if (annotation instanceof DoubleInRange) {
+          DoubleInRange doubleInRange = (DoubleInRange) annotation;
+          minValue = doubleInRange.min();
+          maxValue = doubleInRange.max();
+          allowNaN = doubleInRange.allowNaN();
+        }
+      }
+
+      require(!Double.isNaN(minValue) && !Double.isNaN(maxValue),
+          format("[%f, %f] is not a valid interval: %s", minValue, maxValue, type));
+      require(minValue <= maxValue,
+          format("[%f, %f] is not a valid interval: %s", minValue, maxValue, type));
+      require(minValue != maxValue,
+          format(
+              "[%f, %f] can not be mutated, use a constant instead: %s", minValue, maxValue, type));
+      this.minValue = minValue;
+      this.maxValue = maxValue;
+      this.allowNaN = allowNaN;
+      this.specialValues = collectSpecialValues(minValue, maxValue);
+    }
+
+    private double[] collectSpecialValues(double minValue, double maxValue) {
+      double[] specialValues = new double[] {Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY,
+          0.0, -0.0, Double.NaN, Double.MAX_VALUE, Double.MIN_VALUE, -Double.MAX_VALUE,
+          -Double.MIN_VALUE, this.minValue, this.maxValue};
+      return Arrays.stream(specialValues)
+          .boxed()
+          .filter(value -> (allowNaN && value.isNaN()) || (value >= minValue && value <= maxValue))
+          .distinct()
+          .sorted()
+          .mapToDouble(Double::doubleValue)
+          .toArray();
+    }
+
+    public double mutateWithLibFuzzer(double value) {
+      return LibFuzzerMutator.mutateDefault(value, this, 0);
+    }
+
+    @Override
+    public Double init(PseudoRandom prng) {
+      if (prng.choice()) {
+        return specialValues[prng.closedRange(0, specialValues.length - 1)];
+      } else {
+        return prng.closedRange(minValue, maxValue);
+      }
+    }
+
+    @Override
+    public Double mutate(Double value, PseudoRandom prng) {
+      double result;
+      // small chance to return a special value
+      if (prng.trueInOneOutOf(INVERSE_FREQUENCY_SPECIAL_VALUE)) {
+        result = specialValues[prng.closedRange(0, specialValues.length - 1)];
+      } else {
+        switch (prng.closedRange(0, 5)) {
+          case 0:
+            result = mutateWithBitFlip(value, prng);
+            break;
+          case 1:
+            result = mutateExponent(value, prng);
+            break;
+          case 2:
+            result = mutateMantissa(value, prng);
+            break;
+          case 3:
+            result = mutateWithMathematicalFn(value, prng);
+            break;
+          case 4:
+            result = mutateWithLibFuzzer(value);
+            break;
+          case 5: // random in range cannot exceed the given bounds (and cannot be NaN)
+            result = prng.closedRange(minValue, maxValue);
+            break;
+          default:
+            throw new IllegalStateException("Unknown mutation case");
+        }
+      }
+      result = forceInRange(result, minValue, maxValue, allowNaN);
+
+      // Repeating values are not allowed.
+      if (Double.compare(result, value) == 0) {
+        if (Double.isNaN(result)) {
+          return prng.closedRange(minValue, maxValue);
+        } else { // Change the value to the neighboring float.
+          if (result > minValue && result < maxValue) {
+            return prng.choice() ? Math.nextAfter(result, Double.NEGATIVE_INFINITY)
+                                 : Math.nextAfter(result, Double.POSITIVE_INFINITY);
+          } else if (result > minValue) {
+            return Math.nextAfter(result, Double.NEGATIVE_INFINITY);
+          } else
+            return Math.nextAfter(result, Double.POSITIVE_INFINITY);
+        }
+      }
+
+      return result;
+    }
+
+    static double forceInRange(double value, double minValue, double maxValue, boolean allowNaN) {
+      if ((value >= minValue && value <= maxValue) || (Double.isNaN(value) && allowNaN)) {
+        return value;
+      }
+
+      // Clamp infinite values
+      if (value == Double.POSITIVE_INFINITY)
+        return maxValue;
+      if (value == Double.NEGATIVE_INFINITY)
+        return minValue;
+
+      // From here on limits should be finite
+      double finiteMax = Math.min(Double.MAX_VALUE, maxValue);
+      double finiteMin = Math.max(-Double.MAX_VALUE, minValue);
+
+      // If NaN was allowed, it was handled above.
+      // Here we replace NaN by the middle of the clamped finite range.
+      if (Double.isNaN(value)) {
+        // maxValue or minValue may be infinite, so we need to clamp them.
+        return minValue
+            + (Math.min(Double.MAX_VALUE, maxValue) * 0.5
+                - Math.max(-Double.MAX_VALUE, minValue) * 0.5);
+      }
+
+      double range = finiteMax - finiteMin;
+      if (range == 0)
+        return finiteMin;
+
+      double diff = value - finiteMin;
+
+      if (Double.isFinite(diff) && Double.isFinite(range)) {
+        return finiteMin + Math.abs(diff % range);
+      }
+
+      // diff, range, or both are infinite: divide both by 2, reduce, and multiply by 2.
+      double halfDiff = value * 0.5 - finiteMin * 0.5;
+      return finiteMin + (halfDiff % (finiteMax * 0.5 - finiteMin * 0.5)) * 2.0;
+    }
+
+    public double mutateWithMathematicalFn(double value, PseudoRandom prng) {
+      return prng.pickIn(mathFunctions).apply(value);
+    }
+
+    public static double mutateWithBitFlip(double value, PseudoRandom prng) {
+      long bits = Double.doubleToRawLongBits(value);
+      int bitToFlip = prng.closedRange(0, 63);
+      bits ^= 1L << bitToFlip;
+      return Double.longBitsToDouble(bits);
+    }
+
+    private static double mutateExponent(double value, PseudoRandom prng) {
+      long bits = Double.doubleToRawLongBits(value);
+      long exponent = ((bits >> EXPONENT_INITIAL_BIT) & EXPONENT_MASK)
+          + prng.closedRange(0, EXPONENT_RANDOM_WALK_RANGE);
+      bits = (bits & ~(EXPONENT_MASK << EXPONENT_INITIAL_BIT))
+          | ((exponent % EXPONENT_MASK) << EXPONENT_INITIAL_BIT);
+      return Double.longBitsToDouble(bits);
+    }
+
+    public static double mutateMantissa(double value, PseudoRandom prng) {
+      long bits = Double.doubleToRawLongBits(value);
+      long mantissa = bits & MANTISSA_MASK;
+      switch (prng.closedRange(0, 2)) {
+        case 0: // +
+          mantissa =
+              (mantissa + prng.closedRange(-MANTISSA_RANDOM_WALK_RANGE, MANTISSA_RANDOM_WALK_RANGE))
+              % MANTISSA_MASK;
+          break;
+        case 1: // *
+          mantissa =
+              (mantissa * prng.closedRange(-MANTISSA_RANDOM_WALK_RANGE, MANTISSA_RANDOM_WALK_RANGE))
+              % MANTISSA_MASK;
+          break;
+        case 2: // /
+          long divisor = prng.closedRange(2, MANTISSA_RANDOM_WALK_RANGE);
+          if (prng.choice()) {
+            divisor = -divisor;
+          }
+          mantissa = (mantissa / divisor);
+          break;
+        default:
+          throw new IllegalStateException("Unknown mutation case for mantissa");
+      }
+      bits = (bits & ~MANTISSA_MASK) | mantissa;
+      return Double.longBitsToDouble(bits);
+    }
+
+    @Override
+    public Double crossOver(Double value, Double otherValue, PseudoRandom prng) {
+      double result;
+      switch (prng.closedRange(0, 2)) {
+        case 0:
+          result = crossOverMean(value, otherValue);
+          break;
+        case 1:
+          result = crossOverExponent(value, otherValue);
+          break;
+        case 2:
+          result = crossOverMantissa(value, otherValue);
+          break;
+        default:
+          throw new IllegalStateException("Unknown mutation case");
+      }
+      return forceInRange(result, minValue, maxValue, allowNaN);
+    }
+
+    private double crossOverMean(double value, double otherValue) {
+      return (value * 0.5) + (otherValue * 0.5);
+    }
+
+    private double crossOverExponent(double value, double otherValue) {
+      long bits = Double.doubleToRawLongBits(value);
+      long otherExponent =
+          Double.doubleToRawLongBits(otherValue) & (EXPONENT_MASK << EXPONENT_INITIAL_BIT);
+      long bitsWithOtherExponent =
+          (bits & ~(EXPONENT_MASK << EXPONENT_INITIAL_BIT)) | otherExponent;
+      return Double.longBitsToDouble(bitsWithOtherExponent);
+    }
+
+    private double crossOverMantissa(double value, double otherValue) {
+      long bits = Double.doubleToRawLongBits(value);
+      long otherMantissa = Double.doubleToRawLongBits(otherValue) & MANTISSA_MASK;
+      long bitsWithOtherMantissa = (bits & ~MANTISSA_MASK) | otherMantissa;
+      return Double.longBitsToDouble(bitsWithOtherMantissa);
+    }
+
+    @Override
+    public Double read(DataInputStream in) throws IOException {
+      return forceInRange(in.readDouble(), minValue, maxValue, allowNaN);
+    }
+
+    @Override
+    public void write(Double value, DataOutputStream out) throws IOException {
+      out.writeDouble(value);
+    }
+
+    @Override
+    public Double detach(Double value) {
+      return value;
+    }
+
+    @Override
+    public String toDebugString(Predicate<Debuggable> isInCycle) {
+      return "Double";
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/IntegralMutatorFactory.java b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/IntegralMutatorFactory.java
new file mode 100644
index 0000000..e701e6a
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/IntegralMutatorFactory.java
@@ -0,0 +1,399 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.lang;
+
+import static com.code_intelligence.jazzer.mutation.support.Preconditions.require;
+import static java.lang.String.format;
+
+import com.code_intelligence.jazzer.mutation.annotation.InRange;
+import com.code_intelligence.jazzer.mutation.api.Debuggable;
+import com.code_intelligence.jazzer.mutation.api.MutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.PseudoRandom;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.code_intelligence.jazzer.mutation.mutator.libfuzzer.LibFuzzerMutator;
+import com.google.errorprone.annotations.ForOverride;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.AnnotatedType;
+import java.lang.reflect.ParameterizedType;
+import java.util.Optional;
+import java.util.function.Predicate;
+import java.util.stream.LongStream;
+
+final class IntegralMutatorFactory extends MutatorFactory {
+  @Override
+  public Optional<SerializingMutator<?>> tryCreate(AnnotatedType type, MutatorFactory factory) {
+    if (!(type.getType() instanceof Class)) {
+      return Optional.empty();
+    }
+    Class<?> clazz = (Class<?>) type.getType();
+
+    if (clazz == byte.class || clazz == Byte.class) {
+      return Optional.of(new AbstractIntegralMutator<Byte>(type, Byte.MIN_VALUE, Byte.MAX_VALUE) {
+        @Override
+        protected long mutateWithLibFuzzer(long value) {
+          return LibFuzzerMutator.mutateDefault((byte) value, this, 0);
+        }
+
+        @Override
+        public Byte init(PseudoRandom prng) {
+          return (byte) initImpl(prng);
+        }
+
+        @Override
+        public Byte mutate(Byte value, PseudoRandom prng) {
+          return (byte) mutateImpl(value, prng);
+        }
+
+        @Override
+        public Byte crossOver(Byte value, Byte otherValue, PseudoRandom prng) {
+          return (byte) crossOverImpl(value, otherValue, prng);
+        }
+
+        @Override
+        public Byte read(DataInputStream in) throws IOException {
+          return (byte) forceInRange(in.readByte());
+        }
+
+        @Override
+        public void write(Byte value, DataOutputStream out) throws IOException {
+          out.writeByte(value);
+        }
+      });
+    } else if (clazz == short.class || clazz == Short.class) {
+      return Optional.of(
+          new AbstractIntegralMutator<Short>(type, Short.MIN_VALUE, Short.MAX_VALUE) {
+            @Override
+            protected long mutateWithLibFuzzer(long value) {
+              return LibFuzzerMutator.mutateDefault((short) value, this, 0);
+            }
+
+            @Override
+            public Short init(PseudoRandom prng) {
+              return (short) initImpl(prng);
+            }
+
+            @Override
+            public Short mutate(Short value, PseudoRandom prng) {
+              return (short) mutateImpl(value, prng);
+            }
+
+            @Override
+            public Short crossOver(Short value, Short otherValue, PseudoRandom prng) {
+              return (short) crossOverImpl(value, otherValue, prng);
+            }
+
+            @Override
+            public Short read(DataInputStream in) throws IOException {
+              return (short) forceInRange(in.readShort());
+            }
+
+            @Override
+            public void write(Short value, DataOutputStream out) throws IOException {
+              out.writeShort(value);
+            }
+          });
+    } else if (clazz == int.class || clazz == Integer.class) {
+      return Optional.of(
+          new AbstractIntegralMutator<Integer>(type, Integer.MIN_VALUE, Integer.MAX_VALUE) {
+            @Override
+            protected long mutateWithLibFuzzer(long value) {
+              return LibFuzzerMutator.mutateDefault((int) value, this, 0);
+            }
+
+            @Override
+            public Integer init(PseudoRandom prng) {
+              return (int) initImpl(prng);
+            }
+
+            @Override
+            public Integer mutate(Integer value, PseudoRandom prng) {
+              return (int) mutateImpl(value, prng);
+            }
+
+            @Override
+            public Integer crossOver(Integer value, Integer otherValue, PseudoRandom prng) {
+              return (int) crossOverImpl(value, otherValue, prng);
+            }
+
+            @Override
+            public Integer read(DataInputStream in) throws IOException {
+              return (int) forceInRange(in.readInt());
+            }
+
+            @Override
+            public void write(Integer value, DataOutputStream out) throws IOException {
+              out.writeInt(value);
+            }
+          });
+    } else if (clazz == long.class || clazz == Long.class) {
+      return Optional.of(new AbstractIntegralMutator<Long>(type, Long.MIN_VALUE, Long.MAX_VALUE) {
+        @Override
+        protected long mutateWithLibFuzzer(long value) {
+          return LibFuzzerMutator.mutateDefault(value, this, 0);
+        }
+
+        @Override
+        public Long init(PseudoRandom prng) {
+          return initImpl(prng);
+        }
+
+        @Override
+        public Long mutate(Long value, PseudoRandom prng) {
+          return mutateImpl(value, prng);
+        }
+
+        @Override
+        public Long crossOver(Long value, Long otherValue, PseudoRandom prng) {
+          return crossOverImpl(value, otherValue, prng);
+        }
+
+        @Override
+        public Long read(DataInputStream in) throws IOException {
+          return forceInRange(in.readLong());
+        }
+
+        @Override
+        public void write(Long value, DataOutputStream out) throws IOException {
+          out.writeLong(value);
+        }
+      });
+    } else {
+      return Optional.empty();
+    }
+  }
+
+  // Based on
+  // https://github.com/google/fuzztest/blob/a663ded6c36f050fbdc634a8fc81d553068d71d7/fuzztest/internal/domain.h#L1447
+  // SPDX: Apache-2.0
+  // Copyright 2022 Google LLC
+  //
+  // Visible for testing.
+  static abstract class AbstractIntegralMutator<T extends Number> extends SerializingMutator<T> {
+    private static final long RANDOM_WALK_RANGE = 5;
+    private final long minValue;
+    private final long maxValue;
+    private final int largestMutableBitNegative;
+    private final int largestMutableBitPositive;
+    private final long[] specialValues;
+
+    AbstractIntegralMutator(
+        AnnotatedType type, long defaultMinValueForType, long defaultMaxValueForType) {
+      long minValue = defaultMinValueForType;
+      long maxValue = defaultMaxValueForType;
+      // InRange is not repeatable, so the loop body will apply exactly once.
+      for (Annotation annotation : type.getAnnotations()) {
+        if (annotation instanceof InRange) {
+          InRange inRange = (InRange) annotation;
+          // Since we use a single annotation for all integral types and its min and max fields are
+          // longs, we have to ignore them if they are at their default values.
+          //
+          // This results in a small quirk that is probably acceptable: If someone specifies
+          // @InRange(max = Long.MAX_VALUE) on a byte, we will not fail but silently use
+          // Byte.MAX_VALUE instead. IDEs will warn about the redundant specification of the default
+          // value, so this should not be a problem in practice.
+          if (inRange.min() != Long.MIN_VALUE) {
+            require(inRange.min() >= defaultMinValueForType,
+                format("@InRange.min=%d is out of range: %s", inRange.min(), type.getType()));
+            minValue = inRange.min();
+          }
+          if (inRange.max() != Long.MAX_VALUE) {
+            require(inRange.max() <= defaultMaxValueForType,
+                format("@InRange.max=%d is out of range: %s", inRange.max(), type.getType()));
+            maxValue = inRange.max();
+          }
+        }
+      }
+
+      require(minValue <= maxValue,
+          format("[%d, %d] is not a valid interval: %s", minValue, maxValue, type));
+      require(minValue != maxValue,
+          format(
+              "[%d, %d] can not be mutated, use a constant instead: %s", minValue, maxValue, type));
+      this.minValue = minValue;
+      this.maxValue = maxValue;
+      if (minValue >= 0) {
+        largestMutableBitNegative = 0;
+        largestMutableBitPositive = bitWidth(minValue ^ maxValue);
+      } else if (maxValue < 0) {
+        largestMutableBitNegative = bitWidth(minValue ^ maxValue);
+        largestMutableBitPositive = 0;
+      } else /* minValue < 0 && maxValue >= 0 */ {
+        largestMutableBitNegative = bitWidth(~minValue);
+        largestMutableBitPositive = bitWidth(maxValue);
+      }
+      this.specialValues = collectSpecialValues(minValue, maxValue);
+    }
+
+    private static long[] collectSpecialValues(long minValue, long maxValue) {
+      // Special values can collide or not apply when @InRange is used, so filter appropriately and
+      // remove duplicates - we don't want to weigh certain special values higher than others.
+      return LongStream.of(0, 1, minValue, maxValue)
+          .filter(value -> value >= minValue)
+          .filter(value -> value <= maxValue)
+          .distinct()
+          .sorted()
+          .toArray();
+    }
+
+    private static int bitWidth(long value) {
+      return 64 - Long.numberOfLeadingZeros(value);
+    }
+
+    protected final long initImpl(PseudoRandom prng) {
+      int sentinel = specialValues.length;
+      int choice = prng.closedRange(0, sentinel);
+      if (choice < sentinel) {
+        return specialValues[choice];
+      } else {
+        return prng.closedRange(minValue, maxValue);
+      }
+    }
+
+    protected final long mutateImpl(long value, PseudoRandom prng) {
+      final long previousValue = value;
+      // Mutate in a loop to verify that we really mutated.
+      do {
+        switch (prng.indexIn(4)) {
+          case 0:
+            value = bitFlip(value, prng);
+            break;
+          case 1:
+            value = randomWalk(value, prng);
+            break;
+          case 2:
+            value = prng.closedRange(minValue, maxValue);
+            break;
+          case 3:
+            // TODO: Replace this with a structure-aware dictionary/TORC search similar to fuzztest.
+            value = forceInRange(mutateWithLibFuzzer(value));
+            break;
+        }
+      } while (value == previousValue);
+      return value;
+    }
+
+    protected final long crossOverImpl(long x, long y, PseudoRandom prng) {
+      switch (prng.indexIn(3)) {
+        case 0:
+          return mean(x, y);
+        case 1:
+          return forceInRange(x ^ y);
+        case 2:
+          return bitmask(x, y, prng);
+        default:
+          throw new AssertionError("Invalid cross over function.");
+      }
+    }
+
+    private long bitmask(long x, long y, PseudoRandom prng) {
+      long mask = prng.nextLong();
+      return forceInRange((x & mask) | (y & ~mask));
+    }
+
+    private static long mean(long x, long y) {
+      // Add the common set bits (x & y) and the half of the sum of the
+      // differing bits together ((x ^ y) >> 1), the result will never exceed
+      // the sum of x and y as both parts of the calculation are guaranteed to
+      // be smaller than or equal to x and y.
+      long xor = x ^ y;
+      long mean = (x & y) + (xor >> 1);
+      // Round towards zero (add 1) if rounding is not exact (last xor bit is
+      // set) and result is negative (sign bit is set).
+      return mean + (1 & xor & (mean >>> 31));
+    }
+
+    @ForOverride protected abstract long mutateWithLibFuzzer(long value);
+
+    /**
+     * Force value into the closed interval [minValue, maxValue] while preserving as many of its
+     * bits as possible (e.g. so that mutations that apply to the raw byte representation still have
+     * a good chance to actually mutate the value). Clamping would not have this property.
+     */
+    protected final long forceInRange(long value) {
+      // Fast path for the common case.
+      if (value >= minValue && value <= maxValue) {
+        return value;
+      }
+      return forceInRange(value, minValue, maxValue);
+    }
+
+    // Visible for testing.
+    static long forceInRange(long value, long minValue, long maxValue) {
+      long range = maxValue - minValue;
+      if (range > 0) {
+        return minValue + Math.abs((value - minValue) % range);
+      } else {
+        // [minValue, maxValue] covers at least half of the [Long.MIN_VALUE, Long.MAX_VALUE] range,
+        // so if value doesn't lie in [minValue, maxValue], it will after shifting once.
+        if (value >= minValue && value <= maxValue) {
+          return value;
+        } else {
+          return value + range;
+        }
+      }
+    }
+
+    private long bitFlip(long value, PseudoRandom prng) {
+      int range = value >= 0 ? largestMutableBitPositive : largestMutableBitNegative;
+      value = value ^ (1L << prng.indexIn(range));
+      // The bit flip may violate the range constraint, if so, mutate randomly.
+      if (value > maxValue || value < minValue) {
+        value = prng.closedRange(minValue, maxValue);
+      }
+      return value;
+    }
+
+    private long randomWalk(long value, PseudoRandom prng) {
+      // Prevent overflows by averaging the individual bounds.
+      if (maxValue / 2 - minValue / 2 <= RANDOM_WALK_RANGE) {
+        value = prng.closedRange(minValue, maxValue);
+      } else {
+        // At this point we know that (using non-wrapping arithmetic):
+        // RANDOM_WALK_RANGE < maxValue/2 - minValue/2 <= Long.MAX_VALUE/2 - minValue/2, hence
+        // minValue/2 + RANDOM_WALK_RANGE < Long.MAX_VALUE/2, hence
+        // minValue + 2*RANDOM_WALK_RANGE < Long.MAX_VALUE.
+        // In particular, minValue + RANDOM_WALK_RANGE can't overflow, likewise for maxValue.
+        long lower = minValue;
+        if (value > lower + RANDOM_WALK_RANGE) {
+          lower = value - RANDOM_WALK_RANGE;
+        }
+        long upper = maxValue;
+        if (value < upper - RANDOM_WALK_RANGE) {
+          upper = value + RANDOM_WALK_RANGE;
+        }
+        value = prng.closedRange(lower, upper);
+      }
+      return value;
+    }
+
+    @Override
+    public T detach(T value) {
+      // Always immutable.
+      return value;
+    }
+
+    @Override
+    public String toDebugString(Predicate<Debuggable> isInCycle) {
+      return ((Class<T>) ((ParameterizedType) this.getClass().getGenericSuperclass())
+                  .getActualTypeArguments()[0])
+          .getSimpleName();
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/LangMutators.java b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/LangMutators.java
new file mode 100644
index 0000000..00ea8b4
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/LangMutators.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.lang;
+
+import com.code_intelligence.jazzer.mutation.api.ChainedMutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.MutatorFactory;
+
+public final class LangMutators {
+  private LangMutators() {}
+
+  public static MutatorFactory newFactory() {
+    return new ChainedMutatorFactory(new NullableMutatorFactory(), new BooleanMutatorFactory(),
+        new FloatingPointMutatorFactory(), new IntegralMutatorFactory(),
+        new ByteArrayMutatorFactory(), new StringMutatorFactory(), new EnumMutatorFactory());
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/NullableMutatorFactory.java b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/NullableMutatorFactory.java
new file mode 100644
index 0000000..16e6a13
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/NullableMutatorFactory.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.lang;
+
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.isPrimitive;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.notNull;
+import static java.util.Arrays.stream;
+
+import com.code_intelligence.jazzer.mutation.api.Debuggable;
+import com.code_intelligence.jazzer.mutation.api.MutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.PseudoRandom;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.AnnotatedType;
+import java.util.Optional;
+import java.util.function.Predicate;
+
+final class NullableMutatorFactory extends MutatorFactory {
+  private static boolean isNotNullAnnotation(Annotation annotation) {
+    // There are many NotNull annotations in the wild (including our own) and we want to recognize
+    // them all.
+    return annotation.annotationType().getSimpleName().equals("NotNull");
+  }
+
+  @Override
+  public Optional<SerializingMutator<?>> tryCreate(AnnotatedType type, MutatorFactory factory) {
+    if (isPrimitive(type)
+        || stream(type.getAnnotations()).anyMatch(NullableMutatorFactory::isNotNullAnnotation)) {
+      return Optional.empty();
+    }
+    return factory.tryCreate(notNull(type), factory).map(NullableMutator::new);
+  }
+
+  private static final class NullableMutator<T> extends SerializingMutator<T> {
+    private static final int INVERSE_FREQUENCY_NULL = 100;
+
+    private final SerializingMutator<T> mutator;
+
+    NullableMutator(SerializingMutator<T> mutator) {
+      this.mutator = mutator;
+    }
+
+    @Override
+    public T read(DataInputStream in) throws IOException {
+      if (in.readBoolean()) {
+        return mutator.read(in);
+      } else {
+        return null;
+      }
+    }
+
+    @Override
+    public void write(T value, DataOutputStream out) throws IOException {
+      out.writeBoolean(value != null);
+      if (value != null) {
+        mutator.write(value, out);
+      }
+    }
+
+    @Override
+    public T init(PseudoRandom prng) {
+      if (prng.trueInOneOutOf(INVERSE_FREQUENCY_NULL)) {
+        return null;
+      } else {
+        return mutator.init(prng);
+      }
+    }
+
+    @Override
+    public T mutate(T value, PseudoRandom prng) {
+      if (value == null) {
+        return mutator.init(prng);
+      } else if (prng.trueInOneOutOf(INVERSE_FREQUENCY_NULL)) {
+        return null;
+      } else {
+        return mutator.mutate(value, prng);
+      }
+    }
+
+    @Override
+    public T crossOver(T value, T otherValue, PseudoRandom prng) {
+      // Prefer to cross over actual values and only return null if
+      // both are null or at INVERSE_FREQUENCY_NULL probability.
+      if (value != null && otherValue != null) {
+        return mutator.crossOver(value, otherValue, prng);
+      } else if (value == null && otherValue == null) {
+        return null;
+      } else if (prng.trueInOneOutOf(INVERSE_FREQUENCY_NULL)) {
+        return null;
+      } else {
+        return value != null ? value : otherValue;
+      }
+    }
+
+    @Override
+    public T detach(T value) {
+      if (value == null) {
+        return null;
+      } else {
+        return mutator.detach(value);
+      }
+    }
+
+    @Override
+    public String toDebugString(Predicate<Debuggable> isInCycle) {
+      return "Nullable<" + mutator.toDebugString(isInCycle) + ">";
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/StringMutatorFactory.java b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/StringMutatorFactory.java
new file mode 100644
index 0000000..d77cb9d
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/StringMutatorFactory.java
@@ -0,0 +1,172 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.lang;
+
+import static com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators.mutateThenMapToImmutable;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.*;
+
+import com.code_intelligence.jazzer.mutation.annotation.Ascii;
+import com.code_intelligence.jazzer.mutation.annotation.WithUtf8Length;
+import com.code_intelligence.jazzer.mutation.api.Debuggable;
+import com.code_intelligence.jazzer.mutation.api.MutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import java.lang.reflect.AnnotatedType;
+import java.nio.charset.StandardCharsets;
+import java.util.Optional;
+import java.util.function.Predicate;
+
+final class StringMutatorFactory extends MutatorFactory {
+  private static final int HEADER_MASK = 0b1100_0000;
+  private static final int BODY_MASK = 0b0011_1111;
+  private static final int CONTINUATION_HEADER = 0b1000_0000;
+
+  private static final int DEFAULT_MIN_BYTES = 0;
+
+  private static final int DEFAULT_MAX_BYTES = 1000;
+
+  static void fixUpAscii(byte[] bytes) {
+    for (int i = 0; i < bytes.length; i++) {
+      bytes[i] &= 0x7F;
+    }
+  }
+
+  // Based on
+  // https://github.com/google/libprotobuf-mutator/blob/af3bb18749db3559dc4968dd85319d05168d4b5e/src/utf8_fix.cc#L32
+  // SPDX: Apache-2.0
+  // Copyright 2022 Google LLC
+  static void fixUpUtf8(byte[] bytes) {
+    for (int pos = 0; pos < bytes.length;) {
+      // Leniently read a UTF-8 code point consisting of any byte viewed as the leading byte and up
+      // to three following bytes that have a continuation byte header.
+      //
+      // Since the upper two bits of a byte are 10 with probability 25%, this roughly results in
+      // the following distribution for characters:
+      //
+      // ASCII code point: 75%
+      // two-byte UTF-8: 18.75%
+      // three-byte UTF-8: ~4.7%
+      // four-byte UTF-8: ~1.2%
+      int scanPos = pos + 1;
+      int maxScanPos = Math.min(pos + 4, bytes.length);
+
+      int codePoint = bytes[pos] & 0xFF;
+      for (; scanPos < maxScanPos; scanPos++) {
+        byte b = bytes[scanPos];
+        if ((b & HEADER_MASK) != CONTINUATION_HEADER) {
+          break;
+        }
+        codePoint = (codePoint << 6) + (b & BODY_MASK);
+      }
+
+      int size = scanPos - pos;
+      int nextPos = scanPos;
+      switch (size) {
+        case 1:
+          // Force code point to be ASCII.
+          codePoint &= 0x7F;
+
+          bytes[pos] = (byte) codePoint;
+          break;
+        case 2:
+          codePoint &= 0x7FF;
+          if (codePoint <= 0x7F) {
+            // The code point encoding must not be longer than necessary, so fix up the code point
+            // to actually require two bytes without fixing too many bits.
+            codePoint |= 0x80;
+          }
+
+          bytes[--scanPos] = (byte) (CONTINUATION_HEADER | (codePoint & BODY_MASK));
+          codePoint >>= 6;
+          bytes[pos] = (byte) (0b1100_0000 | codePoint);
+          break;
+        case 3:
+          codePoint &= 0xFFFF;
+          if (codePoint <= 0x7FF) {
+            // The code point encoding must not be longer than necessary, so fix up the code point
+            // to actually require three bytes without fixing too many bits.
+            codePoint |= 0x800;
+          }
+          if (codePoint >= 0xD800 && codePoint <= 0xDFFF) {
+            // The code point must not be a low or high UTF-16 surrogate pair, which are not allowed
+            // in UTF-8.
+            codePoint |= (codePoint & ~0xF000) | 0xE000;
+          }
+
+          bytes[--scanPos] = (byte) (CONTINUATION_HEADER | (codePoint & BODY_MASK));
+          codePoint >>= 6;
+          bytes[--scanPos] = (byte) (CONTINUATION_HEADER | (codePoint & BODY_MASK));
+          codePoint >>= 6;
+          bytes[pos] = (byte) (0b1110_0000 | codePoint);
+          break;
+        case 4:
+          codePoint &= 0x1FFFFF;
+          if (codePoint <= 0xFFFF) {
+            // The code point encoding must not be longer than necessary, so fix up the code point
+            // to actually require four bytes without fixing too many bits.
+            codePoint |= 0x100000;
+          }
+          if (codePoint > 0x10FFFF) {
+            // The code point must be in the valid Unicode range, so fix it up by clearing as few
+            // bits as possible.
+            codePoint &= ~0x10FFFF;
+          }
+
+          bytes[--scanPos] = (byte) (CONTINUATION_HEADER | (codePoint & BODY_MASK));
+          codePoint >>= 6;
+          bytes[--scanPos] = (byte) (CONTINUATION_HEADER | (codePoint & BODY_MASK));
+          codePoint >>= 6;
+          bytes[--scanPos] = (byte) (CONTINUATION_HEADER | (codePoint & BODY_MASK));
+          codePoint >>= 6;
+          bytes[pos] = (byte) (0b1111_0000 | codePoint);
+          break;
+        default:
+          throw new IllegalStateException("Not reached as scanPos <= pos + 4");
+      }
+
+      pos = nextPos;
+    }
+  }
+
+  @Override
+  public Optional<SerializingMutator<?>> tryCreate(AnnotatedType type, MutatorFactory factory) {
+    Optional<WithUtf8Length> utf8Length =
+        Optional.ofNullable(type.getAnnotation(WithUtf8Length.class));
+    int min = utf8Length.map(WithUtf8Length::min).orElse(DEFAULT_MIN_BYTES);
+    int max = utf8Length.map(WithUtf8Length::max).orElse(DEFAULT_MAX_BYTES);
+
+    AnnotatedType innerByteArray = notNull(withLength(asAnnotatedType(byte[].class), min, max));
+
+    return findFirstParentIfClass(type, String.class)
+        .flatMap(parent -> factory.tryCreate(innerByteArray))
+        .map(byteArrayMutator -> {
+          boolean fixUpAscii = type.getDeclaredAnnotation(Ascii.class) != null;
+          return mutateThenMapToImmutable((SerializingMutator<byte[]>) byteArrayMutator,
+              bytes
+              -> {
+                if (fixUpAscii) {
+                  fixUpAscii(bytes);
+                } else {
+                  fixUpUtf8(bytes);
+                }
+                return new String(bytes, StandardCharsets.UTF_8);
+              },
+              string
+              -> string.getBytes(StandardCharsets.UTF_8),
+              (Predicate<Debuggable> inCycle) -> "String");
+        });
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/libfuzzer/BUILD.bazel b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/libfuzzer/BUILD.bazel
new file mode 100644
index 0000000..284264b
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/libfuzzer/BUILD.bazel
@@ -0,0 +1,15 @@
+java_library(
+    name = "libfuzzer",
+    srcs = ["LibFuzzerMutator.java"],
+    visibility = [
+        # libFuzzer's mutators should only by used by mutators for primitive types as we want to get
+        # rid of this dependency eventually.
+        "//src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang:__pkg__",
+        "//src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang:__subpackages__",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/mutation/api",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/support",
+        "//src/main/java/com/code_intelligence/jazzer/runtime:mutator",
+    ],
+)
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/libfuzzer/LibFuzzerMutator.java b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/libfuzzer/LibFuzzerMutator.java
new file mode 100644
index 0000000..c77c75e
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/libfuzzer/LibFuzzerMutator.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.libfuzzer;
+
+import static com.code_intelligence.jazzer.mutation.support.Preconditions.require;
+
+import com.code_intelligence.jazzer.mutation.api.Serializer;
+import com.code_intelligence.jazzer.runtime.Mutator;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.Arrays;
+
+public final class LibFuzzerMutator {
+  /**
+   * Key name to give to {@link System#setProperty(String, String)} to control the size of the
+   * returned array for {@link #defaultMutateMock(byte[], int)}. Only used for testing purposes.
+   */
+  public static final String MOCK_SIZE_KEY = "libfuzzermutator.mock.newsize";
+
+  public static byte[] mutateDefault(byte[] data, int maxSizeIncrease) {
+    byte[] mutatedBytes;
+    if (maxSizeIncrease == 0) {
+      mutatedBytes = data;
+    } else {
+      mutatedBytes = Arrays.copyOf(data, data.length + maxSizeIncrease);
+    }
+    int newSize = defaultMutate(mutatedBytes, data.length);
+    if (newSize == 0) {
+      // Mutation failed. This should happen very rarely.
+      return data;
+    }
+    return Arrays.copyOf(mutatedBytes, newSize);
+  }
+
+  public static <T> T mutateDefault(T value, Serializer<T> serializer, int maxSizeIncrease) {
+    require(maxSizeIncrease >= 0);
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    try {
+      serializer.writeExclusive(value, out);
+    } catch (IOException e) {
+      throw new IllegalStateException(
+          "writeExclusive is not expected to throw if the underlying stream doesn't", e);
+    }
+
+    byte[] mutatedBytes = mutateDefault(out.toByteArray(), maxSizeIncrease);
+
+    try {
+      return serializer.readExclusive(new ByteArrayInputStream(mutatedBytes));
+    } catch (IOException e) {
+      throw new IllegalStateException(
+          "readExclusive is not expected to throw if the underlying stream doesn't", e);
+    }
+  }
+
+  private static int defaultMutate(byte[] buffer, int size) {
+    if (Mutator.SHOULD_MOCK) {
+      return defaultMutateMock(buffer, size);
+    } else {
+      return Mutator.defaultMutateNative(buffer, size);
+    }
+  }
+
+  private static int defaultMutateMock(byte[] buffer, int size) {
+    String newSizeProp = System.getProperty(MOCK_SIZE_KEY);
+    int newSize = Math.min(buffer.length, size + 1);
+    if (newSizeProp != null) {
+      newSize = Integer.parseUnsignedInt(newSizeProp);
+    }
+
+    for (int i = 0; i < newSize; i++) {
+      buffer[i] += i + 1;
+    }
+    return newSize;
+  }
+
+  private LibFuzzerMutator() {}
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/proto/BUILD.bazel b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/proto/BUILD.bazel
new file mode 100644
index 0000000..2c59170
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/proto/BUILD.bazel
@@ -0,0 +1,16 @@
+java_library(
+    name = "proto",
+    srcs = glob(["*.java"]),
+    visibility = [
+        "//src/main/java/com/code_intelligence/jazzer/mutation/mutator:__pkg__",
+        "//src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto:__pkg__",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/mutation/annotation",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/annotation/proto",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/annotation/proto:protobuf_runtime_compile_only",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/api",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/combinator",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/support",
+    ],
+)
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/proto/BuilderAdapters.java b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/proto/BuilderAdapters.java
new file mode 100644
index 0000000..12dcd40
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/proto/BuilderAdapters.java
@@ -0,0 +1,188 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.proto;
+
+import static java.util.Collections.singletonList;
+
+import com.google.protobuf.Descriptors.FieldDescriptor;
+import com.google.protobuf.Message;
+import com.google.protobuf.Message.Builder;
+import java.util.AbstractList;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+final class BuilderAdapters {
+  private BuilderAdapters() {}
+
+  static <T extends Builder, U> List<U> makeMutableRepeatedFieldView(
+      T builder, FieldDescriptor field) {
+    return new AbstractList<U>() {
+      // O(1)
+      @Override
+      public U get(int index) {
+        return (U) builder.getRepeatedField(field, index);
+      }
+
+      // O(1)
+      @Override
+      public int size() {
+        return builder.getRepeatedFieldCount(field);
+      }
+
+      // O(1)
+      @Override
+      public boolean add(U element) {
+        builder.addRepeatedField(field, element);
+        return true;
+      }
+
+      // O(1)
+      @Override
+      public void add(int index, U element) {
+        addAll(index, singletonList(element));
+      }
+
+      // O(size() + other.size())
+      public boolean addAll(int index, Collection<? extends U> other) {
+        // This was benchmarked against the following implementation and found to be faster in all
+        // cases (up to 4x on lists of size 1000):
+        //
+        // for (U element : other) {
+        //   builder.addRepeatedField(field, element);
+        // }
+        // Collections.rotate(subList(index, size()), other.size());
+        int otherSize = other.size();
+        if (otherSize == 0) {
+          return false;
+        }
+
+        int originalSize = size();
+        if (index == originalSize) {
+          for (U element : other) {
+            builder.addRepeatedField(field, element);
+          }
+          return true;
+        }
+
+        int newSize = originalSize + otherSize;
+        ArrayList<U> temp = new ArrayList<>(newSize);
+        for (int i = 0; i < index; i++) {
+          temp.add((U) builder.getRepeatedField(field, i));
+        }
+        temp.addAll(other);
+        for (int i = index; i < originalSize; i++) {
+          temp.add((U) builder.getRepeatedField(field, i));
+        }
+
+        replaceWith(temp);
+        return true;
+      }
+
+      // O(1)
+      @Override
+      public U set(int index, U element) {
+        U previous = get(index);
+        builder.setRepeatedField(field, index, element);
+        return previous;
+      }
+
+      // O(size())
+      @Override
+      public U remove(int index) {
+        U removed = get(index);
+        removeRange(index, index + 1);
+        return removed;
+      }
+
+      // O(size() - (toIndex - fromIndex))
+      @Override
+      protected void removeRange(int fromIndex, int toIndex) {
+        int originalSize = size();
+        int newSize = originalSize - (toIndex - fromIndex);
+        if (newSize == 0) {
+          builder.clearField(field);
+          return;
+        }
+
+        // There is no way to remove individual repeated field entries without clearing the entire
+        // field, so we have to iterate over all entries and keep them in a temporary list.
+        ArrayList<U> temp = new ArrayList<>(newSize);
+        for (int i = 0; i < fromIndex; i++) {
+          temp.add((U) builder.getRepeatedField(field, i));
+        }
+        for (int i = toIndex; i < originalSize; i++) {
+          temp.add((U) builder.getRepeatedField(field, i));
+        }
+
+        replaceWith(temp);
+      }
+
+      private void replaceWith(ArrayList<U> temp) {
+        builder.clearField(field);
+        for (U element : temp) {
+          builder.addRepeatedField(field, element);
+        }
+      }
+    };
+  }
+
+  static <T extends Builder, U> U getPresentFieldOrNull(T builder, FieldDescriptor field) {
+    if (builder.hasField(field)) {
+      return (U) builder.getField(field);
+    } else {
+      return null;
+    }
+  }
+
+  static <T extends Builder, U> void setFieldWithPresence(
+      T builder, FieldDescriptor field, U value) {
+    if (value == null) {
+      builder.clearField(field);
+    } else {
+      builder.setField(field, value);
+    }
+  }
+
+  static <T extends Builder, K, V> Map<K, V> getMapField(T builder, FieldDescriptor field) {
+    int size = builder.getRepeatedFieldCount(field);
+    FieldDescriptor keyField = field.getMessageType().getFields().get(0);
+    FieldDescriptor valueField = field.getMessageType().getFields().get(1);
+    HashMap<K, V> map = new HashMap<>(size);
+    for (int i = 0; i < size; i++) {
+      Message entry = (Message) builder.getRepeatedField(field, i);
+      map.put((K) entry.getField(keyField), (V) entry.getField(valueField));
+    }
+    return map;
+  }
+
+  static <T extends Builder, K, V> void setMapField(
+      Builder builder, FieldDescriptor field, Map<K, V> map) {
+    builder.clearField(field);
+    FieldDescriptor keyField = field.getMessageType().getFields().get(0);
+    FieldDescriptor valueField = field.getMessageType().getFields().get(1);
+    Builder entryBuilder = builder.newBuilderForField(field);
+    for (Entry<K, V> entry : map.entrySet()) {
+      entryBuilder.setField(keyField, entry.getKey());
+      entryBuilder.setField(valueField, entry.getValue());
+      builder.addRepeatedField(field, entryBuilder.build());
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/proto/BuilderMutatorFactory.java b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/proto/BuilderMutatorFactory.java
new file mode 100644
index 0000000..85427c6
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/proto/BuilderMutatorFactory.java
@@ -0,0 +1,451 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.proto;
+
+import static com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators.assemble;
+import static com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators.combine;
+import static com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators.fixedValue;
+import static com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators.mutateIndices;
+import static com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators.mutateProperty;
+import static com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators.mutateSumInPlace;
+import static com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators.mutateThenMapToImmutable;
+import static com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators.mutateViaView;
+import static com.code_intelligence.jazzer.mutation.mutator.proto.BuilderAdapters.getMapField;
+import static com.code_intelligence.jazzer.mutation.mutator.proto.BuilderAdapters.getPresentFieldOrNull;
+import static com.code_intelligence.jazzer.mutation.mutator.proto.BuilderAdapters.makeMutableRepeatedFieldView;
+import static com.code_intelligence.jazzer.mutation.mutator.proto.BuilderAdapters.setFieldWithPresence;
+import static com.code_intelligence.jazzer.mutation.mutator.proto.BuilderAdapters.setMapField;
+import static com.code_intelligence.jazzer.mutation.mutator.proto.TypeLibrary.getDefaultInstance;
+import static com.code_intelligence.jazzer.mutation.mutator.proto.TypeLibrary.withoutInitIfRecursive;
+import static com.code_intelligence.jazzer.mutation.support.InputStreamSupport.cap;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.asAnnotatedType;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.asSubclassOrEmpty;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.findFirstParentIfClass;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.notNull;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.withExtraAnnotations;
+import static java.util.Arrays.stream;
+import static java.util.Objects.requireNonNull;
+import static java.util.function.UnaryOperator.identity;
+import static java.util.stream.Collectors.groupingBy;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toMap;
+
+import com.code_intelligence.jazzer.mutation.annotation.proto.AnySource;
+import com.code_intelligence.jazzer.mutation.annotation.proto.WithDefaultInstance;
+import com.code_intelligence.jazzer.mutation.api.ChainedMutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.InPlaceMutator;
+import com.code_intelligence.jazzer.mutation.api.MutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.Serializer;
+import com.code_intelligence.jazzer.mutation.api.SerializingInPlaceMutator;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.code_intelligence.jazzer.mutation.support.Preconditions;
+import com.google.protobuf.Any;
+import com.google.protobuf.Descriptors.Descriptor;
+import com.google.protobuf.Descriptors.EnumDescriptor;
+import com.google.protobuf.Descriptors.EnumValueDescriptor;
+import com.google.protobuf.Descriptors.FieldDescriptor;
+import com.google.protobuf.Descriptors.FieldDescriptor.JavaType;
+import com.google.protobuf.Descriptors.OneofDescriptor;
+import com.google.protobuf.DynamicMessage;
+import com.google.protobuf.InvalidProtocolBufferException;
+import com.google.protobuf.Message;
+import com.google.protobuf.Message.Builder;
+import com.google.protobuf.UnknownFieldSet;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.AnnotatedType;
+import java.util.HashMap;
+import java.util.IdentityHashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+public final class BuilderMutatorFactory extends MutatorFactory {
+  private <T extends Builder, U> InPlaceMutator<T> mutatorForField(
+      FieldDescriptor field, Annotation[] annotations, MutatorFactory factory) {
+    factory = withDescriptorDependentMutatorFactoryIfNeeded(factory, field, annotations);
+    AnnotatedType typeToMutate = TypeLibrary.getTypeToMutate(field);
+    requireNonNull(typeToMutate, () -> "Java class not specified for " + field);
+
+    InPlaceMutator<T> mutator;
+    if (field.isMapField()) {
+      SerializingInPlaceMutator<Map> underlyingMutator =
+          (SerializingInPlaceMutator<Map>) factory.createInPlaceOrThrow(typeToMutate);
+      mutator = mutateProperty(builder
+          -> getMapField(builder, field),
+          underlyingMutator, (builder, value) -> setMapField(builder, field, value));
+    } else if (field.isRepeated()) {
+      SerializingInPlaceMutator<List<U>> underlyingMutator =
+          (SerializingInPlaceMutator<List<U>>) factory.createInPlaceOrThrow(typeToMutate);
+      mutator =
+          mutateViaView(builder -> makeMutableRepeatedFieldView(builder, field), underlyingMutator);
+    } else if (field.hasPresence()) {
+      SerializingMutator<U> underlyingMutator =
+          (SerializingMutator<U>) factory.createOrThrow(typeToMutate);
+      mutator = mutateProperty(builder
+          -> getPresentFieldOrNull(builder, field),
+          underlyingMutator, (builder, value) -> setFieldWithPresence(builder, field, value));
+    } else {
+      SerializingMutator<U> underlyingMutator =
+          (SerializingMutator<U>) factory.createOrThrow(typeToMutate);
+      mutator = mutateProperty(builder
+          -> (U) builder.getField(field),
+          underlyingMutator, (builder, value) -> builder.setField(field, value));
+    }
+
+    // If recursive message fields (i.e. those that have themselves as transitive subfields) are
+    // initialized eagerly, they tend to nest very deeply, which easily results in stack overflows.
+    // We guard against that by making their init a no-op and instead initialize them layer by layer
+    // in mutations.
+    return withoutInitIfRecursive(mutator, field);
+  }
+
+  private MutatorFactory withDescriptorDependentMutatorFactoryIfNeeded(
+      MutatorFactory originalFactory, FieldDescriptor field, Annotation[] annotations) {
+    if (field.getJavaType() == JavaType.ENUM) {
+      // Proto enum fields are special as their type (EnumValueDescriptor) does not encode their
+      // domain - we need the actual EnumDescriptor instance.
+      return new ChainedMutatorFactory(originalFactory, new MutatorFactory() {
+        @Override
+        public Optional<SerializingMutator<?>> tryCreate(
+            AnnotatedType type, MutatorFactory factory) {
+          return findFirstParentIfClass(type, EnumValueDescriptor.class).map(parent -> {
+            EnumDescriptor enumType = field.getEnumType();
+            List<EnumValueDescriptor> values = enumType.getValues();
+            String name = enumType.getName();
+            if (values.size() == 1) {
+              // While we generally prefer to error out instead of creating a mutator that can't
+              // actually mutate its domain, we can't do that for proto enum fields as the user
+              // creating the fuzz test may not be in a position to modify the existing proto
+              // definition.
+              return fixedValue(values.get(0));
+            } else {
+              return mutateThenMapToImmutable(mutateIndices(values.size()), values::get,
+                  EnumValueDescriptor::getIndex, unused -> "Enum<" + name + ">");
+            }
+          });
+        }
+      });
+    } else if (field.getJavaType() == JavaType.MESSAGE) {
+      Descriptor messageDescriptor;
+      if (field.isMapField()) {
+        // Map fields are represented as messages, but we mutate them as actual Java Maps. In case
+        // the values of the proto map are themselves messages, we need to mutate their type.
+        FieldDescriptor valueField = field.getMessageType().getFields().get(1);
+        if (valueField.getJavaType() != JavaType.MESSAGE) {
+          return originalFactory;
+        }
+        messageDescriptor = valueField.getMessageType();
+      } else {
+        messageDescriptor = field.getMessageType();
+      }
+      return new ChainedMutatorFactory(originalFactory, new MutatorFactory() {
+        @Override
+        public Optional<SerializingMutator<?>> tryCreate(
+            AnnotatedType type, MutatorFactory factory) {
+          return asSubclassOrEmpty(type, Message.Builder.class).flatMap(clazz -> {
+            // BuilderMutatorFactory only handles subclasses of Message.Builder and requests
+            // Message.Builder itself for message fields, which we handle here.
+            if (clazz != Message.Builder.class) {
+              return Optional.empty();
+            }
+            // It is important that we use originalFactory here instead of factory: factory has this
+            // field-specific message mutator appended, but this mutator should only be used for
+            // this particular field and not any message subfields.
+            return Optional.of(makeBuilderMutator(originalFactory,
+                DynamicMessage.getDefaultInstance(messageDescriptor), annotations));
+          });
+        }
+      });
+    } else {
+      return originalFactory;
+    }
+  }
+
+  private <T extends Builder> Stream<InPlaceMutator<T>> mutatorsForFields(
+      Optional<OneofDescriptor> oneofField, List<FieldDescriptor> fields, Annotation[] annotations,
+      MutatorFactory factory) {
+    if (oneofField.isPresent()) {
+      // oneof fields are mutated as one as mutating them independently would cause the mutator to
+      // erratically switch between the different states. The individual fields are kept in the
+      // order in which they are defined in the .proto file.
+      OneofDescriptor oneofDescriptor = oneofField.get();
+
+      IdentityHashMap<FieldDescriptor, Integer> indexInOneof =
+          new IdentityHashMap<>(oneofDescriptor.getFieldCount());
+      for (int i = 0; i < oneofDescriptor.getFieldCount(); i++) {
+        indexInOneof.put(oneofDescriptor.getField(i), i);
+      }
+
+      return Stream.of(mutateSumInPlace(
+          (T builder)
+              -> {
+            FieldDescriptor setField = builder.getOneofFieldDescriptor(oneofDescriptor);
+            if (setField == null) {
+              return -1;
+            } else {
+              return indexInOneof.get(setField);
+            }
+          },
+          // Mutating to the unset (-1) state is handled by the individual field mutators, which
+          // are created nullable as oneof fields report that they track presence.
+          fields.stream()
+              .map(field -> mutatorForField(field, annotations, factory))
+              .toArray(InPlaceMutator[] ::new)));
+    } else {
+      // All non-oneof fields are mutated independently, using the order in which they are declared
+      // in the .proto file (which may not coincide with the order by field number).
+      return fields.stream().map(field -> mutatorForField(field, annotations, factory));
+    }
+  }
+
+  private static <M extends Message, B extends Builder> Serializer<B> makeBuilderSerializer(
+      M defaultInstance) {
+    return new Serializer<B>() {
+      @Override
+      public B read(DataInputStream in) throws IOException {
+        int length = Math.max(in.readInt(), 0);
+        return (B) parseLeniently(cap(in, length));
+      }
+
+      @Override
+      public B readExclusive(InputStream in) throws IOException {
+        return (B) parseLeniently(in);
+      }
+
+      private Builder parseLeniently(InputStream in) throws IOException {
+        Builder builder = defaultInstance.toBuilder();
+        try {
+          builder.mergeFrom(in);
+        } catch (InvalidProtocolBufferException ignored) {
+          // builder has been partially modified with what could be decoded before the parser error.
+        }
+        // We never want the fuzz test to see unknown fields and our mutations should never produce
+        // them.
+        builder.setUnknownFields(UnknownFieldSet.getDefaultInstance());
+        // Required fields may not have been set at this point. We set them to default values to
+        // prevent an exception when built.
+        forceInitialized(builder);
+        return builder;
+      }
+
+      private void forceInitialized(Builder builder) {
+        if (builder.isInitialized()) {
+          return;
+        }
+        for (FieldDescriptor field : builder.getDescriptorForType().getFields()) {
+          if (!field.isRequired()) {
+            continue;
+          }
+          if (field.getJavaType() == JavaType.MESSAGE) {
+            forceInitialized(builder.getFieldBuilder(field));
+          } else if (!builder.hasField(field)) {
+            builder.setField(field, field.getDefaultValue());
+          }
+        }
+      }
+
+      @Override
+      public void write(Builder builder, DataOutputStream out) throws IOException {
+        Message message = builder.build();
+        out.writeInt(message.getSerializedSize());
+        message.writeTo(out);
+      }
+
+      @Override
+      public void writeExclusive(Builder builder, OutputStream out) throws IOException {
+        builder.build().writeTo(out);
+      }
+
+      @Override
+      public B detach(Builder builder) {
+        return (B) builder.build().toBuilder();
+      }
+    };
+  }
+
+  /*
+   * Ensures that only a single instance is created per builder class and shared among all mutators
+   * that need it. This ensures that arbitrarily nested recursive structures such as a Protobuf
+   * message type that contains itself as a message field are representable as fixed-size mutator
+   * structures.
+   *
+   * Note: The resulting mutator structures may no longer form a tree: If A is a protobuf message
+   * type with a message field B and B in turn has a message field of type A, then the mutators for
+   * A and B will reference each other, forming a cycle.
+   */
+  private final HashMap<CacheKey, SerializingMutator<? extends Builder>> internedMutators =
+      new HashMap<>();
+
+  private SerializingMutator<Any.Builder> mutatorForAny(
+      AnySource anySource, MutatorFactory factory) {
+    Map<String, Integer> typeUrlToIndex =
+        IntStream.range(0, anySource.value().length)
+            .boxed()
+            .collect(toMap(i -> getTypeUrl(getDefaultInstance(anySource.value()[i])), identity()));
+
+    return assemble(mutator
+        -> internedMutators.put(new CacheKey(Any.getDescriptor(), anySource), mutator),
+        Any.getDefaultInstance()::toBuilder, makeBuilderSerializer(Any.getDefaultInstance()),
+        ()
+            -> mutateSumInPlace(
+                // Corpus entries may contain Anys with arbitrary (and even invalid) messages, so we
+                // fall back to mutating the first message type if the type isn't recognized.
+                (Any.Builder builder)
+                    -> typeUrlToIndex.getOrDefault(builder.getTypeUrl(), 0),
+                stream(anySource.value())
+                    .map(messageClass -> {
+                      SerializingMutator<Message> messageMutator =
+                          (SerializingMutator<Message>) factory.createOrThrow(notNull(
+                              withExtraAnnotations(asAnnotatedType(messageClass), anySource)));
+                      return mutateProperty(
+                          (Any.Builder anyBuilder)
+                              -> {
+                            try {
+                              return anyBuilder.build().unpack(messageClass);
+                            } catch (InvalidProtocolBufferException e) {
+                              // This can only happen if the corpus contains an invalid Any.
+                              return getDefaultInstance(messageClass);
+                            }
+                          },
+                          messageMutator,
+                          (Any.Builder any, Message message) -> {
+                            any.setTypeUrl(getTypeUrl(message));
+                            any.setValue(message.toByteString());
+                          });
+                    })
+                    .toArray(InPlaceMutator[] ::new)));
+  }
+
+  private static String getTypeUrl(Message message) {
+    // We only support the default "type.googleapis.com" prefix.
+    // https://github.com/protocolbuffers/protobuf/blob/main/src/google/protobuf/any.proto#L94
+    return "type.googleapis.com/" + message.getDescriptorForType().getFullName();
+  }
+
+  @Override
+  public Optional<SerializingMutator<?>> tryCreate(AnnotatedType type, MutatorFactory factory) {
+    return asSubclassOrEmpty(type, Builder.class).flatMap(builderClass -> {
+      Message defaultInstance;
+      WithDefaultInstance withDefaultInstance = type.getAnnotation(WithDefaultInstance.class);
+      if (withDefaultInstance != null) {
+        defaultInstance = getDefaultInstance(withDefaultInstance);
+      } else if (builderClass == DynamicMessage.Builder.class) {
+        throw new IllegalArgumentException(
+            "To mutate a dynamic message, add a @WithDefaultInstance annotation specifying the"
+            + " fully qualified method name of a static method returning a default instance");
+      } else if (builderClass == Message.Builder.class) {
+        // Handled by a custom mutator factory for message fields that is created in
+        // withDescriptorDependentMutatorFactoryIfNeeded. Without @WithDefaultInstance,
+        // BuilderMutatorFactory only handles proper subclasses, which correspond to generated
+        // message types.
+        return Optional.empty();
+      } else {
+        defaultInstance =
+            getDefaultInstance((Class<? extends Message>) builderClass.getEnclosingClass());
+      }
+
+      return Optional.of(
+          makeBuilderMutator(factory, defaultInstance, type.getDeclaredAnnotations()));
+    });
+  }
+
+  private SerializingMutator<?> makeBuilderMutator(
+      MutatorFactory factory, Message defaultInstance, Annotation[] annotations) {
+    AnySource anySource = (AnySource) stream(annotations)
+                              .filter(annotation -> annotation.annotationType() == AnySource.class)
+                              .findFirst()
+                              .orElse(null);
+    Preconditions.require(anySource == null || anySource.value().length > 0,
+        "@AnySource must list a non-empty list of classes");
+    Descriptor descriptor = defaultInstance.getDescriptorForType();
+
+    CacheKey cacheKey = new CacheKey(descriptor, anySource);
+    if (internedMutators.containsKey(cacheKey)) {
+      return internedMutators.get(cacheKey);
+    }
+
+    // If there is no @AnySource, mutate the Any.Builder fields just like a regular message.
+    // TODO: Determine whether we should show a warning in this case.
+    if (descriptor.equals(Any.getDescriptor()) && anySource != null) {
+      return mutatorForAny(anySource, factory);
+    }
+
+    // assemble inserts the instance of the newly created builder mutator into the
+    // internedMutators map *before* recursively creating the mutators for its fields, which
+    // ensures that the recursion is finite (bounded by the total number of distinct message types
+    // that transitively occur as field types on the current message type).
+    return assemble(mutator
+        -> internedMutators.put(cacheKey, mutator),
+        defaultInstance::toBuilder, makeBuilderSerializer(defaultInstance),
+        ()
+            -> combine(
+                descriptor.getFields()
+                    .stream()
+                    // Keep oneofs sorted by the first appearance of their fields in the
+                    // .proto file.
+                    .collect(groupingBy(
+                        // groupingBy does not support null keys. We use getRealContainingOneof()
+                        // instead of getContainingOneof() as the latter also reports oneofs for
+                        // proto3 optional fields, which we handle separately.
+                        fieldDescriptor
+                        -> Optional.ofNullable(fieldDescriptor.getRealContainingOneof()),
+                        LinkedHashMap::new, toList()))
+                    .entrySet()
+                    .stream()
+                    .flatMap(entry
+                        -> mutatorsForFields(entry.getKey(), entry.getValue(),
+                            anySource == null ? new Annotation[0] : new Annotation[] {anySource},
+                            factory))
+                    .toArray(InPlaceMutator[] ::new)));
+  }
+
+  private static final class CacheKey {
+    private final Descriptor descriptor;
+    private final AnySource anySource;
+
+    private CacheKey(Descriptor descriptor, AnySource anySource) {
+      this.descriptor = descriptor;
+      this.anySource = anySource;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (o == null || getClass() != o.getClass()) {
+        return false;
+      }
+      CacheKey cacheKey = (CacheKey) o;
+      return descriptor == cacheKey.descriptor && Objects.equals(anySource, cacheKey.anySource);
+    }
+
+    @Override
+    public int hashCode() {
+      return 31 * System.identityHashCode(descriptor) + Objects.hashCode(anySource);
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/proto/ByteStringMutatorFactory.java b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/proto/ByteStringMutatorFactory.java
new file mode 100644
index 0000000..a01ffae
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/proto/ByteStringMutatorFactory.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.proto;
+
+import static com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators.mutateThenMapToImmutable;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.asAnnotatedType;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.findFirstParentIfClass;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.notNull;
+
+import com.code_intelligence.jazzer.mutation.api.MutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.google.protobuf.ByteString;
+import java.lang.reflect.AnnotatedType;
+import java.util.Optional;
+
+final class ByteStringMutatorFactory extends MutatorFactory {
+  ByteStringMutatorFactory() {}
+
+  @Override
+  public Optional<SerializingMutator<?>> tryCreate(AnnotatedType type, MutatorFactory factory) {
+    return findFirstParentIfClass(type, ByteString.class)
+        .flatMap(parent -> factory.tryCreate(notNull(asAnnotatedType(byte[].class))))
+        .map(byteArrayMutator
+            -> mutateThenMapToImmutable((SerializingMutator<byte[]>) byteArrayMutator,
+                ByteString::copyFrom, ByteString::toByteArray));
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/proto/MessageMutatorFactory.java b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/proto/MessageMutatorFactory.java
new file mode 100644
index 0000000..eb3220f
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/proto/MessageMutatorFactory.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.proto;
+
+import static com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators.mutateThenMapToImmutable;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.asAnnotatedType;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.asSubclassOrEmpty;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.withExtraAnnotations;
+
+import com.code_intelligence.jazzer.mutation.api.MutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.google.protobuf.Message;
+import com.google.protobuf.Message.Builder;
+import java.lang.reflect.AnnotatedType;
+import java.util.Arrays;
+import java.util.Optional;
+
+public final class MessageMutatorFactory extends MutatorFactory {
+  @Override
+  public Optional<SerializingMutator<?>> tryCreate(
+      AnnotatedType messageType, MutatorFactory factory) {
+    return asSubclassOrEmpty(messageType, Message.class)
+        // If the Message class doesn't have a nested Builder class, it is not a concrete generated
+        // message and we can't mutate it.
+        .flatMap(messageClass
+            -> Arrays.stream(messageClass.getDeclaredClasses())
+                   .filter(clazz -> clazz.getSimpleName().equals("Builder"))
+                   .findFirst())
+        .flatMap(builderClass
+            ->
+            // Forward the annotations (e.g. @NotNull) on the Message type to the Builder type.
+            factory.tryCreateInPlace(
+                withExtraAnnotations(asAnnotatedType(builderClass), messageType.getAnnotations())))
+        .map(builderMutator
+            -> mutateThenMapToImmutable(
+                (SerializingMutator<Builder>) builderMutator, Builder::build, Message::toBuilder));
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/proto/ProtoMutators.java b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/proto/ProtoMutators.java
new file mode 100644
index 0000000..acb25cd
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/proto/ProtoMutators.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.proto;
+
+import com.code_intelligence.jazzer.mutation.api.ChainedMutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.MutatorFactory;
+
+public final class ProtoMutators {
+  private ProtoMutators() {}
+
+  public static MutatorFactory newFactory() {
+    try {
+      Class.forName("com.google.protobuf.Message");
+      return new ChainedMutatorFactory(
+          new ByteStringMutatorFactory(), new MessageMutatorFactory(), new BuilderMutatorFactory());
+    } catch (ClassNotFoundException e) {
+      return new ChainedMutatorFactory();
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/proto/TypeLibrary.java b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/proto/TypeLibrary.java
new file mode 100644
index 0000000..43338f1
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/proto/TypeLibrary.java
@@ -0,0 +1,179 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.proto;
+
+import static com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators.withoutInit;
+import static com.code_intelligence.jazzer.mutation.support.Preconditions.check;
+import static com.code_intelligence.jazzer.mutation.support.StreamSupport.entry;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.asAnnotatedType;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.containedInDirectedCycle;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.notNull;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.withTypeArguments;
+import static java.lang.String.format;
+import static java.util.Collections.unmodifiableMap;
+import static java.util.stream.Collectors.collectingAndThen;
+import static java.util.stream.Collectors.toMap;
+
+import com.code_intelligence.jazzer.mutation.annotation.NotNull;
+import com.code_intelligence.jazzer.mutation.annotation.proto.WithDefaultInstance;
+import com.code_intelligence.jazzer.mutation.api.InPlaceMutator;
+import com.code_intelligence.jazzer.mutation.support.TypeHolder;
+import com.google.protobuf.ByteString;
+import com.google.protobuf.Descriptors.Descriptor;
+import com.google.protobuf.Descriptors.EnumValueDescriptor;
+import com.google.protobuf.Descriptors.FieldDescriptor;
+import com.google.protobuf.Descriptors.FieldDescriptor.JavaType;
+import com.google.protobuf.DynamicMessage;
+import com.google.protobuf.Message;
+import com.google.protobuf.Message.Builder;
+import java.lang.reflect.AnnotatedType;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.stream.Stream;
+
+final class TypeLibrary {
+  private static final AnnotatedType RAW_LIST = new TypeHolder<@NotNull List>() {}.annotatedType();
+  private static final AnnotatedType RAW_MAP = new TypeHolder<@NotNull Map>() {}.annotatedType();
+  private static final Map<JavaType, AnnotatedType> BASE_TYPE_WITH_PRESENCE =
+      Stream
+          .of(entry(JavaType.BOOLEAN, Boolean.class), entry(JavaType.BYTE_STRING, ByteString.class),
+              entry(JavaType.DOUBLE, Double.class), entry(JavaType.ENUM, EnumValueDescriptor.class),
+              entry(JavaType.FLOAT, Float.class), entry(JavaType.INT, Integer.class),
+              entry(JavaType.LONG, Long.class), entry(JavaType.MESSAGE, Message.class),
+              entry(JavaType.STRING, String.class))
+          .collect(collectingAndThen(toMap(Entry::getKey, e -> asAnnotatedType(e.getValue())),
+              map -> unmodifiableMap(new EnumMap<>(map))));
+
+  private TypeLibrary() {}
+
+  static <T extends Builder> AnnotatedType getTypeToMutate(FieldDescriptor field) {
+    if (field.isRequired()) {
+      return getBaseType(field);
+    } else if (field.isMapField()) {
+      // Map fields are represented as repeated message fields, so this check has to come before the
+      // one for regular repeated fields.
+      AnnotatedType keyType = getBaseType(field.getMessageType().getFields().get(0));
+      AnnotatedType valueType = getBaseType(field.getMessageType().getFields().get(1));
+      return withTypeArguments(RAW_MAP, keyType, valueType);
+    } else if (field.isRepeated()) {
+      return withTypeArguments(RAW_LIST, getBaseType(field));
+    } else if (field.hasPresence()) {
+      return BASE_TYPE_WITH_PRESENCE.get(field.getJavaType());
+    } else {
+      return getBaseType(field);
+    }
+  }
+
+  private static <T extends Builder> AnnotatedType getBaseType(FieldDescriptor field) {
+    return notNull(BASE_TYPE_WITH_PRESENCE.get(field.getJavaType()));
+  }
+
+  static <T> InPlaceMutator<T> withoutInitIfRecursive(
+      InPlaceMutator<T> mutator, FieldDescriptor field) {
+    if (field.isRequired() || !isRecursiveField(field)) {
+      return mutator;
+    }
+    return withoutInit(mutator);
+  }
+
+  private static boolean isRecursiveField(FieldDescriptor field) {
+    return containedInDirectedCycle(field, f -> {
+      // For map fields, only the value can be a message.
+      FieldDescriptor realField = f.isMapField() ? f.getMessageType().getFields().get(1) : f;
+      if (realField.getJavaType() != JavaType.MESSAGE) {
+        return Stream.empty();
+      }
+      return realField.getMessageType().getFields().stream();
+    });
+  }
+
+  static Message getDefaultInstance(Class<? extends Message> messageClass) {
+    Method getDefaultInstance;
+    try {
+      getDefaultInstance = messageClass.getMethod("getDefaultInstance");
+      check(Modifier.isStatic(getDefaultInstance.getModifiers()));
+    } catch (NoSuchMethodException e) {
+      throw new IllegalStateException(
+          format("Message class for builder type %s does not have a getDefaultInstance method",
+              messageClass.getName()),
+          e);
+    }
+    try {
+      return (Message) getDefaultInstance.invoke(null);
+    } catch (IllegalAccessException | InvocationTargetException e) {
+      throw new IllegalStateException(
+          format(getDefaultInstance + " isn't accessible or threw an exception"), e);
+    }
+  }
+
+  static Message getDefaultInstance(WithDefaultInstance withDefaultInstance) {
+    String[] parts = withDefaultInstance.value().split("#");
+    if (parts.length != 2) {
+      throw new IllegalArgumentException(
+          format("Expected @WithDefaultInstance(\"%s\") to specify a fully-qualified method name"
+                  + " (e.g. com.example.MyClass#getDefaultInstance)",
+              withDefaultInstance.value()));
+    }
+
+    Class<?> clazz;
+    try {
+      clazz = Class.forName(parts[0]);
+    } catch (ClassNotFoundException e) {
+      throw new IllegalArgumentException(
+          format("Failed to find class '%s' specified by @WithDefaultInstance(\"%s\")", parts[0],
+              withDefaultInstance.value()),
+          e);
+    }
+
+    Method method;
+    try {
+      method = clazz.getDeclaredMethod(parts[1]);
+      method.setAccessible(true);
+    } catch (NoSuchMethodException e) {
+      throw new IllegalArgumentException(
+          format("Failed to find method specified by @WithDefaultInstance(\"%s\")",
+              withDefaultInstance.value()),
+          e);
+    }
+    if (!Modifier.isStatic(method.getModifiers())) {
+      throw new IllegalArgumentException(
+          format("Expected method specified by @WithDefaultInstance(\"%s\") to be static",
+              withDefaultInstance.value()));
+    }
+    if (!Message.class.isAssignableFrom(method.getReturnType())) {
+      throw new IllegalArgumentException(format(
+          "Expected return type of method specified by @WithDefaultInstance(\"%s\") to be a"
+              + " subtype of %s, got %s",
+          withDefaultInstance.value(), Message.class.getName(), method.getReturnType().getName()));
+    }
+
+    try {
+      return (Message) method.invoke(null);
+    } catch (IllegalAccessException | InvocationTargetException e) {
+      throw new IllegalArgumentException(
+          format("Failed to execute method specified by @WithDefaultInstance(\"%s\")",
+              withDefaultInstance.value()),
+          e);
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/support/BUILD.bazel b/src/main/java/com/code_intelligence/jazzer/mutation/support/BUILD.bazel
new file mode 100644
index 0000000..5765cee
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/support/BUILD.bazel
@@ -0,0 +1,11 @@
+java_library(
+    name = "support",
+    srcs = glob(["*.java"]),
+    visibility = [
+        "//src/main/java/com/code_intelligence/jazzer/mutation:__subpackages__",
+        "//src/test/java/com/code_intelligence/jazzer/mutation:__subpackages__",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/mutation/annotation",
+    ],
+)
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/support/ExceptionSupport.java b/src/main/java/com/code_intelligence/jazzer/mutation/support/ExceptionSupport.java
new file mode 100644
index 0000000..bd5f434
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/support/ExceptionSupport.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.support;
+
+public final class ExceptionSupport {
+  /**
+   * Allows throwing any {@link Throwable} unchanged as if it were an unchecked exception.
+   *
+   * <p>Example: {@code throw asUnchecked(new IOException())}
+   */
+  @SuppressWarnings("unchecked")
+  public static <T extends Throwable> T asUnchecked(Throwable t) throws T {
+    throw(T) t;
+  }
+
+  private ExceptionSupport() {}
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/support/InputStreamSupport.java b/src/main/java/com/code_intelligence/jazzer/mutation/support/InputStreamSupport.java
new file mode 100644
index 0000000..c643fea
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/support/InputStreamSupport.java
@@ -0,0 +1,256 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.support;
+
+import static com.code_intelligence.jazzer.mutation.support.Preconditions.require;
+import static java.lang.Math.max;
+import static java.lang.Math.min;
+import static java.util.Objects.requireNonNull;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayDeque;
+import java.util.Arrays;
+import java.util.Queue;
+
+public final class InputStreamSupport {
+  public static byte[] readAllBytes(InputStream stream) throws IOException {
+    requireNonNull(stream);
+    Queue<byte[]> buffers = new ArrayDeque<>();
+    int arrayLength = 0;
+  outer:
+    while (true) {
+      byte[] buffer = new byte[max(8192, stream.available())];
+      buffers.add(buffer);
+      int off = 0;
+      while (off < buffer.length) {
+        int bytesRead = stream.read(buffer, off, buffer.length - off);
+        if (bytesRead == -1) {
+          break outer;
+        }
+        off += bytesRead;
+        arrayLength += bytesRead;
+      }
+    }
+
+    byte[] result = new byte[arrayLength];
+    int offset = 0;
+    byte[] buffer;
+    int remaining = arrayLength;
+    while ((buffer = buffers.poll()) != null) {
+      int toCopy = min(buffer.length, remaining);
+      System.arraycopy(buffer, 0, result, offset, toCopy);
+      remaining -= toCopy;
+    }
+    return result;
+  }
+
+  private static final InputStream infiniteZerosStream = new ExtendWithNullInputStream();
+
+  /**
+   * @return an infinite stream consisting of 0s
+   */
+  public static InputStream infiniteZeros() {
+    return infiniteZerosStream;
+  }
+
+  /**
+   * @return {@code stream} extended with 0s to an infinite stream
+   */
+  public static InputStream extendWithZeros(InputStream stream) {
+    if (stream instanceof ExtendWithNullInputStream) {
+      return stream;
+    }
+    return new ExtendWithNullInputStream(requireNonNull(stream));
+  }
+
+  public static final class ExtendWithNullInputStream extends InputStream {
+    private static final InputStream ALWAYS_EOF = new ByteArrayInputStream(new byte[0]);
+    private final InputStream stream;
+    private boolean eof;
+
+    private ExtendWithNullInputStream() {
+      this.stream = ALWAYS_EOF;
+      this.eof = true;
+    }
+
+    private ExtendWithNullInputStream(InputStream stream) {
+      this.stream = stream;
+      this.eof = false;
+    }
+
+    @Override
+    public int read() throws IOException {
+      if (eof) {
+        return 0;
+      }
+
+      int res = stream.read();
+      if (res != -1) {
+        return res;
+      } else {
+        eof = true;
+        return 0;
+      }
+    }
+
+    @Override
+    public int read(byte[] b, int off, int len) throws IOException {
+      if (eof) {
+        Arrays.fill(b, off, off + len, (byte) 0);
+      } else {
+        int bytesRead = stream.read(b, off, len);
+        if (bytesRead < len) {
+          eof = true;
+          Arrays.fill(b, max(off, off + bytesRead), off + len, (byte) 0);
+        }
+      }
+      return len;
+    }
+
+    @Override
+    public int available() throws IOException {
+      if (eof) {
+        return Integer.MAX_VALUE;
+      } else {
+        return stream.available();
+      }
+    }
+
+    @Override
+    public void close() throws IOException {
+      stream.close();
+    }
+  }
+
+  /**
+   * @return a stream with the first {@code bytes} bytes of {@code stream}
+   */
+  public static InputStream cap(InputStream stream, long bytes) {
+    requireNonNull(stream);
+    require(bytes >= 0, "bytes must be non-negative");
+    return new CappedInputStream(stream, bytes);
+  }
+
+  private static final class CappedInputStream extends InputStream {
+    private final InputStream stream;
+    private long remaining;
+
+    CappedInputStream(InputStream stream, long remaining) {
+      this.stream = stream;
+      this.remaining = remaining;
+    }
+
+    @Override
+    public int read() throws IOException {
+      if (remaining == 0) {
+        return -1;
+      }
+
+      int res = stream.read();
+      if (res != -1) {
+        --remaining;
+      }
+      return res;
+    }
+
+    @Override
+    public int read(byte[] b, int off, int len) throws IOException {
+      if (remaining == 0) {
+        return -1;
+      }
+
+      int res = stream.read(b, off, (int) min(len, remaining));
+      if (res != -1) {
+        remaining -= res;
+      }
+      return res;
+    }
+
+    @Override
+    public int available() throws IOException {
+      return (int) min(stream.available(), remaining);
+    }
+
+    @Override
+    public void close() throws IOException {
+      stream.close();
+    }
+  }
+
+  /**
+   * Wraps a given stream with the functionality to detect if it was read exactly.
+   * To do so, the stream must provide an accurate implementation of {@link
+   * InputStream#available()}, hence it's restricted to {@link ByteArrayInputStream} for now.
+   *
+   * @return {@code stream} extended that detects if it was consumed exactly
+   */
+  public static ReadExactlyInputStream extendWithReadExactly(ByteArrayInputStream stream) {
+    return new ReadExactlyInputStream(requireNonNull(stream));
+  }
+
+  public static final class ReadExactlyInputStream extends InputStream {
+    private final InputStream stream;
+    private boolean eof;
+
+    private ReadExactlyInputStream(InputStream stream) {
+      this.stream = stream;
+      this.eof = false;
+    }
+
+    public boolean isConsumedExactly() {
+      try {
+        // Forwards availability check to the underlying ByteInputStream,
+        // which is accurate for the number of available bytes.
+        return !eof && available() == 0;
+      } catch (IOException e) {
+        return false;
+      }
+    }
+
+    @Override
+    public int read() throws IOException {
+      int res = stream.read();
+      if (res == -1) {
+        eof = true;
+      }
+      return res;
+    }
+
+    @Override
+    public int read(byte[] b, int off, int len) throws IOException {
+      int read = stream.read(b, off, len);
+      if (read < len) {
+        eof = true;
+      }
+      return read;
+    }
+
+    @Override
+    public int available() throws IOException {
+      return stream.available();
+    }
+
+    @Override
+    public void close() throws IOException {
+      stream.close();
+    }
+  }
+
+  private InputStreamSupport() {}
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/support/ParameterHolder.java b/src/main/java/com/code_intelligence/jazzer/mutation/support/ParameterHolder.java
new file mode 100644
index 0000000..316a6df
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/support/ParameterHolder.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.support;
+
+import static com.code_intelligence.jazzer.mutation.support.Preconditions.require;
+import static java.util.stream.Collectors.toList;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.AnnotatedType;
+import java.lang.reflect.Method;
+import java.lang.reflect.Type;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * A factory for {@link AnnotatedType} instances capturing method parameters.
+ *
+ * <p>Due to type erasure, this class can only be used by creating an anonymous subclass with a
+ * method called {@code foo} that takes exactly the desired parameter.
+ *
+ * <p>Example: {@code new ParameterHolder {void foo(@NotNull List<String> param)}.annotatedType}
+ */
+public abstract class ParameterHolder {
+  protected ParameterHolder() {}
+
+  public AnnotatedType annotatedType() {
+    return getMethod().getAnnotatedParameterTypes()[0];
+  }
+
+  public Type type() {
+    return annotatedType().getType();
+  }
+
+  public Annotation[] parameterAnnotations() {
+    return getMethod().getParameterAnnotations()[0];
+  }
+
+  private Method getMethod() {
+    List<Method> foos = Arrays.stream(this.getClass().getDeclaredMethods())
+                            .filter(method -> method.getName().equals("foo"))
+                            .collect(toList());
+    require(foos.size() == 1,
+        this.getClass().getName() + " must define exactly one function named 'foo'");
+    Method foo = foos.get(0);
+    require(foo.getParameterCount() == 1,
+        this.getClass().getName() + "#foo must define exactly one parameter");
+    return foo;
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/support/Preconditions.java b/src/main/java/com/code_intelligence/jazzer/mutation/support/Preconditions.java
new file mode 100644
index 0000000..a77f65f
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/support/Preconditions.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.support;
+
+import static java.util.Objects.requireNonNull;
+
+public final class Preconditions {
+  private Preconditions() {}
+
+  public static void check(boolean property) {
+    if (!property) {
+      throw new IllegalStateException();
+    }
+  }
+
+  public static void check(boolean property, String message) {
+    if (!property) {
+      throw new IllegalStateException(message);
+    }
+  }
+
+  public static void require(boolean property) {
+    if (!property) {
+      throw new IllegalArgumentException();
+    }
+  }
+
+  public static void require(boolean property, String message) {
+    if (!property) {
+      throw new IllegalArgumentException(message);
+    }
+  }
+
+  public static <T> T[] requireNonNullElements(T[] array) {
+    requireNonNull(array);
+    for (T element : array) {
+      requireNonNull(element, "array must not contain null elements");
+    }
+    return array;
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/support/RandomSupport.java b/src/main/java/com/code_intelligence/jazzer/mutation/support/RandomSupport.java
new file mode 100644
index 0000000..5cfa765
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/support/RandomSupport.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.support;
+
+import java.util.SplittableRandom;
+
+public final class RandomSupport {
+  private RandomSupport() {}
+
+  /**
+   * Polyfill for {@link SplittableRandom#nextBytes(byte[])}, which is not available in Java 8.
+   */
+  public static void nextBytes(SplittableRandom random, byte[] bytes) {
+    // Taken from the implementation contract
+    // https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/random/RandomGenerator.html#nextBytes(byte%5B%5D)
+    // for interoperability with the RandomGenerator interface available as of Java 17.
+    int i = 0;
+    int len = bytes.length;
+    for (int words = len >> 3; words-- > 0;) {
+      long rnd = random.nextLong();
+      for (int n = 8; n-- > 0; rnd >>>= Byte.SIZE) bytes[i++] = (byte) rnd;
+    }
+    if (i < len)
+      for (long rnd = random.nextLong(); i<len; rnd>>>= Byte.SIZE) bytes[i++] = (byte) rnd;
+  }
+
+  /**
+   * Clamp function for integers, which Java does not yet have
+   *
+   * @param value the value you want to clamp
+   * @param min the minimum allowable value (inclusive)
+   * @param max the maximum allowable value (inclusive)
+   * @return Closest number to {@code value} within the range {@code [min, max]}
+   */
+  public static int clamp(int value, int min, int max) {
+    return Math.min(Math.max(value, min), max);
+  }
+
+  /**
+   * Clamp function for longs, which Java does not yet have
+   *
+   * @param value the value you want to clamp
+   * @param min the minimum allowable value (inclusive)
+   * @param max the maximum allowable value (inclusive)
+   * @return Closest number to {@code value} within the range {@code [min, max]}
+   */
+  public static long clamp(long value, long min, long max) {
+    return Math.min(Math.max(value, min), max);
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/support/StreamSupport.java b/src/main/java/com/code_intelligence/jazzer/mutation/support/StreamSupport.java
new file mode 100644
index 0000000..b61980c
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/support/StreamSupport.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.support;
+
+import static java.util.stream.Collectors.toList;
+
+import java.util.AbstractMap.SimpleEntry;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.Optional;
+import java.util.function.IntFunction;
+import java.util.stream.Stream;
+
+public final class StreamSupport {
+  private StreamSupport() {}
+
+  public static boolean[] toBooleanArray(Stream<Boolean> stream) {
+    List<Boolean> list = stream.collect(toList());
+    boolean[] array = new boolean[list.size()];
+    for (int i = 0; i < list.size(); i++) {
+      array[i] = list.get(i);
+    }
+    return array;
+  }
+
+  /**
+   * @return the first present value, otherwise {@link Optional#empty()}
+   */
+  public static <T> Optional<T> findFirstPresent(Stream<Optional<T>> stream) {
+    return stream.filter(Optional::isPresent).map(Optional::get).findFirst();
+  }
+
+  /**
+   * @return an array with the values if all {@link Optional}s are present, otherwise
+   * {@link Optional#empty()}
+   */
+  public static <T> Optional<T[]> toArrayOrEmpty(
+      Stream<Optional<T>> stream, IntFunction<T[]> newArray) {
+    try {
+      return Optional.of(stream.map(Optional::get).toArray(newArray));
+    } catch (NoSuchElementException e) {
+      return Optional.empty();
+    }
+  }
+
+  /**
+   * Return a stream containing the optional value if present, otherwise an empty stream.
+   *
+   * @return stream containing the optional value
+   */
+  public static <T> Stream<T> getOrEmpty(Optional<T> optional) {
+    return optional.isPresent() ? Stream.of(optional.get()) : Stream.empty();
+  }
+
+  public static <K, V> SimpleEntry<K, V> entry(K key, V value) {
+    return new SimpleEntry<>(key, value);
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/support/TypeHolder.java b/src/main/java/com/code_intelligence/jazzer/mutation/support/TypeHolder.java
new file mode 100644
index 0000000..e703f9f
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/support/TypeHolder.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.support;
+
+import java.lang.reflect.AnnotatedParameterizedType;
+import java.lang.reflect.AnnotatedType;
+import java.lang.reflect.Type;
+
+/**
+ * A factory for {@link AnnotatedType} instances capturing types.
+ *
+ * <p>Due to type erasure, this class can only be used by creating an anonymous subclass.
+ *
+ * <p>Example: {@code new TypeHolder<List<String>> {}.annotatedType}
+ *
+ * @param <T> the type to hold
+ */
+public abstract class TypeHolder<T> {
+  protected TypeHolder() {}
+
+  public AnnotatedType annotatedType() {
+    return ((AnnotatedParameterizedType) this.getClass().getAnnotatedSuperclass())
+        .getAnnotatedActualTypeArguments()[0];
+  }
+
+  public Type type() {
+    return annotatedType().getType();
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/support/TypeSupport.java b/src/main/java/com/code_intelligence/jazzer/mutation/support/TypeSupport.java
new file mode 100644
index 0000000..8ce3aef
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/support/TypeSupport.java
@@ -0,0 +1,564 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.support;
+
+import static com.code_intelligence.jazzer.mutation.support.Preconditions.check;
+import static com.code_intelligence.jazzer.mutation.support.Preconditions.require;
+import static com.code_intelligence.jazzer.mutation.support.Preconditions.requireNonNullElements;
+import static java.util.Arrays.stream;
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toSet;
+
+import com.code_intelligence.jazzer.mutation.annotation.NotNull;
+import com.code_intelligence.jazzer.mutation.annotation.WithLength;
+import java.lang.annotation.Annotation;
+import java.lang.annotation.Inherited;
+import java.lang.reflect.AnnotatedArrayType;
+import java.lang.reflect.AnnotatedElement;
+import java.lang.reflect.AnnotatedParameterizedType;
+import java.lang.reflect.AnnotatedType;
+import java.lang.reflect.AnnotatedTypeVariable;
+import java.lang.reflect.AnnotatedWildcardType;
+import java.lang.reflect.Array;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.util.*;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+public final class TypeSupport {
+  private static final Annotation NOT_NULL =
+      new TypeHolder<@NotNull String>() {}.annotatedType().getAnnotation(NotNull.class);
+
+  private TypeSupport() {}
+
+  public static boolean isPrimitive(AnnotatedType type) {
+    return isPrimitive(type.getType());
+  }
+
+  public static boolean isPrimitive(Type type) {
+    if (!(type instanceof Class<?>) ) {
+      return false;
+    }
+    return ((Class<?>) type).isPrimitive();
+  }
+
+  public static boolean isInheritable(Annotation annotation) {
+    return annotation.annotationType().getDeclaredAnnotation(Inherited.class) != null;
+  }
+
+  /**
+   * Returns {@code type} as a {@code Class<? extends T>} if it is a subclass of T, otherwise
+   * empty.
+   *
+   * <p>This function also returns an empty {@link Optional} for more complex (e.g. parameterized)
+   * types.
+   */
+  public static <T> Optional<Class<? extends T>> asSubclassOrEmpty(
+      AnnotatedType type, Class<T> superclass) {
+    if (!(type.getType() instanceof Class<?>) ) {
+      return Optional.empty();
+    }
+
+    Class<?> actualClazz = (Class<?>) type.getType();
+    if (!superclass.isAssignableFrom(actualClazz)) {
+      return Optional.empty();
+    }
+
+    return Optional.of(actualClazz.asSubclass(superclass));
+  }
+
+  public static AnnotatedType asAnnotatedType(Class<?> clazz) {
+    requireNonNull(clazz);
+    return new AnnotatedType() {
+      @Override
+      public Type getType() {
+        return clazz;
+      }
+
+      @Override
+      public <T extends Annotation> T getAnnotation(Class<T> annotationClass) {
+        return annotatedElementGetAnnotation(this, annotationClass);
+      }
+
+      @Override
+      public Annotation[] getAnnotations() {
+        // No directly present annotations, look for inheritable present annotations on the
+        // superclass.
+        if (clazz.getSuperclass() == null) {
+          return new Annotation[0];
+        }
+        return stream(clazz.getSuperclass().getAnnotations())
+            .filter(TypeSupport::isInheritable)
+            .toArray(Annotation[] ::new);
+      }
+
+      @Override
+      public Annotation[] getDeclaredAnnotations() {
+        // No directly present annotations.
+        return new Annotation[0];
+      }
+
+      @Override
+      public String toString() {
+        return annotatedTypeToString(this);
+      }
+
+      @Override
+      public int hashCode() {
+        throw new UnsupportedOperationException(
+            "hashCode() is not supported as its behavior isn't specified");
+      }
+
+      @Override
+      public boolean equals(Object obj) {
+        throw new UnsupportedOperationException(
+            "equals() is not supported as its behavior isn't specified");
+      }
+    };
+  }
+
+  /**
+   * Visits the individual classes and their directly present annotations that make up the given
+   * type.
+   *
+   * <p>Classes are visited in left-to-right order as they appear in the type definition, except
+   * that an array class is visited before its component class.
+   *
+   * @throws IllegalArgumentException if the given type contains a wildcard type or type variable
+   */
+  public static void visitAnnotatedType(
+      AnnotatedType type, BiConsumer<Class<?>, Annotation[]> visitor) {
+    visitAnnotatedTypeInternal(type, visitor);
+  }
+
+  private static Class<?> visitAnnotatedTypeInternal(
+      AnnotatedType type, BiConsumer<Class<?>, Annotation[]> visitor) {
+    Class<?> clazz;
+    if (type instanceof AnnotatedWildcardType) {
+      throw new IllegalArgumentException("Wildcard types are not supported: " + type);
+    } else if (type instanceof AnnotatedTypeVariable) {
+      throw new IllegalArgumentException("Type variables are not supported: " + type);
+    } else if (type instanceof AnnotatedParameterizedType) {
+      AnnotatedParameterizedType annotatedParameterizedType = (AnnotatedParameterizedType) type;
+      check(annotatedParameterizedType.getType() instanceof ParameterizedType);
+      Type rawType = ((ParameterizedType) annotatedParameterizedType.getType()).getRawType();
+      check(rawType instanceof Class<?>);
+      clazz = (Class<?>) rawType;
+
+      visitor.accept(clazz, type.getDeclaredAnnotations());
+      for (AnnotatedType typeArgument :
+          annotatedParameterizedType.getAnnotatedActualTypeArguments()) {
+        visitAnnotatedTypeInternal(typeArgument, visitor);
+      }
+    } else if (type instanceof AnnotatedArrayType) {
+      AnnotatedArrayType arrayType = (AnnotatedArrayType) type;
+
+      // Recursively determine the array class before visiting the component type.
+      Class<?> componentClass =
+          visitAnnotatedTypeInternal(arrayType.getAnnotatedGenericComponentType(), (c, a) -> {});
+      clazz = Array.newInstance(componentClass, 0).getClass();
+      visitor.accept(clazz, type.getDeclaredAnnotations());
+      visitAnnotatedTypeInternal(arrayType.getAnnotatedGenericComponentType(), visitor);
+    } else {
+      check(type.getType() instanceof Class<?>);
+      clazz = (Class<?>) type.getType();
+
+      visitor.accept(clazz, type.getDeclaredAnnotations());
+    }
+    return clazz;
+  }
+
+  public static AnnotatedType notNull(AnnotatedType type) {
+    return withExtraAnnotations(type, NOT_NULL);
+  }
+
+  /**
+   * Constructs an anonymous WithLength class that can be applied as an annotation to {@code type}
+   * with the given
+   * {@code min} and {@code max} values.
+   * @param type
+   * @param min
+   * @param max
+   * @return {@code type} with a `WithLength` annotation applied to it
+   */
+  public static AnnotatedType withLength(AnnotatedType type, int min, int max) {
+    WithLength withLength = withLengthImplementation(min, max);
+    return withExtraAnnotations(type, withLength);
+  }
+
+  private static WithLength withLengthImplementation(int min, int max) {
+    return new WithLength() {
+      @Override
+      public int min() {
+        return min;
+      }
+
+      @Override
+      public int max() {
+        return max;
+      }
+
+      @Override
+      public Class<? extends Annotation> annotationType() {
+        return WithLength.class;
+      }
+
+      @Override
+      public boolean equals(Object o) {
+        if (!(o instanceof WithLength)) {
+          return false;
+        }
+        WithLength other = (WithLength) o;
+        return this.min() == other.min() && this.max() == other.max();
+      }
+
+      @Override
+      public int hashCode() {
+        int hash = 0;
+        hash += ("min".hashCode() * 127) ^ Integer.valueOf(this.min()).hashCode();
+        hash += ("max".hashCode() * 127) ^ Integer.valueOf(this.max()).hashCode();
+        return hash;
+      }
+    };
+  }
+
+  public static AnnotatedParameterizedType withTypeArguments(
+      AnnotatedType type, AnnotatedType... typeArguments) {
+    requireNonNull(type);
+    requireNonNullElements(typeArguments);
+    require(typeArguments.length > 0);
+    require(!(type instanceof AnnotatedParameterizedType || type instanceof AnnotatedTypeVariable
+                || type instanceof AnnotatedWildcardType || type instanceof AnnotatedArrayType),
+        "only plain annotated types are supported");
+    require(
+        ((Class<?>) type.getType()).getEnclosingClass() == null, "nested classes aren't supported");
+
+    ParameterizedType filledRawType = new ParameterizedType() {
+      @Override
+      public Type[] getActualTypeArguments() {
+        return stream(typeArguments).map(AnnotatedType::getType).toArray(Type[] ::new);
+      }
+
+      @Override
+      public Type getRawType() {
+        return type.getType();
+      }
+
+      @Override
+      public Type getOwnerType() {
+        // We require the class is top-level.
+        return null;
+      }
+
+      @Override
+      public String toString() {
+        return getRawType()
+            + stream(getActualTypeArguments()).map(Type::toString).collect(joining(",", "<", ">"));
+      }
+
+      @Override
+      public boolean equals(Object obj) {
+        if (!(obj instanceof ParameterizedType)) {
+          return false;
+        }
+        ParameterizedType other = (ParameterizedType) obj;
+        return getRawType().equals(other.getRawType()) && null == other.getOwnerType()
+            && Arrays.equals(getActualTypeArguments(), other.getActualTypeArguments());
+      }
+
+      @Override
+      public int hashCode() {
+        throw new UnsupportedOperationException(
+            "hashCode() is not supported as its behavior isn't specified");
+      }
+    };
+
+    return new AnnotatedParameterizedType() {
+      @Override
+      public AnnotatedType[] getAnnotatedActualTypeArguments() {
+        return Arrays.copyOf(typeArguments, typeArguments.length);
+      }
+
+      // @Override as of Java 9
+      @SuppressWarnings("Since15")
+      public AnnotatedType getAnnotatedOwnerType() {
+        return null;
+      }
+
+      @Override
+      public Type getType() {
+        return filledRawType;
+      }
+
+      @Override
+      public <T extends Annotation> T getAnnotation(Class<T> annotationClass) {
+        return type.getAnnotation(annotationClass);
+      }
+
+      @Override
+      public Annotation[] getAnnotations() {
+        return type.getAnnotations();
+      }
+
+      @Override
+      public Annotation[] getDeclaredAnnotations() {
+        return type.getDeclaredAnnotations();
+      }
+
+      @Override
+      public String toString() {
+        return annotatedTypeToString(this);
+      }
+
+      @Override
+      public boolean equals(Object obj) {
+        if (!(obj instanceof AnnotatedParameterizedType)) {
+          return false;
+        }
+        AnnotatedParameterizedType other = (AnnotatedParameterizedType) obj;
+        // Can't call getAnnotatedOwnerType on Java 8, but since our own implementation always
+        // returns null, comparing getType().getOwnerType() via getType() is sufficient.
+        return Objects.equals(getType(), other.getType())
+            && Arrays.equals(
+                getAnnotatedActualTypeArguments(), other.getAnnotatedActualTypeArguments())
+            && Arrays.equals(getAnnotations(), other.getAnnotations());
+      }
+
+      @Override
+      public int hashCode() {
+        throw new UnsupportedOperationException(
+            "hashCode() is not supported as its behavior isn't specified");
+      }
+    };
+  }
+
+  public static AnnotatedType withExtraAnnotations(
+      AnnotatedType base, Annotation... extraAnnotations) {
+    requireNonNull(base);
+    requireNonNullElements(extraAnnotations);
+
+    if (extraAnnotations.length == 0) {
+      return base;
+    }
+
+    require(!(base instanceof AnnotatedTypeVariable || base instanceof AnnotatedWildcardType),
+        "Adding annotations to AnnotatedTypeVariables or AnnotatedWildcardTypes is not supported");
+    if (base instanceof AnnotatedArrayType) {
+      return new AugmentedArrayType((AnnotatedArrayType) base, extraAnnotations);
+    } else if (base instanceof AnnotatedParameterizedType) {
+      return new AugmentedParameterizedType((AnnotatedParameterizedType) base, extraAnnotations);
+    } else {
+      return new AugmentedAnnotatedType(base, extraAnnotations);
+    }
+  }
+
+  private static String annotatedTypeToString(AnnotatedType annotatedType) {
+    String annotations =
+        stream(annotatedType.getAnnotations()).map(Annotation::toString).collect(joining(" "));
+    if (annotations.isEmpty()) {
+      return annotatedType.getType().toString();
+    } else {
+      return annotations + " " + annotatedType.getType();
+    }
+  }
+
+  private static <T extends Annotation> T annotatedElementGetAnnotation(
+      AnnotatedElement element, Class<T> annotationClass) {
+    requireNonNull(annotationClass);
+    return stream(element.getAnnotations())
+        .filter(annotation -> annotationClass.equals(annotation.annotationType()))
+        .findFirst()
+        .map(annotationClass::cast)
+        .orElse(null);
+  }
+
+  public static Optional<Class<?>> findFirstParentIfClass(AnnotatedType type, Class<?>... parents) {
+    if (!(type.getType() instanceof Class<?>) ) {
+      return Optional.empty();
+    }
+    Class<?> clazz = (Class<?>) type.getType();
+    return Stream.of(parents).filter(parent -> parent.isAssignableFrom(clazz)).findFirst();
+  }
+
+  public static Optional<AnnotatedType> parameterTypeIfParameterized(
+      AnnotatedType type, Class<?> expectedParent) {
+    return parameterTypesIfParameterized(type, expectedParent).flatMap(typeArguments -> {
+      if (typeArguments.size() != 1) {
+        return Optional.empty();
+      } else {
+        AnnotatedType elementType = typeArguments.get(0);
+        if (!(elementType.getType() instanceof ParameterizedType)
+            && !(elementType.getType() instanceof Class)) {
+          return Optional.empty();
+        } else {
+          return Optional.of(elementType);
+        }
+      }
+    });
+  }
+
+  public static Optional<List<AnnotatedType>> parameterTypesIfParameterized(
+      AnnotatedType type, Class<?> expectedParent) {
+    if (!(type instanceof AnnotatedParameterizedType)) {
+      return Optional.empty();
+    }
+    Class<?> clazz = (Class<?>) ((ParameterizedType) type.getType()).getRawType();
+    if (!expectedParent.isAssignableFrom(clazz)) {
+      return Optional.empty();
+    }
+
+    AnnotatedType[] typeArguments =
+        ((AnnotatedParameterizedType) type).getAnnotatedActualTypeArguments();
+    if (typeArguments.length == 0) {
+      return Optional.empty();
+    }
+    return Optional.of(Collections.unmodifiableList(Arrays.asList(typeArguments)));
+  }
+
+  /**
+   * Whether {@code root} is contained in a directed cycle in the directed graph rooted at it and
+   * defined by the given {@code successors} function.
+   */
+  public static <T> boolean containedInDirectedCycle(T root, Function<T, Stream<T>> successors) {
+    HashSet<T> traversed = new HashSet<>();
+    ArrayDeque<T> toTraverse = new ArrayDeque<>();
+    toTraverse.addLast(root);
+    T currentNode;
+    while ((currentNode = toTraverse.pollLast()) != null) {
+      if (traversed.add(currentNode)) {
+        successors.apply(currentNode).forEachOrdered(toTraverse::addLast);
+      } else if (currentNode.equals(root)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private static class AugmentedArrayType
+      extends AugmentedAnnotatedType implements AnnotatedArrayType {
+    private AugmentedArrayType(AnnotatedArrayType base, Annotation[] extraAnnotations) {
+      super(base, extraAnnotations);
+    }
+
+    @Override
+    public AnnotatedType getAnnotatedGenericComponentType() {
+      return ((AnnotatedArrayType) base).getAnnotatedGenericComponentType();
+    }
+
+    // @Override as of Java 9
+    @SuppressWarnings("Since15")
+    public AnnotatedType getAnnotatedOwnerType() {
+      throw new UnsupportedOperationException("Not implemented");
+    }
+  }
+
+  private static class AugmentedParameterizedType
+      extends AugmentedAnnotatedType implements AnnotatedParameterizedType {
+    private AugmentedParameterizedType(
+        AnnotatedParameterizedType base, Annotation[] extraAnnotations) {
+      super(base, extraAnnotations);
+    }
+
+    @Override
+    public AnnotatedType[] getAnnotatedActualTypeArguments() {
+      return ((AnnotatedParameterizedType) base).getAnnotatedActualTypeArguments();
+    }
+
+    // @Override as of Java 9
+    @SuppressWarnings("Since15")
+    public AnnotatedType getAnnotatedOwnerType() {
+      throw new UnsupportedOperationException("Not implemented");
+    }
+  }
+
+  private static class AugmentedAnnotatedType implements AnnotatedType {
+    protected final AnnotatedType base;
+    private final Annotation[] extraAnnotations;
+
+    private AugmentedAnnotatedType(AnnotatedType base, Annotation[] extraAnnotations) {
+      this.base = requireNonNull(base);
+      this.extraAnnotations = checkExtraAnnotations(base, extraAnnotations);
+    }
+
+    private static Annotation[] checkExtraAnnotations(
+        AnnotatedElement base, Annotation[] extraAnnotations) {
+      requireNonNullElements(extraAnnotations);
+      Set<Class<? extends Annotation>> existingAnnotationTypes =
+          stream(base.getDeclaredAnnotations())
+              .map(Annotation::annotationType)
+              .collect(Collectors.toCollection(HashSet::new));
+      for (Annotation annotation : extraAnnotations) {
+        boolean added = existingAnnotationTypes.add(annotation.annotationType());
+        require(added, annotation + " already directly present on " + base);
+      }
+      return extraAnnotations;
+    }
+
+    @Override
+    public Type getType() {
+      return base.getType();
+    }
+
+    @Override
+    public <T extends Annotation> T getAnnotation(Class<T> annotationClass) {
+      return annotatedElementGetAnnotation(this, annotationClass);
+    }
+
+    @Override
+    public Annotation[] getAnnotations() {
+      Set<Class<? extends Annotation>> directlyPresentTypes =
+          stream(getDeclaredAnnotations()).map(Annotation::annotationType).collect(toSet());
+      return Stream
+          .concat(
+              // Directly present annotations.
+              stream(getDeclaredAnnotations()),
+              // Present but not directly present annotations, never added by us as we don't add
+              // annotations to the super class.
+              stream(base.getAnnotations())
+                  .filter(
+                      annotation -> !directlyPresentTypes.contains(annotation.annotationType())))
+          .toArray(Annotation[] ::new);
+    }
+
+    @Override
+    public Annotation[] getDeclaredAnnotations() {
+      return Stream.concat(stream(base.getDeclaredAnnotations()), stream(extraAnnotations))
+          .toArray(Annotation[] ::new);
+    }
+
+    @Override
+    public String toString() {
+      return annotatedTypeToString(this);
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+      throw new UnsupportedOperationException(
+          "equals() is not supported as its behavior isn't specified");
+    }
+
+    @Override
+    public int hashCode() {
+      throw new UnsupportedOperationException(
+          "hashCode() is not supported as its behavior isn't specified");
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/support/WeakIdentityHashMap.java b/src/main/java/com/code_intelligence/jazzer/mutation/support/WeakIdentityHashMap.java
new file mode 100644
index 0000000..0eef7c2
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/mutation/support/WeakIdentityHashMap.java
@@ -0,0 +1,168 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.support;
+
+import static java.util.stream.Collectors.toSet;
+
+import java.lang.ref.Reference;
+import java.lang.ref.ReferenceQueue;
+import java.lang.ref.WeakReference;
+import java.util.AbstractMap.SimpleEntry;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * An unoptimized version of a {@link java.util.WeakHashMap} with the semantics of a
+ * {@link java.util.IdentityHashMap}.
+ *
+ * <p>If this class ever becomes a bottleneck, e.g. because of the IdentityWeakReference
+ * allocations, it should be replaced by a copy of the * {@link java.util.WeakHashMap} code with all
+ * {@code equals} calls dropped and all {@code hashCode} * calls replaced with {@link
+ * System#identityHashCode}.
+ */
+public final class WeakIdentityHashMap<K, V> implements Map<K, V> {
+  private final HashMap<WeakReference<K>, V> map = new HashMap<>();
+  private final ReferenceQueue<K> weaklyReachables = new ReferenceQueue<>();
+
+  @Override
+  public int size() {
+    removeNewWeaklyReachables();
+    return map.size();
+  }
+
+  @Override
+  public boolean isEmpty() {
+    removeNewWeaklyReachables();
+    return map.isEmpty();
+  }
+
+  @Override
+  public boolean containsKey(Object key) {
+    removeNewWeaklyReachables();
+    return map.containsKey(new IdentityWeakReference<>(key));
+  }
+
+  @Override
+  public boolean containsValue(Object value) {
+    removeNewWeaklyReachables();
+    return map.containsValue(value);
+  }
+
+  @Override
+  public V get(Object key) {
+    removeNewWeaklyReachables();
+    return map.get(new IdentityWeakReference<>(key));
+  }
+
+  @Override
+  public V put(K key, V value) {
+    removeNewWeaklyReachables();
+    return map.put(new IdentityWeakReference<>(key, weaklyReachables), value);
+  }
+
+  @Override
+  public V remove(Object key) {
+    removeNewWeaklyReachables();
+    return map.remove(new IdentityWeakReference<>(key));
+  }
+
+  @Override
+  public void putAll(Map<? extends K, ? extends V> otherMap) {
+    removeNewWeaklyReachables();
+    for (Entry<? extends K, ? extends V> entry : otherMap.entrySet()) {
+      map.put(new IdentityWeakReference<>(entry.getKey(), weaklyReachables), entry.getValue());
+    }
+  }
+
+  @Override
+  public void clear() {
+    map.clear();
+  }
+
+  @Override
+  public Set<K> keySet() {
+    removeNewWeaklyReachables();
+    return map.keySet().stream().map(WeakReference::get).filter(Objects::nonNull).collect(toSet());
+  }
+
+  @Override
+  public Collection<V> values() {
+    removeNewWeaklyReachables();
+    return map.values();
+  }
+
+  @Override
+  public Set<Entry<K, V>> entrySet() {
+    removeNewWeaklyReachables();
+    return map.entrySet()
+        .stream()
+        .map(e -> new SimpleEntry<>(e.getKey().get(), e.getValue()))
+        .filter(e -> e.getKey() != null)
+        .collect(toSet());
+  }
+
+  void collectKeysForTesting() {
+    map.keySet().forEach(ref -> {
+      ref.clear();
+      ref.enqueue();
+    });
+  }
+
+  private void removeNewWeaklyReachables() {
+    Reference<? extends K> referent;
+    while ((referent = weaklyReachables.poll()) != null) {
+      map.remove(referent);
+    }
+  }
+
+  private static final class IdentityWeakReference<T> extends WeakReference<T> {
+    private final int referentHashCode;
+
+    public IdentityWeakReference(T referent) {
+      super(referent);
+      this.referentHashCode = System.identityHashCode(referent);
+    }
+
+    public IdentityWeakReference(T referent, ReferenceQueue<? super T> queue) {
+      super(referent, queue);
+      this.referentHashCode = System.identityHashCode(referent);
+    }
+
+    @Override
+    public boolean equals(Object other) {
+      if (this == other) {
+        return true;
+      }
+      if (!(other instanceof WeakReference)) {
+        return false;
+      }
+      T referent = get();
+      if (referent == null) {
+        return false;
+      }
+      return referent == ((WeakReference<?>) other).get();
+    }
+
+    @Override
+    public int hashCode() {
+      return referentHashCode;
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/replay/BUILD.bazel b/src/main/java/com/code_intelligence/jazzer/replay/BUILD.bazel
new file mode 100644
index 0000000..4b53454
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/replay/BUILD.bazel
@@ -0,0 +1,16 @@
+load("@fmeum_rules_jni//jni:defs.bzl", "java_jni_library")
+
+java_jni_library(
+    name = "replay",
+    srcs = ["Replayer.java"],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/api",
+        "//src/main/java/com/code_intelligence/jazzer/driver:fuzzed_data_provider_impl",
+    ],
+)
+
+java_binary(
+    name = "Replayer",
+    visibility = ["//visibility:public"],
+    runtime_deps = [":replay"],
+)
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/replay/Replayer.java b/src/main/java/com/code_intelligence/jazzer/replay/Replayer.java
similarity index 92%
rename from agent/src/main/java/com/code_intelligence/jazzer/replay/Replayer.java
rename to src/main/java/com/code_intelligence/jazzer/replay/Replayer.java
index 0a250d1..dc76328 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/replay/Replayer.java
+++ b/src/main/java/com/code_intelligence/jazzer/replay/Replayer.java
@@ -15,8 +15,7 @@
 package com.code_intelligence.jazzer.replay;
 
 import com.code_intelligence.jazzer.api.FuzzedDataProvider;
-import com.code_intelligence.jazzer.runtime.FuzzedDataProviderImpl;
-import com.github.fmeum.rules_jni.RulesJni;
+import com.code_intelligence.jazzer.driver.FuzzedDataProviderImpl;
 import java.io.IOException;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
@@ -28,17 +27,6 @@
   public static final int STATUS_FINDING = 77;
   public static final int STATUS_OTHER_ERROR = 1;
 
-  static {
-    System.setProperty("jazzer.is_replayer", "true");
-    try {
-      RulesJni.loadLibrary(
-          "fuzzed_data_provider_standalone", "/com/code_intelligence/jazzer/driver");
-    } catch (Throwable t) {
-      t.printStackTrace();
-      System.exit(STATUS_OTHER_ERROR);
-    }
-  }
-
   public static void main(String[] args) {
     if (args.length < 2) {
       System.err.println("Usage: <fuzz target class> <input file path> <fuzzerInitialize args>...");
diff --git a/src/main/java/com/code_intelligence/jazzer/runtime/BUILD.bazel b/src/main/java/com/code_intelligence/jazzer/runtime/BUILD.bazel
new file mode 100644
index 0000000..c31c86e
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/runtime/BUILD.bazel
@@ -0,0 +1,180 @@
+load("@com_github_johnynek_bazel_jar_jar//:jar_jar.bzl", "jar_jar")
+load("@fmeum_rules_jni//jni:defs.bzl", "java_jni_library")
+load("//bazel:compat.bzl", "SKIP_ON_WINDOWS")
+load("//bazel:jar.bzl", "strip_jar")
+
+# The transitive dependencies of this target will be appended to the search path
+# of the bootstrap class loader. They will be visible to all classes - care must
+# be taken to shade everything and generally keep this target as small as
+# possible.
+java_binary(
+    name = "jazzer_bootstrap_unshaded",
+    create_executable = False,
+    runtime_deps = [":jazzer_bootstrap_lib"],
+)
+
+java_library(
+    name = "jazzer_bootstrap_lib",
+    visibility = ["//src/main/java/com/code_intelligence/jazzer:__pkg__"],
+    runtime_deps = [
+        ":runtime",
+        "//sanitizers",
+    ],
+)
+
+# These classes with public Bazel visibility are contained in jazzer_bootstrap.jar
+# and will thus be available on the bootstrap class path. This target can be
+# passed to the `deploy_env` attribute of the Jazzer `java_binary` to ensure that
+# it doesn't bundle in these classes.
+java_binary(
+    name = "jazzer_bootstrap_env",
+    create_executable = False,
+    visibility = ["//src/main/java/com/code_intelligence/jazzer:__pkg__"],
+    runtime_deps = [
+        "//src/main/java/com/code_intelligence/jazzer/api:hooks",
+        "//src/main/java/com/code_intelligence/jazzer/utils:unsafe_provider",
+    ],
+)
+
+jar_jar(
+    name = "jazzer_bootstrap_unstripped",
+    input_jar = ":jazzer_bootstrap_unshaded_deploy.jar",
+    rules = "bootstrap_shade_rules",
+)
+
+strip_jar(
+    name = "jazzer_bootstrap",
+    out = "jazzer_bootstrap.jar",
+    jar = ":jazzer_bootstrap_unstripped",
+    paths_to_keep = [
+        "com/code_intelligence/jazzer/**",
+        "jaz/**",
+        "META-INF/MANIFEST.MF",
+    ],
+    visibility = [
+        "//src/main/java/com/code_intelligence/jazzer/agent:__pkg__",
+        "//src/main/java/com/code_intelligence/jazzer/android:__pkg__",
+    ],
+)
+
+sh_test(
+    name = "jazzer_bootstrap_shading_test",
+    srcs = ["verify_shading.sh"],
+    args = [
+        "$(rootpath jazzer_bootstrap.jar)",
+    ],
+    data = [
+        "jazzer_bootstrap.jar",
+        "@local_jdk//:bin/jar",
+    ],
+    tags = [
+        # Coverage instrumentation necessarily adds files to the jar that we
+        # wouldn't want to release and thus causes this test to fail.
+        "no-coverage",
+    ],
+    target_compatible_with = SKIP_ON_WINDOWS,
+)
+
+# At runtime, the AgentInstaller appends jazzer_bootstrap.jar to the bootstrap
+# class loader's search path - these classes must not be available on the
+# regular classpath. Since dependents should not have to resort to reflection to
+# access these classes they know will be there at runtime, this compile-time
+# only dependency can be used as a replacement.
+java_library(
+    name = "jazzer_bootstrap_compile_only",
+    neverlink = True,
+    visibility = [
+        "//src/main/java/com/code_intelligence/jazzer/autofuzz:__pkg__",
+        "//src/main/java/com/code_intelligence/jazzer/driver:__pkg__",
+        "//src/main/java/com/code_intelligence/jazzer/instrumentor:__pkg__",
+    ],
+    exports = [
+        ":fuzz_target_runner_natives",
+        ":runtime",
+    ],
+)
+
+# The following targets must only be referenced directly by tests or native implementations.
+
+java_jni_library(
+    name = "coverage_map",
+    srcs = ["CoverageMap.java"],
+    native_libs = select({
+        "@platforms//os:android": ["//src/main/native/com/code_intelligence/jazzer/driver:jazzer_driver"],
+        "//conditions:default": [],
+    }),
+    visibility = [
+        "//src/jmh/java/com/code_intelligence/jazzer/instrumentor:__pkg__",
+        "//src/main/native/com/code_intelligence/jazzer/driver:__pkg__",
+        "//src/test:__subpackages__",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/runtime:constants",
+        "//src/main/java/com/code_intelligence/jazzer/utils:unsafe_provider",
+    ],
+)
+
+java_jni_library(
+    name = "trace_data_flow_native_callbacks",
+    srcs = ["TraceDataFlowNativeCallbacks.java"],
+    visibility = [
+        "//src/main/native/com/code_intelligence/jazzer/driver:__pkg__",
+    ],
+    deps = ["@org_ow2_asm_asm//jar"],
+)
+
+java_jni_library(
+    name = "fuzz_target_runner_natives",
+    srcs = ["FuzzTargetRunnerNatives.java"],
+    visibility = ["//src/main/native/com/code_intelligence/jazzer/driver:__pkg__"],
+    deps = [
+        ":constants",
+    ],
+)
+
+java_jni_library(
+    name = "mutator",
+    srcs = ["Mutator.java"],
+    visibility = [
+        "//src/main/java/com/code_intelligence/jazzer/mutation/mutator/libfuzzer:__pkg__",
+        "//src/main/native/com/code_intelligence/jazzer/driver:__pkg__",
+    ],
+)
+
+java_library(
+    name = "runtime",
+    srcs = [
+        "HardToCatchError.java",
+        "JazzerInternal.java",
+        "NativeLibHooks.java",
+        "TraceCmpHooks.java",
+        "TraceDivHooks.java",
+        "TraceIndirHooks.java",
+    ],
+    visibility = [
+        "//src/main/java/com/code_intelligence/jazzer/android:__pkg__",
+        "//src/main/native/com/code_intelligence/jazzer/driver:__pkg__",
+        "//src/test:__subpackages__",
+    ],
+    runtime_deps = [
+        ":fuzz_target_runner_natives",
+        ":mutator",
+        # Access to Unsafe is possible without any tricks if the class that does it is loaded by the
+        # bootstrap loader. We thus want Jazzer to use this class from jazzer_bootstrap.
+        "//src/main/java/com/code_intelligence/jazzer/utils:unsafe_provider",
+    ],
+    deps = [
+        ":constants",
+        ":coverage_map",
+        ":trace_data_flow_native_callbacks",
+        "//src/main/java/com/code_intelligence/jazzer/api:hooks",
+    ],
+)
+
+# This target exposes a class that can safely be loaded in both the system and the bootstrap class
+# loader as it provides true constants that do not change over the lifetime of the JVM.
+java_library(
+    name = "constants",
+    srcs = ["Constants.java"],
+    visibility = ["//visibility:public"],
+)
diff --git a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h b/src/main/java/com/code_intelligence/jazzer/runtime/Constants.java
similarity index 62%
copy from driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
copy to src/main/java/com/code_intelligence/jazzer/runtime/Constants.java
index 0e8846c..92f4a3c 100644
--- a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
+++ b/src/main/java/com/code_intelligence/jazzer/runtime/Constants.java
@@ -1,11 +1,11 @@
 /*
- * Copyright 2021 Code Intelligence GmbH
+ * Copyright 2023 Code Intelligence GmbH
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
  *
- *      http://www.apache.org/licenses/LICENSE-2.0
+ *     http://www.apache.org/licenses/LICENSE-2.0
  *
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS,
@@ -14,15 +14,8 @@
  * limitations under the License.
  */
 
-#pragma once
+package com.code_intelligence.jazzer.runtime;
 
-#include <jni.h>
-
-namespace jazzer {
-/*
- * Print the stack traces of all active JVM threads.
- *
- * This function can be called from any thread.
- */
-void DumpJvmStackTraces();
-}  // namespace jazzer
+public final class Constants {
+  public static final boolean IS_ANDROID = System.getProperty("java.vm.vendor").contains("Android");
+}
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/runtime/CoverageMap.java b/src/main/java/com/code_intelligence/jazzer/runtime/CoverageMap.java
similarity index 65%
rename from agent/src/main/java/com/code_intelligence/jazzer/runtime/CoverageMap.java
rename to src/main/java/com/code_intelligence/jazzer/runtime/CoverageMap.java
index 4069d25..a945a30 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/runtime/CoverageMap.java
+++ b/src/main/java/com/code_intelligence/jazzer/runtime/CoverageMap.java
@@ -14,7 +14,13 @@
 
 package com.code_intelligence.jazzer.runtime;
 
+import static com.code_intelligence.jazzer.runtime.Constants.IS_ANDROID;
+
+import com.code_intelligence.jazzer.utils.UnsafeProvider;
 import com.github.fmeum.rules_jni.RulesJni;
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.Set;
@@ -36,13 +42,20 @@
       : 1 << 20;
 
   private static final Unsafe UNSAFE = UnsafeProvider.getUnsafe();
+  private static final Class<?> LOG;
+  private static final MethodHandle LOG_INFO;
+  private static final MethodHandle LOG_ERROR;
 
   static {
-    if (UNSAFE == null) {
-      System.out.println("ERROR: Failed to get Unsafe instance for CoverageMap.%n"
-          + "       Please file a bug at:%n"
-          + "         https://github.com/CodeIntelligenceTesting/jazzer/issues/new");
-      System.exit(1);
+    try {
+      LOG = Class.forName(
+          "com.code_intelligence.jazzer.utils.Log", false, ClassLoader.getSystemClassLoader());
+      LOG_INFO = MethodHandles.lookup().findStatic(
+          LOG, "info", MethodType.methodType(void.class, String.class));
+      LOG_ERROR = MethodHandles.lookup().findStatic(
+          LOG, "error", MethodType.methodType(void.class, String.class, Throwable.class));
+    } catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException e) {
+      throw new RuntimeException(e);
     }
   }
 
@@ -79,25 +92,30 @@
     while (nextId >= newNumCounters) {
       newNumCounters = 2 * newNumCounters;
       if (newNumCounters > MAX_NUM_COUNTERS) {
-        System.out.printf("ERROR: Maximum number (%s) of coverage counters exceeded. Try to%n"
-                + "       limit the scope of a single fuzz target as much as possible to keep the%n"
-                + "       fuzzer fast.%n"
-                + "       If that is not possible, the maximum number of counters can be increased%n"
-                + "       via the %s environment variable.",
-            MAX_NUM_COUNTERS, ENV_MAX_NUM_COUNTERS);
+        logError(
+            String.format(
+                "Maximum number (%s) of coverage counters exceeded. Try to limit the scope of a single fuzz target as "
+                    + "much as possible to keep the fuzzer fast. If that is not possible, the maximum number of "
+                    + "counters can be increased via the %s environment variable.",
+                MAX_NUM_COUNTERS, ENV_MAX_NUM_COUNTERS),
+            null);
         System.exit(1);
       }
     }
     if (newNumCounters > currentNumCounters) {
       registerNewCounters(currentNumCounters, newNumCounters);
       currentNumCounters = newNumCounters;
-      System.out.println("INFO: New number of coverage counters: " + currentNumCounters);
+      logInfo("New number of coverage counters: " + currentNumCounters);
     }
   }
 
   // Called by the coverage instrumentation.
   @SuppressWarnings("unused")
   public static void recordCoverage(final int id) {
+    if (IS_ANDROID) {
+      enlargeIfNeeded(id);
+    }
+
     final long address = countersAddress + id;
     final byte counter = UNSAFE.getByte(address);
     UNSAFE.putByte(address, (byte) (counter == -1 ? 1 : counter + 1));
@@ -119,6 +137,28 @@
     }
   }
 
+  private static void logInfo(String message) {
+    try {
+      LOG_INFO.invokeExact(message);
+    } catch (Throwable error) {
+      // Should not be reached, Log.error does not throw.
+      error.printStackTrace();
+      System.err.println("Failed to call Log.info:");
+      System.err.println(message);
+    }
+  }
+
+  private static void logError(String message, Throwable t) {
+    try {
+      LOG_ERROR.invokeExact(message, t);
+    } catch (Throwable error) {
+      // Should not be reached, Log.error does not throw.
+      error.printStackTrace();
+      System.err.println("Failed to call Log.error:");
+      System.err.println(message);
+    }
+  }
+
   // Returns the IDs of all blocks that have been covered in at least one run (not just the current
   // one).
   public static native int[] getEverCoveredIds();
diff --git a/src/main/java/com/code_intelligence/jazzer/runtime/FuzzTargetRunnerNatives.java b/src/main/java/com/code_intelligence/jazzer/runtime/FuzzTargetRunnerNatives.java
new file mode 100644
index 0000000..bbf74fd
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/runtime/FuzzTargetRunnerNatives.java
@@ -0,0 +1,41 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.runtime;
+
+import com.github.fmeum.rules_jni.RulesJni;
+
+/**
+ * The native functions used by FuzzTargetRunner.
+ *
+ * <p>This class has to be loaded by the bootstrap class loader since the native library it loads
+ * links in libFuzzer and the Java hooks, which have to be on the bootstrap path so that they are
+ * seen by Java standard library classes, need to be able to call native libFuzzer callbacks.
+ */
+public class FuzzTargetRunnerNatives {
+  static {
+    if (!Constants.IS_ANDROID && FuzzTargetRunnerNatives.class.getClassLoader() != null) {
+      throw new IllegalStateException(
+          "FuzzTargetRunnerNatives must be loaded in the bootstrap loader");
+    }
+    RulesJni.loadLibrary("jazzer_driver", "/com/code_intelligence/jazzer/driver");
+  }
+
+  public static native int startLibFuzzer(
+      byte[][] args, Class<?> runner, boolean useExperimentalMutator);
+
+  public static native void printCrashingInput();
+
+  public static native void temporarilyDisableLibfuzzerExitHook();
+}
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/runtime/HardToCatchError.java b/src/main/java/com/code_intelligence/jazzer/runtime/HardToCatchError.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/runtime/HardToCatchError.java
rename to src/main/java/com/code_intelligence/jazzer/runtime/HardToCatchError.java
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/runtime/JazzerInternal.java b/src/main/java/com/code_intelligence/jazzer/runtime/JazzerInternal.java
similarity index 62%
rename from agent/src/main/java/com/code_intelligence/jazzer/runtime/JazzerInternal.java
rename to src/main/java/com/code_intelligence/jazzer/runtime/JazzerInternal.java
index 79c851a..3b36853 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/runtime/JazzerInternal.java
+++ b/src/main/java/com/code_intelligence/jazzer/runtime/JazzerInternal.java
@@ -17,9 +17,18 @@
 import java.util.ArrayList;
 
 final public class JazzerInternal {
-  private static final ArrayList<Runnable> ON_FUZZ_TARGET_READY_CALLBACKS = new ArrayList<>();
-
   public static Throwable lastFinding;
+  // The value is only relevant when regression testing. Read by the bytecode emitted by
+  // HookMethodVisitor to enable hooks only when invoked from a @FuzzTest.
+  //
+  // Alternatives considered:
+  // Making this thread local rather than global may potentially allow to run fuzz tests in
+  // parallel with regular unit tests, but it is next to impossible to determine which thread is
+  // currently doing work for a fuzz test versus a regular unit test. Instead, @FuzzTest is
+  // annotated with @Isolated.
+  @SuppressWarnings("unused") public static boolean hooksEnabled = true;
+
+  private static final ArrayList<Runnable> onFuzzTargetReadyCallbacks = new ArrayList<>();
 
   // Accessed from api.Jazzer via reflection.
   public static void reportFindingFromHook(Throwable finding) {
@@ -31,11 +40,11 @@
   }
 
   public static void registerOnFuzzTargetReadyCallback(Runnable callback) {
-    ON_FUZZ_TARGET_READY_CALLBACKS.add(callback);
+    onFuzzTargetReadyCallbacks.add(callback);
   }
 
   public static void onFuzzTargetReady(String fuzzTargetClass) {
-    ON_FUZZ_TARGET_READY_CALLBACKS.forEach(Runnable::run);
-    ON_FUZZ_TARGET_READY_CALLBACKS.clear();
+    onFuzzTargetReadyCallbacks.forEach(Runnable::run);
+    onFuzzTargetReadyCallbacks.clear();
   }
 }
diff --git a/src/main/java/com/code_intelligence/jazzer/runtime/Mutator.java b/src/main/java/com/code_intelligence/jazzer/runtime/Mutator.java
new file mode 100644
index 0000000..2d9a7f6
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/runtime/Mutator.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.runtime;
+
+import com.github.fmeum.rules_jni.RulesJni;
+
+public final class Mutator {
+  public static final boolean SHOULD_MOCK =
+      Boolean.parseBoolean(System.getenv("JAZZER_MOCK_LIBFUZZER_MUTATOR"));
+
+  static {
+    if (!SHOULD_MOCK) {
+      RulesJni.loadLibrary("jazzer_driver", "/com/code_intelligence/jazzer/driver");
+    }
+  }
+
+  public static native int defaultMutateNative(byte[] buffer, int size);
+
+  private Mutator() {}
+}
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/runtime/NativeLibHooks.java b/src/main/java/com/code_intelligence/jazzer/runtime/NativeLibHooks.java
similarity index 96%
rename from agent/src/main/java/com/code_intelligence/jazzer/runtime/NativeLibHooks.java
rename to src/main/java/com/code_intelligence/jazzer/runtime/NativeLibHooks.java
index 495cad7..8572f05 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/runtime/NativeLibHooks.java
+++ b/src/main/java/com/code_intelligence/jazzer/runtime/NativeLibHooks.java
@@ -30,6 +30,10 @@
       targetMethodDescriptor = "(Ljava/lang/String;)V")
   public static void
   loadLibraryHook(MethodHandle method, Object thisObject, Object[] arguments, int hookId) {
+    if (Constants.IS_ANDROID) {
+      return;
+    }
+
     TraceDataFlowNativeCallbacks.handleLibraryLoad();
   }
 }
diff --git a/src/main/java/com/code_intelligence/jazzer/runtime/TraceCmpHooks.java b/src/main/java/com/code_intelligence/jazzer/runtime/TraceCmpHooks.java
new file mode 100644
index 0000000..7fb1586
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/runtime/TraceCmpHooks.java
@@ -0,0 +1,598 @@
+// Copyright 2021 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.runtime;
+
+import com.code_intelligence.jazzer.api.HookType;
+import com.code_intelligence.jazzer.api.MethodHook;
+import java.lang.invoke.MethodHandle;
+import java.util.*;
+
+@SuppressWarnings("unused")
+final public class TraceCmpHooks {
+  @MethodHook(type = HookType.BEFORE, targetClassName = "java.lang.Byte", targetMethod = "compare",
+      targetMethodDescriptor = "(BB)I")
+  @MethodHook(type = HookType.BEFORE, targetClassName = "java.lang.Byte",
+      targetMethod = "compareUnsigned", targetMethodDescriptor = "(BB)I")
+  @MethodHook(type = HookType.BEFORE, targetClassName = "java.lang.Short", targetMethod = "compare",
+      targetMethodDescriptor = "(SS)I")
+  @MethodHook(type = HookType.BEFORE, targetClassName = "java.lang.Short",
+      targetMethod = "compareUnsigned", targetMethodDescriptor = "(SS)I")
+  @MethodHook(type = HookType.BEFORE, targetClassName = "java.lang.Integer",
+      targetMethod = "compare", targetMethodDescriptor = "(II)I")
+  @MethodHook(type = HookType.BEFORE, targetClassName = "java.lang.Integer",
+      targetMethod = "compareUnsigned", targetMethodDescriptor = "(II)I")
+  @MethodHook(type = HookType.BEFORE, targetClassName = "kotlin.jvm.internal.Intrinsics ",
+      targetMethod = "compare", targetMethodDescriptor = "(II)I")
+  public static void
+  integerCompare(MethodHandle method, Object alwaysNull, Object[] arguments, int hookId) {
+    TraceDataFlowNativeCallbacks.traceCmpInt((int) arguments[0], (int) arguments[1], hookId);
+  }
+
+  @MethodHook(type = HookType.BEFORE, targetClassName = "java.lang.Byte",
+      targetMethod = "compareTo", targetMethodDescriptor = "(Ljava/lang/Byte;)I")
+  @MethodHook(type = HookType.BEFORE, targetClassName = "java.lang.Short",
+      targetMethod = "compareTo", targetMethodDescriptor = "(Ljava/lang/Short;)I")
+  @MethodHook(type = HookType.BEFORE, targetClassName = "java.lang.Integer",
+      targetMethod = "compareTo", targetMethodDescriptor = "(Ljava/lang/Integer;)I")
+  public static void
+  integerCompareTo(MethodHandle method, Object thisObject, Object[] arguments, int hookId) {
+    TraceDataFlowNativeCallbacks.traceCmpInt((int) thisObject, (int) arguments[0], hookId);
+  }
+
+  @MethodHook(type = HookType.BEFORE, targetClassName = "java.lang.Long", targetMethod = "compare",
+      targetMethodDescriptor = "(JJ)I")
+  @MethodHook(type = HookType.BEFORE, targetClassName = "java.lang.Long",
+      targetMethod = "compareUnsigned", targetMethodDescriptor = "(JJ)I")
+  public static void
+  longCompare(MethodHandle method, Object thisObject, Object[] arguments, int hookId) {
+    TraceDataFlowNativeCallbacks.traceCmpLong((long) arguments[0], (long) arguments[1], hookId);
+  }
+
+  @MethodHook(type = HookType.BEFORE, targetClassName = "java.lang.Long",
+      targetMethod = "compareTo", targetMethodDescriptor = "(Ljava/lang/Long;)I")
+  public static void
+  longCompareTo(MethodHandle method, Long thisObject, Object[] arguments, int hookId) {
+    TraceDataFlowNativeCallbacks.traceCmpLong(thisObject, (long) arguments[0], hookId);
+  }
+
+  @MethodHook(type = HookType.BEFORE, targetClassName = "kotlin.jvm.internal.Intrinsics ",
+      targetMethod = "compare", targetMethodDescriptor = "(JJ)I")
+  public static void
+  longCompareKt(MethodHandle method, Object alwaysNull, Object[] arguments, int hookId) {
+    TraceDataFlowNativeCallbacks.traceCmpLong((long) arguments[0], (long) arguments[1], hookId);
+  }
+
+  @MethodHook(type = HookType.AFTER, targetClassName = "java.lang.String", targetMethod = "equals")
+  @MethodHook(type = HookType.AFTER, targetClassName = "java.lang.String",
+      targetMethod = "equalsIgnoreCase")
+  public static void
+  equals(MethodHandle method, String thisObject, Object[] arguments, int hookId, Boolean areEqual) {
+    if (!areEqual && arguments.length == 1 && arguments[0] instanceof String) {
+      // The precise value of the result of the comparison is not used by libFuzzer as long as it is
+      // non-zero.
+      TraceDataFlowNativeCallbacks.traceStrcmp(thisObject, (String) arguments[0], 1, hookId);
+    }
+  }
+
+  @MethodHook(type = HookType.AFTER, targetClassName = "java.lang.Object", targetMethod = "equals")
+  @MethodHook(
+      type = HookType.AFTER, targetClassName = "java.lang.CharSequence", targetMethod = "equals")
+  @MethodHook(type = HookType.AFTER, targetClassName = "java.lang.Number", targetMethod = "equals")
+  public static void
+  genericEquals(
+      MethodHandle method, Object thisObject, Object[] arguments, int hookId, Boolean areEqual) {
+    if (!areEqual && arguments.length == 1 && arguments[0] != null
+        && thisObject.getClass() == arguments[0].getClass()) {
+      TraceDataFlowNativeCallbacks.traceGenericCmp(thisObject, arguments[0], hookId);
+    }
+  }
+
+  @MethodHook(type = HookType.AFTER, targetClassName = "clojure.lang.Util", targetMethod = "equiv")
+  public static void genericStaticEquals(
+      MethodHandle method, Object thisObject, Object[] arguments, int hookId, Boolean areEqual) {
+    if (!areEqual && arguments.length == 2 && arguments[0] != null && arguments[1] != null
+        && arguments[1].getClass() == arguments[0].getClass()) {
+      TraceDataFlowNativeCallbacks.traceGenericCmp(arguments[0], arguments[1], hookId);
+    }
+  }
+
+  @MethodHook(
+      type = HookType.AFTER, targetClassName = "java.lang.String", targetMethod = "compareTo")
+  @MethodHook(type = HookType.AFTER, targetClassName = "java.lang.String",
+      targetMethod = "compareToIgnoreCase")
+  public static void
+  compareTo(
+      MethodHandle method, String thisObject, Object[] arguments, int hookId, Integer returnValue) {
+    if (returnValue != 0 && arguments.length == 1 && arguments[0] instanceof String) {
+      TraceDataFlowNativeCallbacks.traceStrcmp(
+          thisObject, (String) arguments[0], returnValue, hookId);
+    }
+  }
+
+  @MethodHook(
+      type = HookType.AFTER, targetClassName = "java.lang.String", targetMethod = "contentEquals")
+  public static void
+  contentEquals(MethodHandle method, String thisObject, Object[] arguments, int hookId,
+      Boolean areEqualContents) {
+    if (!areEqualContents && arguments.length == 1 && arguments[0] instanceof CharSequence) {
+      TraceDataFlowNativeCallbacks.traceStrcmp(
+          thisObject, ((CharSequence) arguments[0]).toString(), 1, hookId);
+    }
+  }
+
+  @MethodHook(type = HookType.AFTER, targetClassName = "java.lang.String",
+      targetMethod = "regionMatches", targetMethodDescriptor = "(ZILjava/lang/String;II)Z")
+  public static void
+  regionsMatches5(
+      MethodHandle method, Object thisObject, Object[] arguments, int hookId, Boolean returnValue) {
+    if (!returnValue) {
+      int toffset = (int) arguments[1];
+      String other = (String) arguments[2];
+      int ooffset = (int) arguments[3];
+      int len = (int) arguments[4];
+      regionMatchesInternal((String) thisObject, toffset, other, ooffset, len, hookId);
+    }
+  }
+
+  @MethodHook(type = HookType.AFTER, targetClassName = "java.lang.String",
+      targetMethod = "regionMatches", targetMethodDescriptor = "(ILjava/lang/String;II)Z")
+  public static void
+  regionMatches4(
+      MethodHandle method, Object thisObject, Object[] arguments, int hookId, Boolean returnValue) {
+    if (!returnValue) {
+      int toffset = (int) arguments[0];
+      String other = (String) arguments[1];
+      int ooffset = (int) arguments[2];
+      int len = (int) arguments[3];
+      regionMatchesInternal((String) thisObject, toffset, other, ooffset, len, hookId);
+    }
+  }
+
+  private static void regionMatchesInternal(
+      String thisString, int toffset, String other, int ooffset, int len, int hookId) {
+    if (toffset < 0 || ooffset < 0)
+      return;
+    int cappedThisStringEnd = Math.min(toffset + len, thisString.length());
+    int cappedOtherStringEnd = Math.min(ooffset + len, other.length());
+    String thisPart = thisString.substring(toffset, cappedThisStringEnd);
+    String otherPart = other.substring(ooffset, cappedOtherStringEnd);
+    TraceDataFlowNativeCallbacks.traceStrcmp(thisPart, otherPart, 1, hookId);
+  }
+
+  @MethodHook(
+      type = HookType.AFTER, targetClassName = "java.lang.String", targetMethod = "contains")
+  public static void
+  contains(
+      MethodHandle method, String thisObject, Object[] arguments, int hookId, Boolean doesContain) {
+    if (!doesContain && arguments.length == 1 && arguments[0] instanceof CharSequence) {
+      TraceDataFlowNativeCallbacks.traceStrstr(
+          thisObject, ((CharSequence) arguments[0]).toString(), hookId);
+    }
+  }
+
+  @MethodHook(type = HookType.AFTER, targetClassName = "java.lang.String", targetMethod = "indexOf")
+  @MethodHook(
+      type = HookType.AFTER, targetClassName = "java.lang.String", targetMethod = "lastIndexOf")
+  @MethodHook(
+      type = HookType.AFTER, targetClassName = "java.lang.StringBuffer", targetMethod = "indexOf")
+  @MethodHook(type = HookType.AFTER, targetClassName = "java.lang.StringBuffer",
+      targetMethod = "lastIndexOf")
+  @MethodHook(
+      type = HookType.AFTER, targetClassName = "java.lang.StringBuilder", targetMethod = "indexOf")
+  @MethodHook(type = HookType.AFTER, targetClassName = "java.lang.StringBuilder",
+      targetMethod = "lastIndexOf")
+  public static void
+  indexOf(
+      MethodHandle method, Object thisObject, Object[] arguments, int hookId, Integer returnValue) {
+    if (returnValue == -1 && arguments.length >= 1 && arguments[0] instanceof String) {
+      TraceDataFlowNativeCallbacks.traceStrstr(
+          thisObject.toString(), (String) arguments[0], hookId);
+    }
+  }
+
+  @MethodHook(
+      type = HookType.AFTER, targetClassName = "java.lang.String", targetMethod = "startsWith")
+  @MethodHook(
+      type = HookType.AFTER, targetClassName = "java.lang.String", targetMethod = "endsWith")
+  public static void
+  startsWith(MethodHandle method, String thisObject, Object[] arguments, int hookId,
+      Boolean doesStartOrEndsWith) {
+    if (!doesStartOrEndsWith && arguments.length >= 1 && arguments[0] instanceof String) {
+      TraceDataFlowNativeCallbacks.traceStrstr(thisObject, (String) arguments[0], hookId);
+    }
+  }
+
+  @MethodHook(type = HookType.AFTER, targetClassName = "java.lang.String", targetMethod = "replace",
+      targetMethodDescriptor =
+          "(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Ljava/lang/String;")
+  public static void
+  replace(
+      MethodHandle method, Object thisObject, Object[] arguments, int hookId, String returnValue) {
+    String original = (String) thisObject;
+    // Report only if the replacement was not successful.
+    if (original.equals(returnValue)) {
+      String target = arguments[0].toString();
+      TraceDataFlowNativeCallbacks.traceStrstr(original, target, hookId);
+    }
+  }
+
+  // For standard Kotlin packages, which are named according to the pattern kotlin.*, we append a
+  // whitespace to the package name of the target class so that they are not mangled due to shading.
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.jvm.internal.Intrinsics ",
+      targetMethod = "areEqual")
+  @MethodHook(
+      type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ", targetMethod = "equals")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "equals$default")
+  public static void
+  equalsKt(MethodHandle method, Object alwaysNull, Object[] arguments, int hookId,
+      Boolean equalStrings) {
+    if (!equalStrings && arguments.length >= 2 && arguments[0] instanceof String
+        && arguments[1] instanceof String) {
+      TraceDataFlowNativeCallbacks.traceStrcmp(
+          (String) arguments[0], (String) arguments[1], 1, hookId);
+    }
+  }
+
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "contentEquals")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "contentEquals$default")
+  public static void
+  contentEqualKt(MethodHandle method, Object alwaysNull, Object[] arguments, int hookId,
+      Boolean equalStrings) {
+    if (!equalStrings && arguments.length >= 2 && arguments[0] instanceof CharSequence
+        && arguments[1] instanceof CharSequence) {
+      TraceDataFlowNativeCallbacks.traceStrcmp(
+          arguments[0].toString(), arguments[1].toString(), 1, hookId);
+    }
+  }
+
+  @MethodHook(
+      type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ", targetMethod = "compareTo")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "compareTo$default")
+  public static void
+  compareToKt(
+      MethodHandle method, Object alwaysNull, Object[] arguments, int hookId, Integer returnValue) {
+    if (returnValue != 0 && arguments.length >= 2 && arguments[0] instanceof String
+        && arguments[1] instanceof String) {
+      TraceDataFlowNativeCallbacks.traceStrcmp(
+          (String) arguments[0], (String) arguments[1], 1, hookId);
+    }
+  }
+
+  @MethodHook(
+      type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ", targetMethod = "endsWith")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "endsWith$default")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "startsWith")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "startsWith$default")
+  public static void
+  startsWithKt(MethodHandle method, Object alwaysNull, Object[] arguments, int hookId,
+      Boolean doesStartOrEndsWith) {
+    if (!doesStartOrEndsWith && arguments.length >= 2 && arguments[0] instanceof CharSequence
+        && arguments[1] instanceof CharSequence) {
+      TraceDataFlowNativeCallbacks.traceStrstr(
+          arguments[0].toString(), arguments[1].toString(), hookId);
+    }
+  }
+
+  @MethodHook(
+      type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ", targetMethod = "contains")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "contains$default")
+  public static void
+  containsKt(
+      MethodHandle method, Object alwaysNull, Object[] arguments, int hookId, Boolean doesContain) {
+    if (!doesContain && arguments.length >= 2 && arguments[0] instanceof CharSequence
+        && arguments[1] instanceof CharSequence) {
+      TraceDataFlowNativeCallbacks.traceStrstr(
+          arguments[0].toString(), arguments[1].toString(), hookId);
+    }
+  }
+
+  @MethodHook(
+      type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ", targetMethod = "indexOf")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "indexOf$default")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "lastIndexOf")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "lastIndexOf$default")
+  public static void
+  indexOfKt(
+      MethodHandle method, Object alwaysNull, Object[] arguments, int hookId, Integer returnValue) {
+    if (returnValue != -1 || arguments.length < 2 || !(arguments[0] instanceof CharSequence)) {
+      return;
+    }
+    if (arguments[1] instanceof String) {
+      TraceDataFlowNativeCallbacks.traceStrstr(
+          arguments[0].toString(), (String) arguments[1], hookId);
+    } else if (arguments[1] instanceof Character) {
+      TraceDataFlowNativeCallbacks.traceStrstr(
+          arguments[0].toString(), ((Character) arguments[1]).toString(), hookId);
+    }
+  }
+
+  @MethodHook(
+      type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ", targetMethod = "replace")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "replace$default")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "replaceAfter")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "replaceAfter$default")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "replaceAfterLast")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "replaceAfterLast$default")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "replaceBefore")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "replaceBefore$default")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "replaceBeforeLast")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "replaceBeforeLast$default")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "replaceFirst")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "replaceFirst$default")
+  public static void
+  replaceKt(
+      MethodHandle method, Object alwaysNull, Object[] arguments, int hookId, String returnValue) {
+    if (arguments.length < 2 || !(arguments[0] instanceof String)) {
+      return;
+    }
+    String original = (String) arguments[0];
+    if (!original.equals(returnValue)) {
+      return;
+    }
+
+    // We currently don't handle the overloads that take a regex as a second argument.
+    if (arguments[1] instanceof String || arguments[1] instanceof Character) {
+      TraceDataFlowNativeCallbacks.traceStrstr(original, arguments[1].toString(), hookId);
+    }
+  }
+
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "regionMatches",
+      targetMethodDescriptor = "(Ljava/lang/String;ILjava/lang/String;IIZ)Z")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "regionMatches$default",
+      targetMethodDescriptor = "(Ljava/lang/String;ILjava/lang/String;IIZILjava/lang/Object;)Z")
+  public static void
+  regionMatchesKt(MethodHandle method, Object alwaysNull, Object[] arguments, int hookId,
+      Boolean doesRegionMatch) {
+    if (!doesRegionMatch) {
+      String thisString = arguments[0].toString();
+      int thisOffset = (int) arguments[1];
+      String other = arguments[2].toString();
+      int otherOffset = (int) arguments[3];
+      int length = (int) arguments[4];
+      regionMatchesInternal(thisString, thisOffset, other, otherOffset, length, hookId);
+    }
+  }
+
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "indexOfAny")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "indexOfAny$default")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "lastIndexOfAny")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "lastIndexOfAny$default")
+  public static void
+  indexOfAnyKt(
+      MethodHandle method, Object alwaysNull, Object[] arguments, int hookId, Integer returnValue) {
+    if (returnValue == -1 && arguments.length >= 2 && arguments[0] instanceof CharSequence) {
+      guideTowardContainmentOfFirstElement(arguments[0].toString(), arguments[1], hookId);
+    }
+  }
+
+  @MethodHook(
+      type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ", targetMethod = "findAnyOf")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "findAnyOf$default")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "findLastAnyOf")
+  @MethodHook(type = HookType.AFTER, targetClassName = "kotlin.text.StringsKt ",
+      targetMethod = "findLastAnyOf$default")
+  public static void
+  findAnyKt(
+      MethodHandle method, Object alwaysNull, Object[] arguments, int hookId, Object returnValue) {
+    if (returnValue == null && arguments.length >= 2 && arguments[0] instanceof CharSequence) {
+      guideTowardContainmentOfFirstElement(arguments[0].toString(), arguments[1], hookId);
+    }
+  }
+
+  private static void guideTowardContainmentOfFirstElement(
+      String containingString, Object candidateCollectionObj, int hookId) {
+    if (candidateCollectionObj instanceof Collection<?>) {
+      Collection<?> strings = (Collection<?>) candidateCollectionObj;
+      if (strings.isEmpty()) {
+        return;
+      }
+      Object firstElementObj = strings.iterator().next();
+      if (firstElementObj instanceof CharSequence) {
+        TraceDataFlowNativeCallbacks.traceStrstr(
+            containingString, firstElementObj.toString(), hookId);
+      }
+    } else if (candidateCollectionObj.getClass().isArray()) {
+      if (candidateCollectionObj.getClass().getComponentType() == char.class) {
+        char[] chars = (char[]) candidateCollectionObj;
+        if (chars.length > 0) {
+          TraceDataFlowNativeCallbacks.traceStrstr(
+              containingString, Character.toString(chars[0]), hookId);
+        }
+      }
+    }
+  }
+
+  @MethodHook(type = HookType.AFTER, targetClassName = "java.util.Arrays", targetMethod = "equals",
+      targetMethodDescriptor = "([B[B)Z")
+  public static void
+  arraysEquals(
+      MethodHandle method, Object thisObject, Object[] arguments, int hookId, Boolean returnValue) {
+    if (returnValue)
+      return;
+    byte[] first = (byte[]) arguments[0];
+    byte[] second = (byte[]) arguments[1];
+    TraceDataFlowNativeCallbacks.traceMemcmp(first, second, 1, hookId);
+  }
+
+  @MethodHook(type = HookType.AFTER, targetClassName = "java.util.Arrays", targetMethod = "equals",
+      targetMethodDescriptor = "([BII[BII)Z")
+  public static void
+  arraysEqualsRange(
+      MethodHandle method, Object thisObject, Object[] arguments, int hookId, Boolean returnValue) {
+    if (returnValue)
+      return;
+    byte[] first =
+        Arrays.copyOfRange((byte[]) arguments[0], (int) arguments[1], (int) arguments[2]);
+    byte[] second =
+        Arrays.copyOfRange((byte[]) arguments[3], (int) arguments[4], (int) arguments[5]);
+    TraceDataFlowNativeCallbacks.traceMemcmp(first, second, 1, hookId);
+  }
+
+  @MethodHook(type = HookType.AFTER, targetClassName = "java.util.Arrays", targetMethod = "compare",
+      targetMethodDescriptor = "([B[B)I")
+  @MethodHook(type = HookType.AFTER, targetClassName = "java.util.Arrays",
+      targetMethod = "compareUnsigned", targetMethodDescriptor = "([B[B)I")
+  public static void
+  arraysCompare(
+      MethodHandle method, Object thisObject, Object[] arguments, int hookId, Integer returnValue) {
+    if (returnValue == 0)
+      return;
+    byte[] first = (byte[]) arguments[0];
+    byte[] second = (byte[]) arguments[1];
+    TraceDataFlowNativeCallbacks.traceMemcmp(first, second, returnValue, hookId);
+  }
+
+  @MethodHook(type = HookType.AFTER, targetClassName = "java.util.Arrays", targetMethod = "compare",
+      targetMethodDescriptor = "([BII[BII)I")
+  @MethodHook(type = HookType.AFTER, targetClassName = "java.util.Arrays",
+      targetMethod = "compareUnsigned", targetMethodDescriptor = "([BII[BII)I")
+  public static void
+  arraysCompareRange(
+      MethodHandle method, Object thisObject, Object[] arguments, int hookId, Integer returnValue) {
+    if (returnValue == 0)
+      return;
+    byte[] first =
+        Arrays.copyOfRange((byte[]) arguments[0], (int) arguments[1], (int) arguments[2]);
+    byte[] second =
+        Arrays.copyOfRange((byte[]) arguments[3], (int) arguments[4], (int) arguments[5]);
+    TraceDataFlowNativeCallbacks.traceMemcmp(first, second, returnValue, hookId);
+  }
+
+  // The maximal number of elements of a non-TreeMap Map that will be sorted and searched for the
+  // key closest to the current lookup key in the mapGet hook.
+  private static final int MAX_NUM_KEYS_TO_ENUMERATE = 100;
+
+  @SuppressWarnings({"rawtypes", "unchecked"})
+  @MethodHook(type = HookType.AFTER, targetClassName = "java.util.Map", targetMethod = "get")
+  public static void mapGet(
+      MethodHandle method, Object thisObject, Object[] arguments, int hookId, Object returnValue) {
+    if (returnValue != null)
+      return;
+    if (arguments.length != 1) {
+      return;
+    }
+    if (thisObject == null)
+      return;
+    final Map map = (Map) thisObject;
+    if (map.size() == 0)
+      return;
+    final Object currentKey = arguments[0];
+    if (currentKey == null)
+      return;
+    // Find two valid map keys that bracket currentKey.
+    // This is a generalization of libFuzzer's __sanitizer_cov_trace_switch:
+    // https://github.com/llvm/llvm-project/blob/318942de229beb3b2587df09e776a50327b5cef0/compiler-rt/lib/fuzzer/FuzzerTracePC.cpp#L564
+    Object lowerBoundKey = null;
+    Object upperBoundKey = null;
+    try {
+      if (map instanceof TreeMap) {
+        final TreeMap treeMap = (TreeMap) map;
+        try {
+          lowerBoundKey = treeMap.floorKey(currentKey);
+          upperBoundKey = treeMap.ceilingKey(currentKey);
+        } catch (ClassCastException ignored) {
+          // Can be thrown by floorKey and ceilingKey if currentKey is of a type that can't be
+          // compared to the maps keys.
+        }
+      } else if (currentKey instanceof Comparable) {
+        final Comparable comparableCurrentKey = (Comparable) currentKey;
+        // Find two keys that bracket currentKey.
+        // Note: This is not deterministic if map.size() > MAX_NUM_KEYS_TO_ENUMERATE.
+        int enumeratedKeys = 0;
+        for (Object validKey : map.keySet()) {
+          if (!(validKey instanceof Comparable))
+            continue;
+          final Comparable comparableValidKey = (Comparable) validKey;
+          // If the key sorts lower than the non-existing key, but higher than the current lower
+          // bound, update the lower bound and vice versa for the upper bound.
+          try {
+            if (comparableValidKey.compareTo(comparableCurrentKey) < 0
+                && (lowerBoundKey == null || comparableValidKey.compareTo(lowerBoundKey) > 0)) {
+              lowerBoundKey = validKey;
+            }
+            if (comparableValidKey.compareTo(comparableCurrentKey) > 0
+                && (upperBoundKey == null || comparableValidKey.compareTo(upperBoundKey) < 0)) {
+              upperBoundKey = validKey;
+            }
+          } catch (ClassCastException ignored) {
+            // Can be thrown by floorKey and ceilingKey if currentKey is of a type that can't be
+            // compared to the maps keys.
+          }
+          if (enumeratedKeys++ > MAX_NUM_KEYS_TO_ENUMERATE)
+            break;
+        }
+      }
+    } catch (ConcurrentModificationException ignored) {
+      // map was modified by another thread, skip this invocation
+      return;
+    }
+    // Modify the hook ID so that compares against distinct valid keys are traced separately.
+    if (lowerBoundKey != null) {
+      TraceDataFlowNativeCallbacks.traceGenericCmp(
+          currentKey, lowerBoundKey, hookId + lowerBoundKey.hashCode());
+    }
+    if (upperBoundKey != null) {
+      TraceDataFlowNativeCallbacks.traceGenericCmp(
+          currentKey, upperBoundKey, hookId + upperBoundKey.hashCode());
+    }
+  }
+
+  @MethodHook(type = HookType.AFTER, targetClassName = "org.junit.jupiter.api.Assertions",
+      targetMethod = "assertNotEquals",
+      targetMethodDescriptor = "(Ljava/lang/Object;Ljava/lang/Object;)V")
+  @MethodHook(type = HookType.AFTER, targetClassName = "org.junit.jupiter.api.Assertions",
+      targetMethod = "assertNotEquals",
+      targetMethodDescriptor = "(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/String;)V")
+  @MethodHook(type = HookType.AFTER, targetClassName = "org.junit.jupiter.api.Assertions",
+      targetMethod = "assertNotEquals",
+      targetMethodDescriptor =
+          "(Ljava/lang/Object;Ljava/lang/Object;Ljava/util/function/Supplier;)V")
+  public static void
+  assertEquals(MethodHandle method, Object node, Object[] args, int hookId, Object alwaysNull) {
+    if (args[0] != null && args[1] != null && args[0].getClass() == args[1].getClass()) {
+      TraceDataFlowNativeCallbacks.traceGenericCmp(args[0], args[1], hookId);
+    }
+  }
+}
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/runtime/TraceDataFlowNativeCallbacks.java b/src/main/java/com/code_intelligence/jazzer/runtime/TraceDataFlowNativeCallbacks.java
similarity index 73%
rename from agent/src/main/java/com/code_intelligence/jazzer/runtime/TraceDataFlowNativeCallbacks.java
rename to src/main/java/com/code_intelligence/jazzer/runtime/TraceDataFlowNativeCallbacks.java
index 821ade0..777eb0a 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/runtime/TraceDataFlowNativeCallbacks.java
+++ b/src/main/java/com/code_intelligence/jazzer/runtime/TraceDataFlowNativeCallbacks.java
@@ -14,17 +14,16 @@
 
 package com.code_intelligence.jazzer.runtime;
 
-import com.code_intelligence.jazzer.utils.Utils;
 import com.github.fmeum.rules_jni.RulesJni;
+import java.lang.reflect.Constructor;
 import java.lang.reflect.Executable;
+import java.lang.reflect.Method;
 import java.nio.charset.Charset;
+import java.util.Arrays;
+import org.objectweb.asm.Type;
 
 @SuppressWarnings("unused")
 final public class TraceDataFlowNativeCallbacks {
-  static {
-    RulesJni.loadLibrary("jazzer_driver", "/com/code_intelligence/jazzer/driver");
-  }
-
   // Note that we are not encoding as modified UTF-8 here: The FuzzedDataProvider transparently
   // converts CESU8 into modified UTF-8 by coding null bytes on two bytes. Since the fuzzer is more
   // likely to insert literal null bytes, having both the fuzzer input and the reported string
@@ -32,21 +31,45 @@
   // UTF-8.
   private static final Charset FUZZED_DATA_CHARSET = Charset.forName("CESU8");
 
+  static {
+    RulesJni.loadLibrary("jazzer_driver", "/com/code_intelligence/jazzer/driver");
+  }
+
+  // It is possible for RulesJni#loadLibrary to trigger a hook even though it isn't instrumented if
+  // it uses regexes, which it does with at least some JDKs due to its use of String#format. This
+  // led to exceptions in the past when the hook ended up calling traceStrcmp or traceStrstr before
+  // the static initializer was run: FUZZED_DATA_CHARSET used to be initialized after the call and
+  // thus still had the value null when encodeForLibFuzzer was called, resulting in an NPE in
+  // String#getBytes(Charset). Just switching the order may actually make this bug worse: It could
+  // now lead to traceMemcmp being called before the native library has been loaded. We guard
+  // against this by making the hooks noops when static initialization of this class hasn't
+  // completed yet.
+  private static final boolean NATIVE_INITIALIZED = true;
+
   public static native void traceMemcmp(byte[] b1, byte[] b2, int result, int pc);
 
   public static void traceStrcmp(String s1, String s2, int result, int pc) {
-    traceMemcmp(encodeForLibFuzzer(s1), encodeForLibFuzzer(s2), result, pc);
+    if (NATIVE_INITIALIZED) {
+      traceMemcmp(encodeForLibFuzzer(s1), encodeForLibFuzzer(s2), result, pc);
+    }
   }
 
   public static void traceStrstr(String s1, String s2, int pc) {
-    traceStrstr0(encodeForLibFuzzer(s2), pc);
+    if (NATIVE_INITIALIZED) {
+      traceStrstr0(encodeForLibFuzzer(s2), pc);
+    }
   }
 
   public static void traceReflectiveCall(Executable callee, int pc) {
     String className = callee.getDeclaringClass().getCanonicalName();
     String executableName = callee.getName();
-    String descriptor = Utils.getDescriptor(callee);
-    tracePcIndir(Utils.simpleFastHash(className, executableName, descriptor), pc);
+    String descriptor;
+    if (callee instanceof Method) {
+      descriptor = Type.getMethodDescriptor((Method) callee);
+    } else {
+      descriptor = Type.getConstructorDescriptor((Constructor<?>) callee);
+    }
+    tracePcIndir(Arrays.hashCode(new String[] {className, executableName, descriptor}), pc);
   }
 
   public static int traceCmpLongWrapper(long arg1, long arg2, int pc) {
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/runtime/TraceDivHooks.java b/src/main/java/com/code_intelligence/jazzer/runtime/TraceDivHooks.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/runtime/TraceDivHooks.java
rename to src/main/java/com/code_intelligence/jazzer/runtime/TraceDivHooks.java
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/runtime/TraceIndirHooks.java b/src/main/java/com/code_intelligence/jazzer/runtime/TraceIndirHooks.java
similarity index 100%
rename from agent/src/main/java/com/code_intelligence/jazzer/runtime/TraceIndirHooks.java
rename to src/main/java/com/code_intelligence/jazzer/runtime/TraceIndirHooks.java
diff --git a/src/main/java/com/code_intelligence/jazzer/runtime/bootstrap_shade_rules b/src/main/java/com/code_intelligence/jazzer/runtime/bootstrap_shade_rules
new file mode 100644
index 0000000..0cafcf0
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/runtime/bootstrap_shade_rules
@@ -0,0 +1,4 @@
+rule com.github.fmeum.rules_jni.** com.code_intelligence.jazzer.bootstrap.@0
+rule kotlin.** com.code_intelligence.jazzer.bootstrap.@0
+rule net.sf.jsqlparser.** com.code_intelligence.jazzer.bootstrap.@0
+rule org.objectweb.asm.** com.code_intelligence.jazzer.bootstrap.@0
diff --git a/agent/verify_shading.sh b/src/main/java/com/code_intelligence/jazzer/runtime/verify_shading.sh
similarity index 89%
copy from agent/verify_shading.sh
copy to src/main/java/com/code_intelligence/jazzer/runtime/verify_shading.sh
index 5742476..b3a74ea 100755
--- a/agent/verify_shading.sh
+++ b/src/main/java/com/code_intelligence/jazzer/runtime/verify_shading.sh
@@ -13,16 +13,15 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+[ -f "$1" ] || exit 1
 # List all files in the jar and exclude an allowed list of files.
 # Since grep fails if there is no match, ! ... | grep ... fails if there is a
 # match.
 ! external/local_jdk/bin/jar tf "$1" | \
   grep -v \
-    -e '^build-data.properties$' \
     -e '^com/$' \
     -e '^com/code_intelligence/$' \
     -e '^com/code_intelligence/jazzer/' \
     -e '^jaz/' \
-    -e '^win32-x86/' \
-    -e '^win32-x86-64/' \
-    -e '^META-INF/'
+    -e '^META-INF/$' \
+    -e '^META-INF/MANIFEST.MF$'
diff --git a/src/main/java/com/code_intelligence/jazzer/utils/BUILD.bazel b/src/main/java/com/code_intelligence/jazzer/utils/BUILD.bazel
new file mode 100644
index 0000000..ff9e0d3
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/utils/BUILD.bazel
@@ -0,0 +1,70 @@
+load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library")
+load("//bazel:kotlin.bzl", "ktlint")
+
+kt_jvm_library(
+    name = "utils",
+    srcs = ["Utils.kt"],
+    visibility = ["//visibility:public"],
+)
+
+kt_jvm_library(
+    name = "class_name_globber",
+    srcs = ["ClassNameGlobber.kt"],
+    visibility = [
+        "//src/main/java/com/code_intelligence/jazzer/agent:__pkg__",
+        "//src/main/java/com/code_intelligence/jazzer/instrumentor:__pkg__",
+    ],
+    deps = [":simple_glob_matcher"],
+)
+
+java_library(
+    name = "log",
+    srcs = ["Log.java"],
+    visibility = ["//visibility:public"],
+)
+
+kt_jvm_library(
+    name = "manifest_utils",
+    srcs = ["ManifestUtils.kt"],
+    visibility = [
+        "//src/main/java/com/code_intelligence/jazzer/agent:__pkg__",
+        "//src/main/java/com/code_intelligence/jazzer/driver:__pkg__",
+    ],
+    deps = [":log"],
+)
+
+kt_jvm_library(
+    name = "simple_glob_matcher",
+    srcs = ["SimpleGlobMatcher.kt"],
+    visibility = [
+        "//src/main/java/com/code_intelligence/jazzer/autofuzz:__pkg__",
+    ],
+)
+
+java_library(
+    name = "unsafe_provider",
+    srcs = ["UnsafeProvider.java"],
+    visibility = [
+        "//:__subpackages__",
+    ],
+)
+
+java_library(
+    name = "unsafe_utils",
+    srcs = ["UnsafeUtils.java"],
+    visibility = [
+        "//:__subpackages__",
+    ],
+    deps = [
+        ":unsafe_provider",
+        "@org_ow2_asm_asm//jar",
+    ],
+)
+
+java_library(
+    name = "zip_utils",
+    srcs = ["ZipUtils.java"],
+    visibility = ["//visibility:public"],
+)
+
+ktlint()
diff --git a/src/main/java/com/code_intelligence/jazzer/utils/ClassNameGlobber.kt b/src/main/java/com/code_intelligence/jazzer/utils/ClassNameGlobber.kt
new file mode 100644
index 0000000..c6fa20a
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/utils/ClassNameGlobber.kt
@@ -0,0 +1,66 @@
+// Copyright 2021 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.utils
+
+private val BASE_INCLUDED_CLASS_NAME_GLOBS = listOf(
+    "**", // everything
+)
+
+// We use both a strong indicator for running as a Bazel test together with an indicator for a
+// Bazel coverage run to rule out false positives.
+private val IS_BAZEL_COVERAGE_RUN = System.getenv("TEST_UNDECLARED_OUTPUTS_DIR") != null &&
+    System.getenv("COVERAGE_DIR") != null
+
+private val ADDITIONAL_EXCLUDED_NAME_GLOBS_FOR_BAZEL_COVERAGE = listOf(
+    "com.google.testing.coverage.**",
+    "org.jacoco.**",
+)
+
+private val BASE_EXCLUDED_CLASS_NAME_GLOBS = listOf(
+    // JDK internals
+    "\\[**", // array types
+    "java.**",
+    "javax.**",
+    "jdk.**",
+    "sun.**",
+    "com.sun.**", // package for Proxy objects
+    // Azul JDK internals
+    "com.azul.tooling.**",
+    // Kotlin internals
+    "kotlin.**",
+    // Jazzer internals
+    "com.code_intelligence.jazzer.**",
+    "jaz.Ter", // safe companion of the honeypot class used by sanitizers
+    "jaz.Zer", // honeypot class used by sanitizers
+    // Test and instrumentation tools
+    "org.junit.**", // dependency of @FuzzTest
+    "org.mockito.**", // can cause instrumentation cycles
+    "net.bytebuddy.**", // ignore Byte Buddy, though it's probably shaded
+    "org.jetbrains.**", // ignore JetBrains products (coverage agent)
+) + if (IS_BAZEL_COVERAGE_RUN) ADDITIONAL_EXCLUDED_NAME_GLOBS_FOR_BAZEL_COVERAGE else listOf()
+
+class ClassNameGlobber(includes: List<String>, excludes: List<String>) {
+    // If no include globs are provided, start with all classes.
+    private val includeMatchers = includes.ifEmpty { BASE_INCLUDED_CLASS_NAME_GLOBS }
+        .map(::SimpleGlobMatcher)
+
+    // If no include globs are provided, additionally exclude stdlib classes as well as our own classes.
+    private val excludeMatchers = (if (includes.isEmpty()) BASE_EXCLUDED_CLASS_NAME_GLOBS + excludes else excludes)
+        .map(::SimpleGlobMatcher)
+
+    fun includes(className: String): Boolean {
+        return includeMatchers.any { it.matches(className) } && excludeMatchers.none { it.matches(className) }
+    }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/utils/Log.java b/src/main/java/com/code_intelligence/jazzer/utils/Log.java
new file mode 100644
index 0000000..bccd3a3
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/utils/Log.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.utils;
+
+import java.io.PrintStream;
+
+/**
+ * Provides static functions that should be used for any kind of output (structured or unstructured)
+ * emitted by the fuzzer.
+ *
+ * <p>Output is printed to {@link System#err} and {@link System#out} until {@link
+ * Log#fixOutErr(PrintStream, PrintStream)} is called, which locks in the {@link PrintStream}s to be
+ * used from there on.
+ */
+public class Log {
+  // Don't use directly, always use getOut() and getErr() instead - when these fields haven't been
+  // set yet, we want to resolve them dynamically as System.out and System.err, which may change
+  // over the course of the fuzzer's lifetime.
+  private static PrintStream fixedOut;
+  private static PrintStream fixedErr;
+
+  /**
+   * The {@link PrintStream}s to use for all output from this call on.
+   */
+  public static void fixOutErr(PrintStream out, PrintStream err) {
+    if (out == null) {
+      throw new IllegalArgumentException("out must not be null");
+    }
+    if (err == null) {
+      throw new IllegalArgumentException("err must not be null");
+    }
+    Log.fixedOut = out;
+    Log.fixedErr = err;
+  }
+
+  public static void println(String message) {
+    getErr().println(message);
+  }
+
+  public static void structuredOutput(String output) {
+    getOut().println(output);
+  }
+
+  public static void info(String message) {
+    println("INFO: ", message, null);
+  }
+
+  public static void warn(String message) {
+    warn(message, null);
+  }
+
+  public static void warn(String message, Throwable t) {
+    println("WARN: ", message, t);
+  }
+
+  public static void error(String message) {
+    error(message, null);
+  }
+
+  public static void error(Throwable t) {
+    error(null, t);
+  }
+
+  public static void error(String message, Throwable t) {
+    println("ERROR: ", message, t);
+  }
+
+  public static void finding(Throwable t) {
+    println("\n== Java Exception: ", null, t);
+  }
+
+  private static void println(String prefix, String message, Throwable t) {
+    PrintStream err = getErr();
+    err.print(prefix);
+    if (message != null) {
+      err.println(message + (t != null ? ":" : ""));
+    }
+    if (t != null) {
+      t.printStackTrace(err);
+    }
+  }
+
+  private static PrintStream getOut() {
+    return fixedOut != null ? fixedOut : System.out;
+  }
+
+  private static PrintStream getErr() {
+    return fixedErr != null ? fixedErr : System.err;
+  }
+}
diff --git a/agent/src/main/java/com/code_intelligence/jazzer/utils/ManifestUtils.kt b/src/main/java/com/code_intelligence/jazzer/utils/ManifestUtils.kt
similarity index 84%
rename from agent/src/main/java/com/code_intelligence/jazzer/utils/ManifestUtils.kt
rename to src/main/java/com/code_intelligence/jazzer/utils/ManifestUtils.kt
index e7165e5..9d413a0 100644
--- a/agent/src/main/java/com/code_intelligence/jazzer/utils/ManifestUtils.kt
+++ b/src/main/java/com/code_intelligence/jazzer/utils/ManifestUtils.kt
@@ -22,7 +22,7 @@
     const val HOOK_CLASSES = "Jazzer-Hook-Classes"
 
     fun combineManifestValues(attribute: String): List<String> {
-        val manifests = ClassLoader.getSystemResources("META-INF/MANIFEST.MF")
+        val manifests = ManifestUtils::class.java.classLoader.getResources("META-INF/MANIFEST.MF")
         return manifests.asSequence().mapNotNull { url ->
             url.openStream().use { inputStream ->
                 val manifest = Manifest(inputStream)
@@ -42,11 +42,7 @@
             0 -> null
             1 -> fuzzTargets.first()
             else -> {
-                println(
-                    """
-                    |WARN: More than one Jazzer-Fuzz-Target-Class manifest entry detected on the
-                    |classpath.""".trimMargin()
-                )
+                Log.warn("More than one Jazzer-Fuzz-Target-Class manifest entry detected on the classpath.")
                 null
             }
         }
diff --git a/src/main/java/com/code_intelligence/jazzer/utils/SimpleGlobMatcher.kt b/src/main/java/com/code_intelligence/jazzer/utils/SimpleGlobMatcher.kt
new file mode 100644
index 0000000..fb497fd
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/utils/SimpleGlobMatcher.kt
@@ -0,0 +1,71 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.utils
+
+class SimpleGlobMatcher(val glob: String) {
+    private enum class Type {
+        // foo.bar (matches foo.bar only)
+        FULL_MATCH,
+
+        // foo.** (matches foo.bar and foo.bar.baz)
+        PATH_WILDCARD_SUFFIX,
+
+        // foo.* (matches foo.bar, but not foo.bar.baz)
+        SEGMENT_WILDCARD_SUFFIX,
+    }
+
+    private val type: Type
+    private val prefix: String
+
+    init {
+        // Remain compatible with globs such as "\\[" that use escaping.
+        val pattern = glob.replace("\\", "")
+        when {
+            !pattern.contains('*') -> {
+                type = Type.FULL_MATCH
+                prefix = pattern
+            }
+            // Ends with "**" and contains no other '*'.
+            pattern.endsWith("**") && pattern.indexOf('*') == pattern.length - 2 -> {
+                type = Type.PATH_WILDCARD_SUFFIX
+                prefix = pattern.removeSuffix("**")
+            }
+            // Ends with "*" and contains no other '*'.
+            pattern.endsWith('*') && pattern.indexOf('*') == pattern.length - 1 -> {
+                type = Type.SEGMENT_WILDCARD_SUFFIX
+                prefix = pattern.removeSuffix("*")
+            }
+            else -> throw IllegalArgumentException(
+                "Unsupported glob pattern (only foo.bar, foo.* and foo.** are supported): $pattern",
+            )
+        }
+    }
+
+    /**
+     * Checks whether [maybeInternalClassName], which may be internal (foo/bar) or not (foo.bar), matches [glob].
+     */
+    fun matches(maybeInternalClassName: String): Boolean {
+        val className = maybeInternalClassName.replace('/', '.')
+        return when (type) {
+            Type.FULL_MATCH -> className == prefix
+            Type.PATH_WILDCARD_SUFFIX -> className.startsWith(prefix)
+            Type.SEGMENT_WILDCARD_SUFFIX -> {
+                // className starts with prefix and contains no further '.'.
+                className.startsWith(prefix) &&
+                    className.indexOf('.', startIndex = prefix.length) == -1
+            }
+        }
+    }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/utils/UnsafeProvider.java b/src/main/java/com/code_intelligence/jazzer/utils/UnsafeProvider.java
new file mode 100644
index 0000000..e36e64c
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/utils/UnsafeProvider.java
@@ -0,0 +1,56 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.utils;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import sun.misc.Unsafe;
+
+public final class UnsafeProvider {
+  private static final Unsafe UNSAFE = getUnsafeInternal();
+
+  public static Unsafe getUnsafe() {
+    return UNSAFE;
+  }
+
+  private static Unsafe getUnsafeInternal() {
+    try {
+      // The Jazzer runtime is loaded by the bootstrap class loader and should thus pass the
+      // security checks in getUnsafe, so try that first.
+      return Unsafe.getUnsafe();
+    } catch (Throwable unused) {
+      // If not running as an agent, use the classical reflection trick to get an Unsafe instance,
+      // taking into account that the private field may have a name other than "theUnsafe":
+      // https://android.googlesource.com/platform/libcore/+/gingerbread/luni/src/main/java/sun/misc/Unsafe.java#32
+      for (Field f : Unsafe.class.getDeclaredFields()) {
+        if (f.getType() == Unsafe.class) {
+          f.setAccessible(true);
+          try {
+            return (Unsafe) f.get(null);
+          } catch (IllegalAccessException e) {
+            throw new IllegalStateException(
+                "Please file a bug at https://github.com/CodeIntelligenceTesting/jazzer/issues/new "
+                    + "with this information: Failed to access Unsafe member on Unsafe class",
+                e);
+          }
+        }
+      }
+      throw new IllegalStateException(String.format(
+          "Please file a bug at https://github.com/CodeIntelligenceTesting/jazzer/issues/new with "
+          + "this information: Failed to find Unsafe member on Unsafe class, have: "
+          + Arrays.deepToString(Unsafe.class.getDeclaredFields())));
+    }
+  }
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/utils/UnsafeUtils.java b/src/main/java/com/code_intelligence/jazzer/utils/UnsafeUtils.java
new file mode 100644
index 0000000..30c88dc
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/utils/UnsafeUtils.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.utils;
+
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodHandles.Lookup;
+import java.lang.reflect.Array;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.Arrays;
+import java.util.Optional;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.Opcodes;
+
+public final class UnsafeUtils {
+  /**
+   * Dynamically creates a concrete class implementing the given abstract class.
+   *
+   * <p>The returned class will not be functional and should only be used to construct instances
+   * via {@link sun.misc.Unsafe#allocateInstance(Class)}.
+   */
+  public static <T> Class<? extends T> defineAnonymousConcreteSubclass(Class<T> abstractClass) {
+    if (!Modifier.isAbstract(abstractClass.getModifiers())) {
+      throw new IllegalArgumentException(abstractClass + " is not abstract");
+    }
+
+    ClassWriter cw = new ClassWriter(0);
+    String superClassName = abstractClass.getName().replace('.', '/');
+    // Only the package of the class name matters, the actual name is generated. defineHiddenClass
+    // requires the package of the new class to match the one of the lookup.
+    String className = UnsafeUtils.class.getPackage().getName().replace('.', '/') + "/Anonymous";
+    cw.visit(Opcodes.V1_8, 0, className, null, superClassName, null);
+    cw.visitEnd();
+
+    try {
+      Optional<Method> defineHiddenClass =
+          Arrays.stream(Lookup.class.getMethods())
+              .filter(method -> method.getName().equals("defineHiddenClass"))
+              .findFirst();
+      Optional<Class<?>> classOption =
+          Arrays.stream(Lookup.class.getClasses())
+              .filter(clazz -> clazz.getSimpleName().equals("ClassOption"))
+              .findFirst();
+      // MethodHandles.Lookup#defineHiddenClass is available as of Java 15.
+      // Unsafe#defineAnonymousClass has been removed in Java 17.
+      if (defineHiddenClass.isPresent() && classOption.isPresent()) {
+        return ((MethodHandles.Lookup) defineHiddenClass.get().invoke(MethodHandles.lookup(),
+                    cw.toByteArray(), true, Array.newInstance(classOption.get(), 0)))
+            .lookupClass()
+            .asSubclass(abstractClass);
+      } else {
+        return (Class<? extends T>) UnsafeProvider.getUnsafe()
+            .getClass()
+            .getMethod("defineAnonymousClass", Class.class, byte[].class, Object[].class)
+            .invoke(UnsafeProvider.getUnsafe(), UnsafeUtils.class, cw.toByteArray(), null);
+      }
+    } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
+      throw new IllegalStateException(e);
+    }
+  }
+
+  private UnsafeUtils() {}
+}
diff --git a/src/main/java/com/code_intelligence/jazzer/utils/Utils.kt b/src/main/java/com/code_intelligence/jazzer/utils/Utils.kt
new file mode 100644
index 0000000..2de4782
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/utils/Utils.kt
@@ -0,0 +1,45 @@
+// Copyright 2021 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+@file:JvmName("Utils")
+
+package com.code_intelligence.jazzer.utils
+
+import java.lang.reflect.Executable
+
+val Class<*>.readableDescriptor: String
+    get() = when {
+        isPrimitive -> {
+            when (this) {
+                Boolean::class.javaPrimitiveType -> "boolean"
+                Byte::class.javaPrimitiveType -> "byte"
+                Char::class.javaPrimitiveType -> "char"
+                Short::class.javaPrimitiveType -> "short"
+                Int::class.javaPrimitiveType -> "int"
+                Long::class.javaPrimitiveType -> "long"
+                Float::class.javaPrimitiveType -> "float"
+                Double::class.javaPrimitiveType -> "double"
+                java.lang.Void::class.javaPrimitiveType -> "void"
+                else -> throw IllegalStateException("Unknown primitive type: $name")
+            }
+        }
+        isArray -> "${componentType.readableDescriptor}[]"
+        java.lang.Object::class.java.isAssignableFrom(this) -> name
+        else -> throw IllegalArgumentException("Unknown class type: $name")
+    }
+
+// This does not include the return type as the parameter descriptors already uniquely identify the executable.
+val Executable.readableDescriptor: String
+    get() = parameterTypes.joinToString(separator = ",", prefix = "(", postfix = ")") { parameterType ->
+        parameterType.readableDescriptor
+    }
diff --git a/src/main/java/com/code_intelligence/jazzer/utils/ZipUtils.java b/src/main/java/com/code_intelligence/jazzer/utils/ZipUtils.java
new file mode 100644
index 0000000..4da35c3
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/utils/ZipUtils.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.utils;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.lang.IllegalArgumentException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+import java.util.zip.ZipOutputStream;
+
+public final class ZipUtils {
+  private ZipUtils() {}
+
+  public static Set<String> mergeZipToZip(String src, ZipOutputStream zos, Set<String> skipFiles)
+      throws IOException {
+    HashSet<String> filesAdded = new HashSet<>();
+    try (JarFile jarFile = new JarFile(src)) {
+      // Copy entries from src to dst (jarFile to ZipOutputStream)
+      Enumeration<JarEntry> allEntries = jarFile.entries();
+      while (allEntries.hasMoreElements()) {
+        JarEntry entry = allEntries.nextElement();
+        if (skipFiles != null && skipFiles.contains(entry.getName())) {
+          continue;
+        }
+
+        zos.putNextEntry(new ZipEntry(entry.getName()));
+        try (InputStream is = jarFile.getInputStream(entry)) {
+          byte[] buf = new byte[1024];
+          int i = 0;
+          while ((i = is.read(buf)) != -1) {
+            zos.write(buf, 0, i);
+          }
+
+          zos.closeEntry();
+          filesAdded.add(entry.getName());
+        }
+      }
+    }
+
+    return filesAdded;
+  }
+
+  public static Set<String> mergeDirectoryToZip(String src, ZipOutputStream zos,
+      Set<String> skipFiles) throws IllegalArgumentException, IOException {
+    HashSet<String> filesAdded = new HashSet<>();
+    File sourceDir = new File(src);
+    if (!sourceDir.isDirectory()) {
+      throw new IllegalArgumentException("Argument src must be a directory.");
+    }
+
+    Files.walkFileTree(sourceDir.toPath(), new SimpleFileVisitor<Path>() {
+      public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
+        String zipPath = sourceDir.toPath().relativize(file).toString();
+        if (skipFiles.stream().anyMatch(zipPath::endsWith)) {
+          return FileVisitResult.CONTINUE;
+        }
+
+        zos.putNextEntry(new ZipEntry(zipPath));
+        Files.copy(file, zos);
+        filesAdded.add(zipPath);
+        return FileVisitResult.CONTINUE;
+      }
+    });
+
+    return filesAdded;
+  }
+
+  public static void extractFile(String srcZip, String targetFile, String outputFilePath)
+      throws IOException {
+    try (OutputStream out = new FileOutputStream(outputFilePath);
+         ZipInputStream zis = new ZipInputStream(new FileInputStream(srcZip));) {
+      ZipEntry ze = zis.getNextEntry();
+      while (ze != null) {
+        if (ze.getName().equals(targetFile)) {
+          byte[] buf = new byte[1024];
+          int read = 0;
+
+          while ((read = zis.read(buf)) > -1) {
+            out.write(buf, 0, read);
+          }
+
+          out.close();
+          break;
+        }
+
+        ze = zis.getNextEntry();
+      }
+    }
+  }
+}
diff --git a/src/main/java/jaz/BUILD.bazel b/src/main/java/jaz/BUILD.bazel
new file mode 100644
index 0000000..8acf82f
--- /dev/null
+++ b/src/main/java/jaz/BUILD.bazel
@@ -0,0 +1,8 @@
+filegroup(
+    name = "jaz",
+    srcs = [
+        "Ter.java",
+        "Zer.java",
+    ],
+    visibility = ["//src/main/java/com/code_intelligence/jazzer/api:__pkg__"],
+)
diff --git a/agent/src/main/java/jaz/Ter.java b/src/main/java/jaz/Ter.java
similarity index 72%
rename from agent/src/main/java/jaz/Ter.java
rename to src/main/java/jaz/Ter.java
index 7814396..e7f825a 100644
--- a/agent/src/main/java/jaz/Ter.java
+++ b/src/main/java/jaz/Ter.java
@@ -21,4 +21,16 @@
 @SuppressWarnings("unused")
 public class Ter implements java.io.Serializable {
   static final long serialVersionUID = 42L;
+
+  public static final byte REFLECTIVE_CALL_SANITIZER_ID = 0;
+  public static final byte DESERIALIZATION_SANITIZER_ID = 1;
+  public static final byte EXPRESSION_LANGUAGE_SANITIZER_ID = 2;
+
+  private byte sanitizer = REFLECTIVE_CALL_SANITIZER_ID;
+
+  public Ter() {}
+
+  public Ter(byte sanitizer) {
+    this.sanitizer = sanitizer;
+  }
 }
diff --git a/src/main/java/jaz/Zer.java b/src/main/java/jaz/Zer.java
new file mode 100644
index 0000000..b4d4904
--- /dev/null
+++ b/src/main/java/jaz/Zer.java
@@ -0,0 +1,361 @@
+// Copyright 2021 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package jaz;
+
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueHigh;
+import com.code_intelligence.jazzer.api.Jazzer;
+import java.io.*;
+import java.util.*;
+import java.util.concurrent.Callable;
+import java.util.function.Function;
+
+/**
+ * A honeypot class that reports a finding on initialization.
+ *
+ * Class loading based on externally controlled data could lead to RCE
+ * depending on available classes on the classpath. Even if no applicable
+ * gadget class is available, allowing input to control class loading is a bad
+ * idea and should be prevented. A finding is generated whenever the class
+ * is loaded and initialized, regardless of its further use.
+ * <p>
+ * This class needs to implement {@link Serializable} to be considered in
+ * deserialization scenarios. It also implements common constructors, getter
+ * and setter and common interfaces to increase chances of passing
+ * deserialization checks.
+ * <p>
+ * <b>Note</b>: Jackson provides a nice list of "nasty classes" at
+ * <a
+ * href=https://github.com/FasterXML/jackson-databind/blob/2.14/src/main/java/com/fasterxml/jackson/databind/jsontype/impl/SubTypeValidator.java>SubTypeValidator</a>.
+ * <p>
+ * <b>Note</b>: This class must not be referenced in any way by the rest of the code, not even
+ * statically. When referring to it, always use its hardcoded class name {@code jaz.Zer}.
+ */
+@SuppressWarnings({"rawtypes", "unused"})
+public class Zer
+    implements Serializable, Cloneable, Comparable<Zer>, Comparator, Closeable, Flushable, Iterable,
+               Iterator, Runnable, Callable, Function, Collection, List {
+  static final long serialVersionUID = 42L;
+
+  // serialized size is 41 bytes
+  private static final byte REFLECTIVE_CALL_SANITIZER_ID = 0;
+  private static final byte DESERIALIZATION_SANITIZER_ID = 1;
+  private static final byte EXPRESSION_LANGUAGE_SANITIZER_ID = 2;
+
+  // A byte representing the relevant sanitizer for a given jaz.Zer instance. It is used to check
+  // whether the corresponding sanitizer is disabled and jaz.Zer will not report a finding in this
+  // case. Each sanitizer which relies on this class must set this byte accordingly. We choose a
+  // single byte to represent the sanitizer in order to keep the serialized version of jaz.Zer
+  // objects small (currently 41 bytes) so that it fits in the 64 byte limit of the words that can
+  // be used with Jazzer's methods that guide the fuzzer towards generating inputs that contain or
+  // are equal to target strings. This limit comes from the corresponding libFuzzer hooks that
+  // Jazzer uses under the hood.
+  private byte sanitizer = REFLECTIVE_CALL_SANITIZER_ID;
+
+  // Common constructors
+  public Zer() {
+    reportFindingIfEnabled();
+  }
+
+  public Zer(String arg1) {
+    reportFindingIfEnabled();
+  }
+
+  public Zer(String arg1, Throwable arg2) {
+    reportFindingIfEnabled();
+  }
+
+  public Zer(byte sanitizer) {
+    this.sanitizer = sanitizer;
+    reportFindingIfEnabled();
+  }
+
+  // A special static method that is called by the expression language injection sanitizer. We
+  // choose a parameterless method to keep the string that the sanitizer guides the fuzzer to
+  // generate within the 64-byte boundary required by the corresponding guiding methods.
+  public static void el() {
+    if (isSanitizerEnabled(EXPRESSION_LANGUAGE_SANITIZER_ID)) {
+      reportFinding();
+    }
+  }
+
+  private void reportFindingIfEnabled() {
+    if (isSanitizerEnabled(sanitizer)) {
+      reportFinding();
+    }
+  }
+
+  private static void reportFinding() {
+    Jazzer.reportFindingFromHook(new FuzzerSecurityIssueHigh("Remote Code Execution\n"
+        + "Unrestricted class/object creation based on externally controlled data may allow\n"
+        + "remote code execution depending on available classes on the classpath."));
+  }
+
+  private static boolean isSanitizerEnabled(byte sanitizerId) {
+    String allDisabledHooks = System.getProperty("jazzer.disabled_hooks");
+    if (allDisabledHooks == null || allDisabledHooks.equals("")) {
+      return true;
+    }
+
+    String sanitizer;
+    switch (sanitizerId) {
+      case DESERIALIZATION_SANITIZER_ID:
+        sanitizer = "com.code_intelligence.jazzer.sanitizers.Deserialization";
+        break;
+      case EXPRESSION_LANGUAGE_SANITIZER_ID:
+        sanitizer = "com.code_intelligence.jazzer.sanitizers.ExpressionLanguageInjection";
+        break;
+      default:
+        sanitizer = "com.code_intelligence.jazzer.sanitizers.ReflectiveCall";
+    }
+    return Arrays.stream(allDisabledHooks.split(",")).noneMatch(sanitizer::equals);
+  }
+
+  // Getter/Setter
+
+  public Object getJaz() {
+    reportFindingIfEnabled();
+    return this;
+  }
+
+  public void setJaz(String jaz) {
+    reportFindingIfEnabled();
+  }
+
+  @Override
+  public int hashCode() {
+    reportFindingIfEnabled();
+    return super.hashCode();
+  }
+
+  @Override
+  public boolean equals(Object obj) {
+    reportFindingIfEnabled();
+    return super.equals(obj);
+  }
+
+  @Override
+  public String toString() {
+    reportFindingIfEnabled();
+    return super.toString();
+  }
+
+  // Common interface stubs
+
+  @Override
+  public void close() {
+    reportFindingIfEnabled();
+  }
+
+  @Override
+  public void flush() {
+    reportFindingIfEnabled();
+  }
+
+  @Override
+  public int compareTo(Zer o) {
+    reportFindingIfEnabled();
+    return 0;
+  }
+
+  @Override
+  public int compare(Object o1, Object o2) {
+    reportFindingIfEnabled();
+    return 0;
+  }
+
+  @Override
+  public int size() {
+    reportFindingIfEnabled();
+    return 0;
+  }
+
+  @Override
+  public boolean isEmpty() {
+    reportFindingIfEnabled();
+    return false;
+  }
+
+  @Override
+  public boolean contains(Object o) {
+    reportFindingIfEnabled();
+    return false;
+  }
+
+  @Override
+  public Object[] toArray() {
+    reportFindingIfEnabled();
+    return new Object[0];
+  }
+
+  @Override
+  public boolean add(Object o) {
+    reportFindingIfEnabled();
+    return false;
+  }
+
+  @Override
+  public boolean remove(Object o) {
+    reportFindingIfEnabled();
+    return false;
+  }
+
+  @Override
+  public boolean addAll(Collection c) {
+    reportFindingIfEnabled();
+    return false;
+  }
+
+  @Override
+  public boolean addAll(int index, Collection c) {
+    reportFindingIfEnabled();
+    return false;
+  }
+
+  @Override
+  public void clear() {
+    reportFindingIfEnabled();
+  }
+
+  @Override
+  public Object get(int index) {
+    reportFindingIfEnabled();
+    return this;
+  }
+
+  @Override
+  public Object set(int index, Object element) {
+    reportFindingIfEnabled();
+    return this;
+  }
+
+  @Override
+  public void add(int index, Object element) {
+    reportFindingIfEnabled();
+  }
+
+  @Override
+  public Object remove(int index) {
+    reportFindingIfEnabled();
+    return this;
+  }
+
+  @Override
+  public int indexOf(Object o) {
+    reportFindingIfEnabled();
+    return 0;
+  }
+
+  @Override
+  public int lastIndexOf(Object o) {
+    reportFindingIfEnabled();
+    return 0;
+  }
+
+  @Override
+  @SuppressWarnings("ConstantConditions")
+  public ListIterator listIterator() {
+    reportFindingIfEnabled();
+    return null;
+  }
+
+  @Override
+  @SuppressWarnings("ConstantConditions")
+  public ListIterator listIterator(int index) {
+    reportFindingIfEnabled();
+    return null;
+  }
+
+  @Override
+  public List subList(int fromIndex, int toIndex) {
+    reportFindingIfEnabled();
+    return this;
+  }
+
+  @Override
+  public boolean retainAll(Collection c) {
+    reportFindingIfEnabled();
+    return false;
+  }
+
+  @Override
+  public boolean removeAll(Collection c) {
+    reportFindingIfEnabled();
+    return false;
+  }
+
+  @Override
+  public boolean containsAll(Collection c) {
+    reportFindingIfEnabled();
+    return false;
+  }
+
+  @Override
+  public Object[] toArray(Object[] a) {
+    reportFindingIfEnabled();
+    return new Object[0];
+  }
+
+  @Override
+  public Iterator iterator() {
+    reportFindingIfEnabled();
+    return this;
+  }
+
+  @Override
+  public void run() {
+    reportFindingIfEnabled();
+  }
+
+  @Override
+  public boolean hasNext() {
+    reportFindingIfEnabled();
+    return false;
+  }
+
+  @Override
+  public Object next() {
+    reportFindingIfEnabled();
+    return this;
+  }
+
+  @Override
+  public Object call() throws Exception {
+    reportFindingIfEnabled();
+    return this;
+  }
+
+  @Override
+  public Object apply(Object o) {
+    reportFindingIfEnabled();
+    return this;
+  }
+
+  @Override
+  @SuppressWarnings("MethodDoesntCallSuperMethod")
+  public Object clone() {
+    reportFindingIfEnabled();
+    return this;
+  }
+
+  // readObject calls can directly result in RCE, see https://github.com/frohoff/ysoserial for
+  // examples. Since deserialization doesn't call constructors (see
+  // https://docs.oracle.com/javase/7/docs/platform/serialization/spec/input.html#2971), we emit a
+  // finding right in the readObject method.
+  private void readObject(ObjectInputStream stream) throws IOException, ClassNotFoundException {
+    // Need to read in ourselves to initialize the sanitizer field.
+    stream.defaultReadObject();
+    reportFindingIfEnabled();
+  }
+}
diff --git a/src/main/native/com/code_intelligence/jazzer/BUILD.bazel b/src/main/native/com/code_intelligence/jazzer/BUILD.bazel
new file mode 100644
index 0000000..689adc9
--- /dev/null
+++ b/src/main/native/com/code_intelligence/jazzer/BUILD.bazel
@@ -0,0 +1,60 @@
+load("@fmeum_rules_jni//jni:defs.bzl", "cc_jni_library")
+load("//bazel:compat.bzl", "MULTI_PLATFORM", "SKIP_ON_WINDOWS")
+
+DYNAMIC_SYMBOLS_TO_EXPORT = [
+    "__sancov_lowest_stack",
+    "__sanitizer_cov_8bit_counters_init",
+    "__sanitizer_cov_pcs_init",
+    "__sanitizer_cov_trace_cmp1",
+    "__sanitizer_cov_trace_cmp4",
+    "__sanitizer_cov_trace_cmp4",
+    "__sanitizer_cov_trace_cmp8",
+    "__sanitizer_cov_trace_const_cmp1",
+    "__sanitizer_cov_trace_const_cmp4",
+    "__sanitizer_cov_trace_const_cmp4",
+    "__sanitizer_cov_trace_const_cmp8",
+    "__sanitizer_cov_trace_div4",
+    "__sanitizer_cov_trace_div8",
+    "__sanitizer_cov_trace_gep",
+    "__sanitizer_cov_trace_pc_indir",
+    "__sanitizer_cov_trace_switch",
+    "__sanitizer_weak_hook_memcmp",
+    "__sanitizer_weak_hook_memmem",
+    "__sanitizer_weak_hook_strcasecmp",
+    "__sanitizer_weak_hook_strcasestr",
+    "__sanitizer_weak_hook_strcmp",
+    "__sanitizer_weak_hook_strncasecmp",
+    "__sanitizer_weak_hook_strncmp",
+    "__sanitizer_weak_hook_strstr",
+    "bcmp",
+    "jazzer_preload_init",
+    "memcmp",
+    "memmem",
+    "strcasecmp",
+    "strcasestr",
+    "strcmp",
+    "strncasecmp",
+    "strncmp",
+    "strstr",
+]
+
+cc_jni_library(
+    name = "jazzer_preload",
+    srcs = ["jazzer_preload.c"],
+    linkopts = select({
+        "@platforms//os:linux": [
+            "-Wl,--export-dynamic-symbol=" + symbol
+            for symbol in DYNAMIC_SYMBOLS_TO_EXPORT
+        ] + [
+            "-ldl",
+        ],
+        "@platforms//os:macos": [
+            "-ldl",
+        ],
+        "//conditions:default": [],
+    }),
+    platforms = MULTI_PLATFORM,
+    target_compatible_with = SKIP_ON_WINDOWS,
+    visibility = ["//src/main/java/com/code_intelligence/jazzer:__pkg__"],
+    deps = ["//src/main/native/com/code_intelligence/jazzer/driver:sanitizer_hooks_with_pc"],
+)
diff --git a/src/main/native/com/code_intelligence/jazzer/android/BUILD.bazel b/src/main/native/com/code_intelligence/jazzer/android/BUILD.bazel
new file mode 100644
index 0000000..74f98cd
--- /dev/null
+++ b/src/main/native/com/code_intelligence/jazzer/android/BUILD.bazel
@@ -0,0 +1,47 @@
+load("//bazel:compat.bzl", "SKIP_ON_WINDOWS")
+load("@fmeum_rules_jni//jni:defs.bzl", "cc_jni_library")
+load("@bazel_skylib//rules:copy_file.bzl", "copy_file")
+
+copy_file(
+    name = "jvmti_h_encoded",
+    src = "@android_jvmti//file",
+    out = "jvmti.encoded",
+    is_executable = False,
+    tags = ["manual"],
+    target_compatible_with = SKIP_ON_WINDOWS,
+)
+
+genrule(
+    name = "jvmti_h",
+    srcs = [
+        "jvmti.encoded",
+    ],
+    outs = ["jvmti.h"],
+    cmd = "base64 --decode $< > $(OUTS)",
+    tags = ["manual"],
+    target_compatible_with = SKIP_ON_WINDOWS,
+)
+
+cc_jni_library(
+    name = "android_native_agent",
+    srcs = [
+        "dex_file_manager.cpp",
+        "dex_file_manager.h",
+        "jazzer_jvmti_allocator.h",
+        "native_agent.cpp",
+        ":jvmti_h",
+    ],
+    includes = [
+        ".",
+    ],
+    linkopts = [
+        "-lz",
+    ],
+    tags = ["manual"],
+    target_compatible_with = SKIP_ON_WINDOWS,
+    visibility = ["//visibility:public"],
+    deps = [
+        "@com_google_absl//absl/strings",
+        "@jazzer_slicer",
+    ],
+)
diff --git a/src/main/native/com/code_intelligence/jazzer/android/dex_file_manager.cpp b/src/main/native/com/code_intelligence/jazzer/android/dex_file_manager.cpp
new file mode 100644
index 0000000..b409e82
--- /dev/null
+++ b/src/main/native/com/code_intelligence/jazzer/android/dex_file_manager.cpp
@@ -0,0 +1,208 @@
+// Copyright 2023 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "dex_file_manager.h"
+
+#include <algorithm>
+#include <iostream>
+#include <sstream>
+#include <string>
+#include <vector>
+
+#include "jazzer_jvmti_allocator.h"
+#include "jvmti.h"
+#include "slicer/dex_ir.h"
+#include "slicer/reader.h"
+#include "slicer/writer.h"
+
+std::string GetName(const char* name) {
+  std::stringstream ss;
+  // Class name needs to be in the format "L<class_name>;" as it is stored in
+  // the types table in the DEX file for slicer to find it
+  ss << "L" << name << ";";
+  return ss.str();
+}
+
+bool IsValidIndex(dex::u4 index) { return index != (unsigned)-1; }
+
+void DexFileManager::addDexFile(const unsigned char* bytes, int length) {
+  unsigned char* newArr = new unsigned char[length];
+  std::copy(bytes, bytes + length, newArr);
+
+  dexFiles.push_back(newArr);
+  dexFilesSize.push_back(length);
+}
+
+unsigned char* DexFileManager::getClassBytes(const char* className,
+                                             int dexFileIndex, jvmtiEnv* jvmti,
+                                             size_t* newSize) {
+  dex::Reader dexReader(dexFiles[dexFileIndex], dexFilesSize[dexFileIndex]);
+  auto descName = GetName(className);
+
+  auto classIndex = dexReader.FindClassIndex(descName.c_str());
+  if (!IsValidIndex(classIndex)) {
+    *newSize = *newSize;
+    return nullptr;
+  }
+
+  dexReader.CreateClassIr(classIndex);
+  auto oldIr = dexReader.GetIr();
+
+  dex::Writer writer(oldIr);
+  JazzerJvmtiAllocator allocator(jvmti);
+  return writer.CreateImage(&allocator, newSize);
+}
+
+uint32_t DexFileManager::findDexFileForClass(const char* className) {
+  for (int i = 0; i < dexFiles.size(); i++) {
+    dex::Reader dexReader(dexFiles[i], dexFilesSize[i]);
+
+    std::string descName = GetName(className);
+    dex::u4 classIndex = dexReader.FindClassIndex(descName.c_str());
+
+    if (IsValidIndex(classIndex)) {
+      return i;
+    }
+  }
+
+  return -1;
+}
+
+std::vector<std::string> getMethodDescriptions(
+    std::vector<ir::EncodedMethod*>* encMethodList) {
+  std::vector<std::string> methodDescs;
+
+  for (int i = 0; i < encMethodList->size(); i++) {
+    std::stringstream ss;
+    ss << (*encMethodList)[i]->access_flags;
+    ss << (*encMethodList)[i]->decl->name->c_str();
+    ss << (*encMethodList)[i]->decl->prototype->Signature().c_str();
+
+    methodDescs.push_back(ss.str());
+  }
+
+  sort(methodDescs.begin(), methodDescs.end());
+  return methodDescs;
+}
+
+std::vector<std::string> getFieldDescriptions(
+    std::vector<ir::EncodedField*>* encFieldList) {
+  std::vector<std::string> fieldDescs;
+
+  for (int i = 0; i < encFieldList->size(); i++) {
+    std::stringstream ss;
+    ss << (*encFieldList)[i]->access_flags;
+    ss << (*encFieldList)[i]->decl->type->descriptor->c_str();
+    ss << (*encFieldList)[i]->decl->name->c_str();
+    fieldDescs.push_back(ss.str());
+  }
+
+  sort(fieldDescs.begin(), fieldDescs.end());
+  return fieldDescs;
+}
+
+bool matchFields(std::vector<ir::EncodedField*>* encodedFieldListOne,
+                 std::vector<ir::EncodedField*>* encodedFieldListTwo) {
+  std::vector<std::string> fDescListOne =
+      getFieldDescriptions(encodedFieldListOne);
+  std::vector<std::string> fDescListTwo =
+      getFieldDescriptions(encodedFieldListTwo);
+
+  if (fDescListOne.size() != fDescListTwo.size()) {
+    return false;
+  }
+
+  for (int i = 0; i < fDescListOne.size(); i++) {
+    if (fDescListOne[i] != fDescListTwo[i]) {
+      return false;
+    }
+  }
+
+  return true;
+}
+
+bool matchMethods(std::vector<ir::EncodedMethod*>* encodedMethodListOne,
+                  std::vector<ir::EncodedMethod*>* encodedMethodListTwo) {
+  std::vector<std::string> mDescListOne =
+      getMethodDescriptions(encodedMethodListOne);
+  std::vector<std::string> mDescListTwo =
+      getMethodDescriptions(encodedMethodListTwo);
+
+  if (mDescListOne.size() != mDescListTwo.size()) {
+    return false;
+  }
+
+  for (int i = 0; i < mDescListOne.size(); i++) {
+    if (mDescListOne[i] != mDescListTwo[i]) {
+      return false;
+    }
+  }
+
+  return true;
+}
+
+bool classStructureMatches(ir::Class* classOne, ir::Class* classTwo) {
+  return matchMethods(&(classOne->direct_methods),
+                      &(classTwo->direct_methods)) &&
+         matchMethods(&(classOne->virtual_methods),
+                      &(classTwo->virtual_methods)) &&
+         matchFields(&(classOne->static_fields), &(classTwo->static_fields)) &&
+         matchFields(&(classOne->instance_fields),
+                     &(classTwo->instance_fields)) &&
+         classOne->access_flags == classTwo->access_flags;
+}
+
+bool DexFileManager::structureMatches(dex::Reader* oldReader,
+                                      dex::Reader* newReader,
+                                      const char* className) {
+  std::string descName = GetName(className);
+
+  dex::u4 oldReaderIndex = oldReader->FindClassIndex(descName.c_str());
+  dex::u4 newReaderIndex = newReader->FindClassIndex(descName.c_str());
+
+  if (!IsValidIndex(oldReaderIndex) || !IsValidIndex(newReaderIndex)) {
+    return false;
+  }
+
+  oldReader->CreateClassIr(oldReaderIndex);
+  newReader->CreateClassIr(newReaderIndex);
+
+  std::shared_ptr<ir::DexFile> oldDexFile = oldReader->GetIr();
+  std::shared_ptr<ir::DexFile> newDexFile = newReader->GetIr();
+
+  for (int i = 0; i < oldDexFile->classes.size(); i++) {
+    const char* oldClassDescriptor =
+        oldDexFile->classes[i]->type->descriptor->c_str();
+    if (strcmp(oldClassDescriptor, descName.c_str()) != 0) {
+      continue;
+    }
+
+    bool match = false;
+    for (int j = 0; j < newDexFile->classes.size(); j++) {
+      const char* newClassDescriptor =
+          newDexFile->classes[j]->type->descriptor->c_str();
+      if (strcmp(oldClassDescriptor, newClassDescriptor) == 0) {
+        match = classStructureMatches(oldDexFile->classes[i].get(),
+                                      newDexFile->classes[j].get());
+        break;
+      }
+    }
+
+    if (!match) {
+      return false;
+    }
+  }
+
+  return true;
+}
diff --git a/src/main/native/com/code_intelligence/jazzer/android/dex_file_manager.h b/src/main/native/com/code_intelligence/jazzer/android/dex_file_manager.h
new file mode 100644
index 0000000..2b7dd67
--- /dev/null
+++ b/src/main/native/com/code_intelligence/jazzer/android/dex_file_manager.h
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include <vector>
+
+#include "jvmti.h"
+#include "slicer/reader.h"
+
+// DexFileManager will contain the contents to multiple DEX files
+class DexFileManager {
+ public:
+  DexFileManager() {}
+
+  void addDexFile(const unsigned char* bytes, int length);
+  unsigned char* getClassBytes(const char* className, int dexFileIndex,
+                               jvmtiEnv* jvmti, size_t* newSize);
+  uint32_t findDexFileForClass(const char* className);
+  bool structureMatches(dex::Reader* oldReader, dex::Reader* newReader,
+                        const char* className);
+
+ private:
+  std::vector<unsigned char*> dexFiles;
+  std::vector<int> dexFilesSize;
+};
diff --git a/src/main/native/com/code_intelligence/jazzer/android/jazzer_jvmti_allocator.h b/src/main/native/com/code_intelligence/jazzer/android/jazzer_jvmti_allocator.h
new file mode 100644
index 0000000..0748c17
--- /dev/null
+++ b/src/main/native/com/code_intelligence/jazzer/android/jazzer_jvmti_allocator.h
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include <iostream>
+
+#include "slicer/writer.h"
+
+class JazzerJvmtiAllocator : public dex::Writer::Allocator {
+ public:
+  JazzerJvmtiAllocator(jvmtiEnv* jvmti_env) : jvmti_env_(jvmti_env) {}
+
+  virtual void* Allocate(size_t size) {
+    unsigned char* alloc = nullptr;
+    jvmtiError error_num = jvmti_env_->Allocate(size, &alloc);
+
+    if (error_num != JVMTI_ERROR_NONE) {
+      std::cerr << "JazzerJvmtiAllocator Allocation error. JVMTI error: "
+                << error_num << std::endl;
+    }
+
+    return (void*)alloc;
+  }
+
+  virtual void Free(void* ptr) {
+    if (ptr == nullptr) {
+      return;
+    }
+
+    jvmtiError error_num = jvmti_env_->Deallocate((unsigned char*)ptr);
+
+    if (error_num != JVMTI_ERROR_NONE) {
+      std::cout << "JazzerJvmtiAllocator Free error. JVMTI error: " << error_num
+                << std::endl;
+    }
+  }
+
+ private:
+  jvmtiEnv* jvmti_env_;
+};
diff --git a/src/main/native/com/code_intelligence/jazzer/android/native_agent.cpp b/src/main/native/com/code_intelligence/jazzer/android/native_agent.cpp
new file mode 100644
index 0000000..9f0b2ad
--- /dev/null
+++ b/src/main/native/com/code_intelligence/jazzer/android/native_agent.cpp
@@ -0,0 +1,313 @@
+// Copyright 2023 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include <dlfcn.h>
+#include <jni.h>
+
+#include <fstream>
+#include <iostream>
+#include <map>
+#include <memory>
+#include <sstream>
+#include <string>
+#include <unordered_set>
+#include <vector>
+
+#include "absl/strings/str_split.h"
+#include "dex_file_manager.h"
+#include "jazzer_jvmti_allocator.h"
+#include "jvmti.h"
+#include "slicer/arrayview.h"
+#include "slicer/dex_format.h"
+#include "slicer/reader.h"
+#include "slicer/writer.h"
+
+static std::string agentOptions;
+static DexFileManager dfm;
+
+const std::string kAndroidAgentClass =
+    "com/code_intelligence/jazzer/android/DexFileManager";
+
+void retransformLoadedClasses(jvmtiEnv* jvmti, JNIEnv* env) {
+  jint classCount = 0;
+  jclass* classes;
+
+  jvmti->GetLoadedClasses(&classCount, &classes);
+
+  std::vector<jclass> classesToRetransform;
+  for (int i = 0; i < classCount; i++) {
+    jboolean isModifiable = false;
+    jvmti->IsModifiableClass(classes[i], &isModifiable);
+
+    if ((bool)isModifiable) {
+      classesToRetransform.push_back(classes[i]);
+    }
+  }
+
+  jvmtiError errorNum = jvmti->RetransformClasses(classesToRetransform.size(),
+                                                  &classesToRetransform[0]);
+  if (errorNum != JVMTI_ERROR_NONE) {
+    std::cerr << "Could not retransform classes. JVMTI error: " << errorNum
+              << std::endl;
+    exit(1);
+  }
+}
+
+std::vector<std::string> getDexFiles(std::string jarPath, JNIEnv* env) {
+  jclass jazzerClass = env->FindClass(kAndroidAgentClass.c_str());
+  if (jazzerClass == nullptr) {
+    std::cerr << kAndroidAgentClass << " could not be found" << std::endl;
+    exit(1);
+  }
+
+  const char* getDexFilesFunction = "getDexFilesForJar";
+  jmethodID getDexFilesForJar =
+      env->GetStaticMethodID(jazzerClass, getDexFilesFunction,
+                             "(Ljava/lang/String;)[Ljava/lang/String;");
+  if (getDexFilesForJar == nullptr) {
+    std::cerr << getDexFilesFunction << " could not be found\n";
+    exit(1);
+  }
+
+  jstring jJarFile = env->NewStringUTF(jarPath.data());
+  jobjectArray dexFilesArray = (jobjectArray)env->CallStaticObjectMethod(
+      jazzerClass, getDexFilesForJar, jJarFile);
+
+  if (env->ExceptionCheck()) {
+    env->ExceptionDescribe();
+    exit(1);
+  }
+
+  int length = env->GetArrayLength(dexFilesArray);
+
+  std::vector<std::string> dexFilesResult;
+  for (int i = 0; i < length; i++) {
+    jstring dexFileJstring =
+        (jstring)env->GetObjectArrayElement(dexFilesArray, i);
+    const char* dexFileChars = env->GetStringUTFChars(dexFileJstring, NULL);
+    std::string dexFileString(dexFileChars);
+
+    env->ReleaseStringUTFChars(dexFileJstring, dexFileChars);
+    dexFilesResult.push_back(dexFileString);
+  }
+
+  return dexFilesResult;
+}
+
+void initializeBootclassOverrideJar(std::string jarPath, JNIEnv* env) {
+  std::vector<std::string> dexFiles = getDexFiles(jarPath, env);
+
+  std::cerr << "Adding DEX files for: " << jarPath << std::endl;
+  for (int i = 0; i < dexFiles.size(); i++) {
+    std::cerr << "DEX FILE: " << dexFiles[i] << std::endl;
+  }
+
+  for (int i = 0; i < dexFiles.size(); i++) {
+    jclass bootHelperClass = env->FindClass(kAndroidAgentClass.c_str());
+    if (bootHelperClass == nullptr) {
+      std::cerr << kAndroidAgentClass << " could not be found" << std::endl;
+      exit(1);
+    }
+
+    jmethodID getBytecodeFromDex =
+        env->GetStaticMethodID(bootHelperClass, "getBytecodeFromDex",
+                               "(Ljava/lang/String;Ljava/lang/String;)[B");
+    if (getBytecodeFromDex == nullptr) {
+      std::cerr << "'getBytecodeFromDex' not found\n";
+      exit(1);
+    }
+
+    jstring jjarPath = env->NewStringUTF(jarPath.data());
+    jstring jdexFile = env->NewStringUTF(dexFiles[i].data());
+
+    int length = 1;
+    std::vector<unsigned char> dexFileBytes;
+
+    jbyteArray dexBytes = (jbyteArray)env->CallStaticObjectMethod(
+        bootHelperClass, getBytecodeFromDex, jjarPath, jdexFile);
+
+    if (env->ExceptionCheck()) {
+      env->ExceptionDescribe();
+      exit(1);
+    }
+
+    jbyte* data = new jbyte;
+    data = env->GetByteArrayElements(dexBytes, 0);
+    length = env->GetArrayLength(dexBytes);
+
+    for (int j = 0; j < length; j++) {
+      dexFileBytes.push_back(data[j]);
+    }
+
+    env->DeleteLocalRef(dexBytes);
+    env->DeleteLocalRef(jjarPath);
+    env->DeleteLocalRef(jdexFile);
+    env->DeleteLocalRef(bootHelperClass);
+
+    unsigned char* usData = reinterpret_cast<unsigned char*>(&dexFileBytes[0]);
+    dfm.addDexFile(usData, length);
+  }
+}
+
+void JNICALL jazzerClassFileLoadHook(
+    jvmtiEnv* jvmti, JNIEnv* jni_env, jclass class_being_redefined,
+    jobject loader, const char* name, jobject protection_domain,
+    jint class_data_len, const unsigned char* class_data,
+    jint* new_class_data_len, unsigned char** new_class_data) {
+  // check if Jazzer class
+  const char* prefix = "com/code_intelligence/jazzer/";
+  if (strncmp(name, prefix, 29) == 0) {
+    return;
+  }
+
+  int indx = dfm.findDexFileForClass(name);
+  if (indx < 0) {
+    return;
+  }
+
+  size_t newSize;
+  unsigned char* newClassDataResult =
+      dfm.getClassBytes(name, indx, jvmti, &newSize);
+
+  dex::Reader oldReader(const_cast<unsigned char*>(class_data),
+                        (size_t)class_data_len);
+  dex::Reader newReader(newClassDataResult, newSize);
+  if (dfm.structureMatches(&oldReader, &newReader, name)) {
+    std::cout << "REDEFINING WITH INSTRUMENTATION:  " << name << std::endl;
+    *new_class_data = newClassDataResult;
+    *new_class_data_len = static_cast<jint>(newSize);
+  }
+}
+
+bool fileExists(std::string filePath) { return std::ifstream(filePath).good(); }
+
+void JNICALL jazzerVMInit(jvmtiEnv* jvmti_env, JNIEnv* jni_env,
+                          jthread thread) {
+  // Parse agentOptions
+
+  std::stringstream ss(agentOptions);
+  std::string token;
+
+  std::string jazzerClassesJar;
+  std::vector<std::string> bootpathClassesOverrides;
+  while (std::getline(ss, token, ',')) {
+    std::vector<std::string> split =
+        absl::StrSplit(token, absl::MaxSplits('=', 1));
+    if (split.size() < 2) {
+      std::cerr << "ERROR: no option given for: " << token;
+      exit(1);
+    }
+
+    if (split[0] == "injectJars") {
+      jazzerClassesJar = split[1];
+    } else if (split[0] == "bootstrapClassOverrides") {
+      bootpathClassesOverrides =
+          absl::StrSplit(split[1], absl::MaxSplits(':', 10));
+    }
+  }
+
+  if (!fileExists(jazzerClassesJar)) {
+    std::cerr << "ERROR: Jazzer bootstrap class file not found at: "
+              << jazzerClassesJar << std::endl;
+    exit(1);
+  }
+
+  jvmti_env->AddToBootstrapClassLoaderSearch(jazzerClassesJar.c_str());
+
+  jvmtiCapabilities jazzerJvmtiCapabilities = {
+      .can_tag_objects = 0,
+      .can_generate_field_modification_events = 0,
+      .can_generate_field_access_events = 0,
+      .can_get_bytecodes = 0,
+      .can_get_synthetic_attribute = 0,
+      .can_get_owned_monitor_info = 0,
+      .can_get_current_contended_monitor = 0,
+      .can_get_monitor_info = 0,
+      .can_pop_frame = 0,
+      .can_redefine_classes = 1,
+      .can_signal_thread = 0,
+      .can_get_source_file_name = 1,
+      .can_get_line_numbers = 0,
+      .can_get_source_debug_extension = 0,
+      .can_access_local_variables = 0,
+      .can_maintain_original_method_order = 0,
+      .can_generate_single_step_events = 0,
+      .can_generate_exception_events = 0,
+      .can_generate_frame_pop_events = 0,
+      .can_generate_breakpoint_events = 0,
+      .can_suspend = 0,
+      .can_redefine_any_class = 0,
+      .can_get_current_thread_cpu_time = 0,
+      .can_get_thread_cpu_time = 0,
+      .can_generate_method_entry_events = 0,
+      .can_generate_method_exit_events = 0,
+      .can_generate_all_class_hook_events = 0,
+      .can_generate_compiled_method_load_events = 0,
+      .can_generate_monitor_events = 0,
+      .can_generate_vm_object_alloc_events = 0,
+      .can_generate_native_method_bind_events = 0,
+      .can_generate_garbage_collection_events = 0,
+      .can_generate_object_free_events = 0,
+      .can_force_early_return = 0,
+      .can_get_owned_monitor_stack_depth_info = 0,
+      .can_get_constant_pool = 0,
+      .can_set_native_method_prefix = 0,
+      .can_retransform_classes = 1,
+      .can_retransform_any_class = 0,
+      .can_generate_resource_exhaustion_heap_events = 0,
+      .can_generate_resource_exhaustion_threads_events = 0,
+  };
+
+  jvmtiError je = jvmti_env->AddCapabilities(&jazzerJvmtiCapabilities);
+  if (je != JVMTI_ERROR_NONE) {
+    std::cerr << "JVMTI ERROR: " << je << std::endl;
+    exit(1);
+  }
+
+  for (int i = 0; i < bootpathClassesOverrides.size(); i++) {
+    if (!fileExists(bootpathClassesOverrides[i])) {
+      std::cerr << "ERROR: Bootpath Class override jar not found at: "
+                << bootpathClassesOverrides[i] << std::endl;
+      exit(1);
+    }
+
+    initializeBootclassOverrideJar(bootpathClassesOverrides[i], jni_env);
+  }
+
+  retransformLoadedClasses(jvmti_env, jni_env);
+}
+
+JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM* vm, char* options, void* reserved) {
+  jvmtiEnv* jvmti = nullptr;
+  if (vm->GetEnv((void**)&jvmti, JVMTI_VERSION_1_2) != JNI_OK) {
+    return 1;
+  }
+
+  jvmtiEventCallbacks callbacks;
+
+  memset(&callbacks, 0, sizeof(callbacks));
+  callbacks.ClassFileLoadHook = jazzerClassFileLoadHook;
+  callbacks.VMInit = jazzerVMInit;
+
+  jvmti->SetEventCallbacks(&callbacks, sizeof(jvmtiEventCallbacks));
+  jvmti->SetEventNotificationMode(JVMTI_ENABLE,
+                                  JVMTI_EVENT_CLASS_FILE_LOAD_HOOK, NULL);
+  jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_VM_INIT, NULL);
+
+  // Save the options string here, this is the only time it will be available
+  // however, we wont be able to use this to initialize until VMInit callback is
+  // called
+  agentOptions = std::string(options);
+  return 0;
+}
diff --git a/src/main/native/com/code_intelligence/jazzer/driver/BUILD.bazel b/src/main/native/com/code_intelligence/jazzer/driver/BUILD.bazel
new file mode 100644
index 0000000..27d8a1c
--- /dev/null
+++ b/src/main/native/com/code_intelligence/jazzer/driver/BUILD.bazel
@@ -0,0 +1,166 @@
+load("@fmeum_rules_jni//jni:defs.bzl", "cc_jni_library")
+load("//bazel:compat.bzl", "MULTI_PLATFORM", "SKIP_ON_WINDOWS")
+
+cc_jni_library(
+    name = "jazzer_driver",
+    platforms = MULTI_PLATFORM,
+    visibility = [
+        "//src/jmh:__subpackages__",
+        "//src/main/java/com/code_intelligence/jazzer/driver:__pkg__",
+        "//src/main/java/com/code_intelligence/jazzer/junit:__pkg__",
+        "//src/main/java/com/code_intelligence/jazzer/runtime:__pkg__",
+        "//src/test:__subpackages__",
+    ],
+    deps = [
+        ":jazzer_driver_lib",
+        "@jazzer_libfuzzer//:libfuzzer_no_main",
+    ] + select({
+        # Windows doesn't have a concept analogous to RTLD_GLOBAL.
+        "@platforms//os:windows": [],
+        "//conditions:default": [":init_jazzer_preload"],
+    }),
+)
+
+cc_library(
+    name = "jazzer_driver_lib",
+    visibility = ["//src/test/native/com/code_intelligence/jazzer/driver/mocks:__pkg__"],
+    deps = [
+        ":coverage_tracker",
+        ":fuzz_target_runner",
+        ":jazzer_fuzzer_callbacks",
+        ":libfuzzer_callbacks",
+        ":mutator",
+    ],
+)
+
+cc_jni_library(
+    name = "jazzer_android_tooling",
+    srcs = ["android_tooling.cpp"],
+    platforms = MULTI_PLATFORM,
+    target_compatible_with = SKIP_ON_WINDOWS,
+    visibility = ["//src/main/java/com/code_intelligence/jazzer/android:__pkg__"],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/android:android_runtime.hdrs",
+    ],
+)
+
+cc_library(
+    name = "coverage_tracker",
+    srcs = ["coverage_tracker.cpp"],
+    hdrs = ["coverage_tracker.h"],
+    deps = ["//src/main/java/com/code_intelligence/jazzer/runtime:coverage_map.hdrs"],
+    # Symbols are only referenced dynamically via JNI.
+    alwayslink = True,
+)
+
+cc_library(
+    name = "fuzz_target_runner",
+    srcs = ["fuzz_target_runner.cpp"],
+    hdrs = ["fuzz_target_runner.h"],
+    linkopts = select({
+        "@platforms//os:windows": [],
+        "//conditions:default": ["-ldl"],
+    }),
+    deps = [
+        ":sanitizer_symbols",
+        "//src/main/java/com/code_intelligence/jazzer/runtime:fuzz_target_runner_natives.hdrs",
+    ],
+    # With sanitizers, symbols are only referenced dynamically via JNI.
+    alwayslink = True,
+)
+
+cc_library(
+    name = "fuzzed_data_provider",
+    srcs = ["fuzzed_data_provider.cpp"],
+    visibility = [
+        "//launcher:__pkg__",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/driver:fuzzed_data_provider_impl.hdrs",
+    ],
+    # Symbols may only be referenced dynamically via JNI.
+    alwayslink = True,
+)
+
+cc_jni_library(
+    name = "jazzer_fuzzed_data_provider",
+    platforms = MULTI_PLATFORM,
+    visibility = ["//src/main/java/com/code_intelligence/jazzer/driver:__pkg__"],
+    deps = [":fuzzed_data_provider"],
+)
+
+cc_library(
+    name = "jazzer_fuzzer_callbacks",
+    srcs = ["jazzer_fuzzer_callbacks.cpp"],
+    deps = [
+        ":sanitizer_hooks_with_pc",
+        "//src/main/java/com/code_intelligence/jazzer/runtime:trace_data_flow_native_callbacks.hdrs",
+    ],
+    alwayslink = True,
+)
+
+cc_jni_library(
+    name = "jazzer_signal_handler",
+    srcs = ["signal_handler.cpp"],
+    platforms = MULTI_PLATFORM,
+    visibility = ["//src/main/java/com/code_intelligence/jazzer/driver:__pkg__"],
+    deps = ["//src/main/java/com/code_intelligence/jazzer/driver:signal_handler.hdrs"],
+)
+
+cc_library(
+    name = "libfuzzer_callbacks",
+    srcs = ["libfuzzer_callbacks.cpp"],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/runtime:trace_data_flow_native_callbacks.hdrs",
+        "@com_google_absl//absl/strings",
+    ],
+    # Symbols are only referenced dynamically via JNI.
+    alwayslink = True,
+)
+
+cc_library(
+    name = "mutator",
+    srcs = ["mutator.cpp"],
+    deps = ["//src/main/java/com/code_intelligence/jazzer/runtime:mutator.hdrs"],
+    # Symbols are only referenced dynamically via JNI.
+    alwayslink = True,
+)
+
+cc_library(
+    name = "init_jazzer_preload",
+    srcs = ["init_jazzer_preload.cpp"],
+    linkopts = ["-ldl"],
+    target_compatible_with = SKIP_ON_WINDOWS,
+    deps = ["@fmeum_rules_jni//jni"],
+    # Symbols are only referenced dynamically via JNI.
+    alwayslink = True,
+)
+
+cc_library(
+    name = "sanitizer_hooks_with_pc",
+    hdrs = ["sanitizer_hooks_with_pc.h"],
+    visibility = ["//:__subpackages__"],
+)
+
+cc_library(
+    name = "sanitizer_symbols",
+    srcs = ["sanitizer_symbols.cpp"],
+    # Symbols are referenced dynamically by libFuzzer.
+    alwayslink = True,
+)
+
+cc_test(
+    name = "fuzzed_data_provider_test",
+    size = "small",
+    srcs = ["fuzzed_data_provider_test.cpp"],
+    copts = select({
+        "@platforms//os:windows": ["/std:c++17"],
+        "//conditions:default": ["-std=c++17"],
+    }),
+    deps = [
+        ":fuzzed_data_provider",
+        "@fmeum_rules_jni//jni",
+        "@googletest//:gtest",
+        "@googletest//:gtest_main",
+    ],
+)
diff --git a/src/main/native/com/code_intelligence/jazzer/driver/android_tooling.cpp b/src/main/native/com/code_intelligence/jazzer/driver/android_tooling.cpp
new file mode 100644
index 0000000..7344469
--- /dev/null
+++ b/src/main/native/com/code_intelligence/jazzer/driver/android_tooling.cpp
@@ -0,0 +1,61 @@
+// Copyright 2021 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include <dlfcn.h>
+#include <jni.h>
+
+#include <cstdlib>
+#include <cstring>
+#include <iostream>
+
+#include "com_code_intelligence_jazzer_android_AndroidRuntime.h"
+
+const char *RUNTIME_LIBRARY = "libandroid_runtime.so";
+
+// Register native methods from the Android Runtime (ART) framework.
+[[maybe_unused]] jint
+Java_com_code_1intelligence_jazzer_android_AndroidRuntime_registerNatives(
+    JNIEnv *env, jclass clazz) {
+  void *handle = nullptr;
+  handle = dlopen(RUNTIME_LIBRARY, RTLD_LAZY);
+
+  if (handle == nullptr) {
+    std::cerr
+        << "ERROR: Unable to locate runtime library. Check LD_LIBRARY_PATH."
+        << std::endl;
+    exit(1);
+  }
+  // reset errors
+  dlerror();
+
+  // Load the symbol from library
+  typedef jint (*Register_Frameworks_t)(JNIEnv *);
+  Register_Frameworks_t Register_Frameworks;
+
+  Register_Frameworks = reinterpret_cast<Register_Frameworks_t>(
+      dlsym(handle, "registerFrameworkNatives"));
+  const char *dlsym_error = dlerror();
+  if (dlsym_error) {
+    std::cerr << "ERROR: Unable to invoke registerFrameworkNatives."
+              << std::endl;
+    exit(1);
+  }
+
+  if (Register_Frameworks == nullptr) {
+    std::cerr << "ERROR: Register_Frameworks is null." << std::endl;
+    exit(1);
+  }
+
+  return Register_Frameworks(env);
+}
diff --git a/driver/src/main/native/com/code_intelligence/jazzer/driver/coverage_tracker.cpp b/src/main/native/com/code_intelligence/jazzer/driver/coverage_tracker.cpp
similarity index 87%
rename from driver/src/main/native/com/code_intelligence/jazzer/driver/coverage_tracker.cpp
rename to src/main/native/com/code_intelligence/jazzer/driver/coverage_tracker.cpp
index dc8349d..d904c2d 100644
--- a/driver/src/main/native/com/code_intelligence/jazzer/driver/coverage_tracker.cpp
+++ b/src/main/native/com/code_intelligence/jazzer/driver/coverage_tracker.cpp
@@ -15,8 +15,9 @@
 #include "coverage_tracker.h"
 
 #include <jni.h>
+#include <stdint.h>
 
-#include <stdexcept>
+#include <iostream>
 #include <vector>
 
 #include "com_code_intelligence_jazzer_runtime_CoverageMap.h"
@@ -31,8 +32,9 @@
 void AssertNoException(JNIEnv &env) {
   if (env.ExceptionCheck()) {
     env.ExceptionDescribe();
-    throw std::runtime_error(
-        "Java exception occurred in CoverageTracker JNI code");
+    std::cerr << "ERROR: Java exception occurred in CoverageTracker JNI code"
+              << std::endl;
+    _Exit(1);
   }
 }
 }  // namespace
@@ -44,8 +46,10 @@
 
 void CoverageTracker::Initialize(JNIEnv &env, jlong counters) {
   if (counters_ != nullptr) {
-    throw std::runtime_error(
-        "CoverageTracker::Initialize must not be called more than once");
+    std::cerr << "ERROR: CoverageTracker::Initialize must not be called more "
+                 "than once"
+              << std::endl;
+    _Exit(1);
   }
   counters_ = reinterpret_cast<uint8_t *>(static_cast<uintptr_t>(counters));
 }
@@ -53,12 +57,16 @@
 void CoverageTracker::RegisterNewCounters(JNIEnv &env, jint old_num_counters,
                                           jint new_num_counters) {
   if (counters_ == nullptr) {
-    throw std::runtime_error(
-        "CoverageTracker::Initialize should have been called first");
+    std::cerr
+        << "ERROR: CoverageTracker::Initialize should have been called first"
+        << std::endl;
+    _Exit(1);
   }
   if (new_num_counters < old_num_counters) {
-    throw std::runtime_error(
-        "new_num_counters must not be smaller than old_num_counters");
+    std::cerr
+        << "ERROR: new_num_counters must not be smaller than old_num_counters"
+        << std::endl;
+    _Exit(1);
   }
   if (new_num_counters == old_num_counters) {
     return;
diff --git a/driver/src/main/native/com/code_intelligence/jazzer/driver/coverage_tracker.h b/src/main/native/com/code_intelligence/jazzer/driver/coverage_tracker.h
similarity index 98%
rename from driver/src/main/native/com/code_intelligence/jazzer/driver/coverage_tracker.h
rename to src/main/native/com/code_intelligence/jazzer/driver/coverage_tracker.h
index 8ccecee..234536d 100644
--- a/driver/src/main/native/com/code_intelligence/jazzer/driver/coverage_tracker.h
+++ b/src/main/native/com/code_intelligence/jazzer/driver/coverage_tracker.h
@@ -17,6 +17,7 @@
 #pragma once
 
 #include <jni.h>
+#include <stdint.h>
 
 #include <string>
 
diff --git a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.cpp b/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.cpp
similarity index 61%
rename from driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.cpp
rename to src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.cpp
index 6231af0..02e9ae1 100644
--- a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.cpp
+++ b/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.cpp
@@ -23,23 +23,28 @@
 #include <dlfcn.h>
 #endif
 #include <jni.h>
+#include <stdint.h>
 
 #include <iostream>
 #include <limits>
 #include <string>
 #include <vector>
 
-#include "com_code_intelligence_jazzer_driver_FuzzTargetRunner.h"
+#include "com_code_intelligence_jazzer_runtime_FuzzTargetRunnerNatives.h"
 
 extern "C" int LLVMFuzzerRunDriver(int *argc, char ***argv,
                                    int (*UserCb)(const uint8_t *Data,
                                                  size_t Size));
+extern "C" size_t LLVMFuzzerMutate(uint8_t *Data, size_t Size, size_t MaxSize);
 
 namespace {
 jclass gRunner;
 jmethodID gRunOneId;
+jmethodID gMutateOneId;
+jmethodID gCrossOverId;
 JavaVM *gJavaVm;
 JNIEnv *gEnv;
+jboolean gUseExperimentalMutator;
 
 // A libFuzzer-registered callback that outputs the crashing input, but does
 // not include a stack trace.
@@ -58,13 +63,60 @@
 }
 }  // namespace
 
+extern "C" size_t LLVMFuzzerCustomMutator(uint8_t *Data, size_t Size,
+                                          size_t MaxSize, unsigned int Seed) {
+  if (gUseExperimentalMutator) {
+    JNIEnv &env = *gEnv;
+    jint jsize =
+        std::min(Size, static_cast<size_t>(std::numeric_limits<jint>::max()));
+    jint jmaxSize = std::min(
+        MaxSize, static_cast<size_t>(std::numeric_limits<jint>::max()));
+    jint jseed = static_cast<jint>(Seed);
+    jint newSize = env.CallStaticLongMethod(gRunner, gMutateOneId, Data, jsize,
+                                            jmaxSize, jseed);
+    if (env.ExceptionCheck()) {
+      env.ExceptionDescribe();
+      _Exit(1);
+    }
+    return static_cast<uint32_t>(newSize);
+  } else {
+    return LLVMFuzzerMutate(Data, Size, MaxSize);
+  }
+}
+
+extern "C" size_t LLVMFuzzerCustomCrossOver(const uint8_t *Data1, size_t Size1,
+                                            const uint8_t *Data2, size_t Size2,
+                                            uint8_t *Out, size_t MaxOutSize,
+                                            unsigned int Seed) {
+  if (gUseExperimentalMutator) {
+    JNIEnv &env = *gEnv;
+    jint jsize1 =
+        std::min(Size1, static_cast<size_t>(std::numeric_limits<jint>::max()));
+    jint jsize2 =
+        std::min(Size2, static_cast<size_t>(std::numeric_limits<jint>::max()));
+    jint jMaxOutSize = std::min(
+        MaxOutSize, static_cast<size_t>(std::numeric_limits<jint>::max()));
+    jint jseed = static_cast<jint>(Seed);
+
+    jint newSize =
+        env.CallStaticLongMethod(gRunner, gCrossOverId, Data1, jsize1, Data2,
+                                 jsize2, Out, jMaxOutSize, jseed);
+    if (env.ExceptionCheck()) {
+      env.ExceptionDescribe();
+      _Exit(1);
+    }
+    return static_cast<uint32_t>(newSize);
+  } else {
+    // No custom cross over supported.
+    return 0;
+  }
+}
+
 namespace jazzer {
 void DumpJvmStackTraces() {
   JNIEnv *env = nullptr;
   if (gJavaVm->AttachCurrentThread(reinterpret_cast<void **>(&env), nullptr) !=
       JNI_OK) {
-    std::cerr << "WARN: AttachCurrentThread failed in DumpJvmStackTraces"
-              << std::endl;
     return;
   }
   jmethodID dumpStack =
@@ -83,12 +135,16 @@
 }  // namespace jazzer
 
 [[maybe_unused]] jint
-Java_com_code_1intelligence_jazzer_driver_FuzzTargetRunner_startLibFuzzer(
-    JNIEnv *env, jclass runner, jobjectArray args) {
+Java_com_code_1intelligence_jazzer_runtime_FuzzTargetRunnerNatives_startLibFuzzer(
+    JNIEnv *env, jclass, jobjectArray args, jclass runner,
+    jboolean useExperimentalMutator) {
+  gUseExperimentalMutator = useExperimentalMutator;
   gEnv = env;
   env->GetJavaVM(&gJavaVm);
   gRunner = reinterpret_cast<jclass>(env->NewGlobalRef(runner));
   gRunOneId = env->GetStaticMethodID(runner, "runOne", "(JI)I");
+  gMutateOneId = env->GetStaticMethodID(runner, "mutateOne", "(JIII)I");
+  gCrossOverId = env->GetStaticMethodID(runner, "crossOver", "(JIJIJII)I");
   if (gRunOneId == nullptr) {
     env->ExceptionDescribe();
     _Exit(1);
@@ -136,7 +192,7 @@
 }
 
 [[maybe_unused]] void
-Java_com_code_1intelligence_jazzer_driver_FuzzTargetRunner_printCrashingInput(
+Java_com_code_1intelligence_jazzer_runtime_FuzzTargetRunnerNatives_printCrashingInput(
     JNIEnv *, jclass) {
   if (gLibfuzzerPrintCrashingInput == nullptr) {
     std::cerr << "<not available>" << std::endl;
@@ -145,10 +201,18 @@
   }
 }
 
+namespace fuzzer {
+// Defined in:
+// https://github.com/llvm/llvm-project/blob/27cc31b64c0491725aa88a6822f0f2a2c18914d7/compiler-rt/lib/fuzzer/FuzzerLoop.cpp#L43
+// Used here:
+// https://github.com/llvm/llvm-project/blob/27cc31b64c0491725aa88a6822f0f2a2c18914d7/compiler-rt/lib/fuzzer/FuzzerLoop.cpp#L244
+extern bool RunningUserCallback;
+}  // namespace fuzzer
+
 [[maybe_unused]] void
-Java_com_code_1intelligence_jazzer_driver_FuzzTargetRunner__1Exit(
-    JNIEnv *, jclass, jint exit_code) {
-  _Exit(exit_code);
+Java_com_code_1intelligence_jazzer_runtime_FuzzTargetRunnerNatives_temporarilyDisableLibfuzzerExitHook(
+    JNIEnv *, jclass) {
+  ::fuzzer::RunningUserCallback = false;
 }
 
 // We apply a patch to libFuzzer to make it call this function instead of
diff --git a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h b/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
similarity index 97%
rename from driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
rename to src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
index 0e8846c..e64eb8f 100644
--- a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
+++ b/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
@@ -16,8 +16,6 @@
 
 #pragma once
 
-#include <jni.h>
-
 namespace jazzer {
 /*
  * Print the stack traces of all active JVM threads.
diff --git a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzzed_data_provider.cpp b/src/main/native/com/code_intelligence/jazzer/driver/fuzzed_data_provider.cpp
similarity index 98%
rename from driver/src/main/native/com/code_intelligence/jazzer/driver/fuzzed_data_provider.cpp
rename to src/main/native/com/code_intelligence/jazzer/driver/fuzzed_data_provider.cpp
index 494bb9e..7ea9c34 100644
--- a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzzed_data_provider.cpp
+++ b/src/main/native/com/code_intelligence/jazzer/driver/fuzzed_data_provider.cpp
@@ -50,7 +50,7 @@
 #include <tuple>
 #include <type_traits>
 
-#include "com_code_intelligence_jazzer_runtime_FuzzedDataProviderImpl.h"
+#include "com_code_intelligence_jazzer_driver_FuzzedDataProviderImpl.h"
 
 namespace {
 
@@ -431,7 +431,7 @@
         ForceContinuationByte(c);
         // Preserve the zero character, which is coded on two bytes in modified
         // UTF-8. In all other cases ensure that we are not incorrectly encoding
-        // an ASCII character on two bytes by setting the eigth least
+        // an ASCII character on two bytes by setting the eighth least
         // significant bit of the encoded value (second least significant bit of
         // the leading byte).
         auto previous_c = static_cast<uint8_t>(str.back());
@@ -684,7 +684,7 @@
 }  // namespace
 
 [[maybe_unused]] void
-Java_com_code_1intelligence_jazzer_runtime_FuzzedDataProviderImpl_nativeInit(
+Java_com_code_1intelligence_jazzer_driver_FuzzedDataProviderImpl_nativeInit(
     JNIEnv *env, jclass clazz) {
   env->RegisterNatives(clazz, kFuzzedDataMethods, kNumFuzzedDataMethods);
   gDataPtrField = env->GetFieldID(clazz, "dataPtr", "J");
diff --git a/src/main/native/com/code_intelligence/jazzer/driver/fuzzed_data_provider_test.cpp b/src/main/native/com/code_intelligence/jazzer/driver/fuzzed_data_provider_test.cpp
new file mode 100644
index 0000000..2395cd9
--- /dev/null
+++ b/src/main/native/com/code_intelligence/jazzer/driver/fuzzed_data_provider_test.cpp
@@ -0,0 +1,98 @@
+// Copyright 2021 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include <jni.h>
+
+#include <cstddef>
+#include <cstdint>
+#include <random>
+#include <string>
+#include <vector>
+
+#include "gtest/gtest.h"
+
+namespace jazzer {
+std::pair<std::string, jint> FixUpModifiedUtf8(const uint8_t *pos,
+                                               jint max_bytes, jint max_length,
+                                               bool ascii_only,
+                                               bool stop_on_backslash);
+}
+
+std::pair<std::string, jint> FixUpRemainingModifiedUtf8(
+    const std::string &str, bool ascii_only, bool stop_on_backslash) {
+  return jazzer::FixUpModifiedUtf8(
+      reinterpret_cast<const uint8_t *>(str.c_str()), str.length(),
+      std::numeric_limits<jint>::max(), ascii_only, stop_on_backslash);
+}
+
+std::pair<std::string, jint> expect(const std::string &s, jint i) {
+  return std::make_pair(s, i);
+}
+
+using namespace std::literals::string_literals;
+TEST(FixUpModifiedUtf8Test, FullUtf8_ContinueOnBackslash) {
+  EXPECT_EQ(expect("jazzer"s, 6),
+            FixUpRemainingModifiedUtf8("jazzer"s, false, false));
+  EXPECT_EQ(expect("ja\xC0\x80zzer"s, 7),
+            FixUpRemainingModifiedUtf8("ja\0zzer"s, false, false));
+  EXPECT_EQ(expect("ja\xC0\x80\xC0\x80zzer"s, 8),
+            FixUpRemainingModifiedUtf8("ja\0\0zzer"s, false, false));
+  EXPECT_EQ(expect("ja\\zzer"s, 7),
+            FixUpRemainingModifiedUtf8("ja\\zzer"s, false, false));
+  EXPECT_EQ(expect("ja\\\\zzer"s, 8),
+            FixUpRemainingModifiedUtf8("ja\\\\zzer"s, false, false));
+  EXPECT_EQ(expect("ۧ"s, 5),
+            FixUpRemainingModifiedUtf8(u8"ۧ"s, false, false));
+}
+
+TEST(FixUpModifiedUtf8Test, AsciiOnly_ContinueOnBackslash) {
+  EXPECT_EQ(expect("jazzer"s, 6),
+            FixUpRemainingModifiedUtf8("jazzer"s, true, false));
+  EXPECT_EQ(expect("ja\xC0\x80zzer"s, 7),
+            FixUpRemainingModifiedUtf8("ja\0zzer"s, true, false));
+  EXPECT_EQ(expect("ja\xC0\x80\xC0\x80zzer"s, 8),
+            FixUpRemainingModifiedUtf8("ja\0\0zzer"s, true, false));
+  EXPECT_EQ(expect("ja\\zzer"s, 7),
+            FixUpRemainingModifiedUtf8("ja\\zzer"s, true, false));
+  EXPECT_EQ(expect("ja\\\\zzer"s, 8),
+            FixUpRemainingModifiedUtf8("ja\\\\zzer"s, true, false));
+  EXPECT_EQ(expect("\x62\x02\x2C\x43\x1F"s, 5),
+            FixUpRemainingModifiedUtf8(u8"ۧ"s, true, false));
+}
+
+TEST(FixUpModifiedUtf8Test, FullUtf8_StopOnBackslash) {
+  EXPECT_EQ(expect("jazzer"s, 6),
+            FixUpRemainingModifiedUtf8("jazzer"s, false, true));
+  EXPECT_EQ(expect("ja\xC0\x80zzer"s, 7),
+            FixUpRemainingModifiedUtf8("ja\0zzer"s, false, true));
+  EXPECT_EQ(expect("ja\xC0\x80\xC0\x80zzer"s, 8),
+            FixUpRemainingModifiedUtf8("ja\0\0zzer"s, false, true));
+  EXPECT_EQ(expect("ja"s, 4),
+            FixUpRemainingModifiedUtf8("ja\\zzer"s, false, true));
+  EXPECT_EQ(expect("ja\\zzer"s, 8),
+            FixUpRemainingModifiedUtf8("ja\\\\zzer"s, false, true));
+}
+
+TEST(FixUpModifiedUtf8Test, AsciiOnly_StopOnBackslash) {
+  EXPECT_EQ(expect("jazzer"s, 6),
+            FixUpRemainingModifiedUtf8("jazzer"s, true, true));
+  EXPECT_EQ(expect("ja\xC0\x80zzer"s, 7),
+            FixUpRemainingModifiedUtf8("ja\0zzer"s, true, true));
+  EXPECT_EQ(expect("ja\xC0\x80\xC0\x80zzer"s, 8),
+            FixUpRemainingModifiedUtf8("ja\0\0zzer"s, true, true));
+  EXPECT_EQ(expect("ja"s, 4),
+            FixUpRemainingModifiedUtf8("ja\\zzer"s, true, true));
+  EXPECT_EQ(expect("ja\\zzer"s, 8),
+            FixUpRemainingModifiedUtf8("ja\\\\zzer"s, true, true));
+}
diff --git a/driver/src/main/native/com/code_intelligence/jazzer/driver/trigger_driver_hooks_load.cpp b/src/main/native/com/code_intelligence/jazzer/driver/init_jazzer_preload.cpp
similarity index 72%
rename from driver/src/main/native/com/code_intelligence/jazzer/driver/trigger_driver_hooks_load.cpp
rename to src/main/native/com/code_intelligence/jazzer/driver/init_jazzer_preload.cpp
index 8e6d19a..23a86c5 100644
--- a/driver/src/main/native/com/code_intelligence/jazzer/driver/trigger_driver_hooks_load.cpp
+++ b/src/main/native/com/code_intelligence/jazzer/driver/init_jazzer_preload.cpp
@@ -17,7 +17,13 @@
 
 #include <cstdlib>
 
-// The native driver binary, if used, forwards all calls to native libFuzzer
+#if defined(_ANDROID)
+#define __jni_version__ JNI_VERSION_1_6
+#else
+#define __jni_version__ JNI_VERSION_1_8
+#endif
+
+// The jazzer_preload library, if used, forwards all calls to native libFuzzer
 // hooks such as __sanitizer_cov_trace_cmp8 to the Jazzer JNI library. In order
 // to load the hook symbols when the library is ready, it needs to be passed a
 // handle - the JVM loads libraries with RTLD_LOCAL and thus their symbols
@@ -37,14 +43,14 @@
     abort();
   }
 
-  void *register_hooks = dlsym(RTLD_DEFAULT, "jazzer_initialize_native_hooks");
-  // We may be running without the native driver, so not finding this method is
-  // an expected error.
-  if (register_hooks) {
-    reinterpret_cast<void (*)(void *)>(register_hooks)(handle);
+  void *preload_init = dlsym(RTLD_DEFAULT, "jazzer_preload_init");
+  // jazzer_preload is only preloaded when Jazzer is started with --native, so
+  // not finding this method is an expected error.
+  if (preload_init) {
+    reinterpret_cast<void (*)(void *)>(preload_init)(handle);
   }
 
   dlclose(handle);
 
-  return JNI_VERSION_1_8;
+  return __jni_version__;
 }
diff --git a/driver/src/main/native/com/code_intelligence/jazzer/driver/jazzer_fuzzer_callbacks.cpp b/src/main/native/com/code_intelligence/jazzer/driver/jazzer_fuzzer_callbacks.cpp
similarity index 100%
rename from driver/src/main/native/com/code_intelligence/jazzer/driver/jazzer_fuzzer_callbacks.cpp
rename to src/main/native/com/code_intelligence/jazzer/driver/jazzer_fuzzer_callbacks.cpp
diff --git a/driver/src/main/native/com/code_intelligence/jazzer/driver/libfuzzer_callbacks.cpp b/src/main/native/com/code_intelligence/jazzer/driver/libfuzzer_callbacks.cpp
similarity index 92%
rename from driver/src/main/native/com/code_intelligence/jazzer/driver/libfuzzer_callbacks.cpp
rename to src/main/native/com/code_intelligence/jazzer/driver/libfuzzer_callbacks.cpp
index a20863f..b7a0df5 100644
--- a/driver/src/main/native/com/code_intelligence/jazzer/driver/libfuzzer_callbacks.cpp
+++ b/src/main/native/com/code_intelligence/jazzer/driver/libfuzzer_callbacks.cpp
@@ -30,7 +30,7 @@
 std::vector<std::pair<uintptr_t, uintptr_t>> ignore_for_interception_ranges;
 
 /**
- * Adds the address ranges of executable segmentes of the library lib_name to
+ * Adds the address ranges of executable segments of the library lib_name to
  * the ignorelist for C standard library function interception (strcmp, memcmp,
  * ...).
  */
@@ -84,10 +84,12 @@
 }
 
 const std::vector<std::string> kLibrariesToIgnoreForInterception = {
-    // The driver executable itself can be treated just like a library.
-    "jazzer_driver", "libinstrument.so", "libjava.so",
-    "libjimage.so",  "libjli.so",        "libjvm.so",
-    "libnet.so",     "libverify.so",     "libzip.so",
+    // The launcher executable itself can be treated just like a library.
+    "jazzer",           "libjazzer_preload.so",
+    "libinstrument.so", "libjava.so",
+    "libjimage.so",     "libjli.so",
+    "libjvm.so",        "libnet.so",
+    "libverify.so",     "libzip.so",
 };
 }  // namespace
 
diff --git a/src/main/native/com/code_intelligence/jazzer/driver/mutator.cpp b/src/main/native/com/code_intelligence/jazzer/driver/mutator.cpp
new file mode 100644
index 0000000..4e21612
--- /dev/null
+++ b/src/main/native/com/code_intelligence/jazzer/driver/mutator.cpp
@@ -0,0 +1,31 @@
+// Copyright 2023 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include <cstddef>
+#include <cstdint>
+
+#include "com_code_intelligence_jazzer_runtime_Mutator.h"
+
+extern "C" size_t LLVMFuzzerMutate(uint8_t *Data, size_t Size, size_t MaxSize);
+
+[[maybe_unused]] jint
+Java_com_code_1intelligence_jazzer_runtime_Mutator_defaultMutateNative(
+    JNIEnv *env, jclass, jbyteArray jni_data, jint size) {
+  jint maxSize = env->GetArrayLength(jni_data);
+  uint8_t *data =
+      static_cast<uint8_t *>(env->GetPrimitiveArrayCritical(jni_data, nullptr));
+  jint res = LLVMFuzzerMutate(data, size, maxSize);
+  env->ReleasePrimitiveArrayCritical(jni_data, data, 0);
+  return res;
+}
diff --git a/driver/src/main/native/com/code_intelligence/jazzer/driver/sanitizer_hooks_with_pc.h b/src/main/native/com/code_intelligence/jazzer/driver/sanitizer_hooks_with_pc.h
similarity index 100%
rename from driver/src/main/native/com/code_intelligence/jazzer/driver/sanitizer_hooks_with_pc.h
rename to src/main/native/com/code_intelligence/jazzer/driver/sanitizer_hooks_with_pc.h
diff --git a/driver/src/main/native/com/code_intelligence/jazzer/driver/sanitizer_symbols.cpp b/src/main/native/com/code_intelligence/jazzer/driver/sanitizer_symbols.cpp
similarity index 100%
rename from driver/src/main/native/com/code_intelligence/jazzer/driver/sanitizer_symbols.cpp
rename to src/main/native/com/code_intelligence/jazzer/driver/sanitizer_symbols.cpp
diff --git a/agent/src/main/native/com/code_intelligence/jazzer/runtime/signal_handler.cpp b/src/main/native/com/code_intelligence/jazzer/driver/signal_handler.cpp
similarity index 89%
rename from agent/src/main/native/com/code_intelligence/jazzer/runtime/signal_handler.cpp
rename to src/main/native/com/code_intelligence/jazzer/driver/signal_handler.cpp
index 2600a53..e284925 100644
--- a/agent/src/main/native/com/code_intelligence/jazzer/runtime/signal_handler.cpp
+++ b/src/main/native/com/code_intelligence/jazzer/driver/signal_handler.cpp
@@ -17,7 +17,7 @@
 #include <atomic>
 #include <csignal>
 
-#include "com_code_intelligence_jazzer_runtime_SignalHandler.h"
+#include "com_code_intelligence_jazzer_driver_SignalHandler.h"
 
 #ifdef _WIN32
 // Windows does not have SIGUSR1, which triggers a graceful exit of libFuzzer.
@@ -27,7 +27,7 @@
 
 // Handles SIGINT raised while running Java code.
 [[maybe_unused]] void
-Java_com_code_1intelligence_jazzer_runtime_SignalHandler_handleInterrupt(
+Java_com_code_1intelligence_jazzer_driver_SignalHandler_handleInterrupt(
     JNIEnv *, jclass) {
   static std::atomic<bool> already_exiting{false};
   if (!already_exiting.exchange(true)) {
diff --git a/src/main/native/com/code_intelligence/jazzer/jazzer_preload.c b/src/main/native/com/code_intelligence/jazzer/jazzer_preload.c
new file mode 100644
index 0000000..074c3d2
--- /dev/null
+++ b/src/main/native/com/code_intelligence/jazzer/jazzer_preload.c
@@ -0,0 +1,249 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/*
+ * Dynamically exported definitions of fuzzer hooks and libc functions that
+ * forward to the symbols provided by the jazzer_driver JNI library once it has
+ * been loaded.
+ */
+
+#define _GNU_SOURCE  // for RTLD_NEXT
+#include <dlfcn.h>
+#include <stdatomic.h>
+#include <stddef.h>
+#include <stdint.h>
+#ifdef __APPLE__
+// Using dyld's interpose feature requires knowing the addresses of libc
+// functions.
+#include <string.h>
+#endif
+
+#if defined(__APPLE__) && defined(__arm64__)
+// arm64 has a fixed instruction length of 32 bits, which means that the lowest
+// two bits of the return address of a function are always zero. Since
+// libFuzzer's value profiling uses the lowest bits of the address to index into
+// a hash table, we increase their entropy by shifting away the constant bits.
+#define GET_CALLER_PC() \
+  ((void *)(((uintptr_t)__builtin_return_address(0)) >> 2))
+#else
+#define GET_CALLER_PC() __builtin_return_address(0)
+#endif
+#define LIKELY(x) __builtin_expect(!!(x), 1)
+#define UNLIKELY(x) __builtin_expect(!!(x), 0)
+
+// Unwraps (foo, bar) passed as arguments to foo, bar - this allows passing
+// multiple var args into a single macro.
+#define UNWRAP_VA_ARGS(...) __VA_ARGS__
+
+// Define a dynamic, global symbol such as __sanitizer_weak_hook_memcmp that
+// calls the local symbol of the same name in the jazzer_driver shared library
+// loaded in the JVM.
+#define DEFINE_LIBC_HOOK(name, ret, params, args)                           \
+  typedef void (*name##_hook_t)(void *, UNWRAP_VA_ARGS params, ret);        \
+  static _Atomic name##_hook_t name##_hook;                                 \
+                                                                            \
+  __attribute__((visibility("default"))) void __sanitizer_weak_hook_##name( \
+      void *called_pc, UNWRAP_VA_ARGS params, ret result) {                 \
+    name##_hook_t hook =                                                    \
+        atomic_load_explicit(&name##_hook, memory_order_relaxed);           \
+    if (LIKELY(hook != NULL)) {                                             \
+      hook(called_pc, UNWRAP_VA_ARGS args, result);                         \
+    }                                                                       \
+  }
+
+#define INIT_LIBC_HOOK(handle, name) \
+  atomic_store(&name##_hook, dlsym(handle, "__sanitizer_weak_hook_" #name))
+
+#ifdef __linux__
+// Alternate definitions for libc functions mimicking those that libFuzzer would
+// provide if it were linked into the JVM. All these functions invoke the real
+// libc function loaded from the next library in search order (either libc
+// itself or a sanitizer's interceptor).
+//
+// Function pointers have to be loaded and stored atomically even if libc
+// functions are invoked from different threads, but we do not need any
+// synchronization guarantees - in the worst case, we will non-deterministically
+// lose a few hook invocations.
+
+#define DEFINE_LIBC_INTERCEPTOR(name, ret, params, args)                   \
+  DEFINE_LIBC_HOOK(name, ret, params, args)                                \
+                                                                           \
+  typedef ret (*name##_t)(UNWRAP_VA_ARGS params);                          \
+  static _Atomic name##_t name##_real;                                     \
+                                                                           \
+  __attribute__((visibility("default"))) ret name(UNWRAP_VA_ARGS params) { \
+    name##_t name##_real_local =                                           \
+        atomic_load_explicit(&name##_real, memory_order_relaxed);          \
+    if (UNLIKELY(name##_real_local == NULL)) {                             \
+      name##_real_local = dlsym(RTLD_NEXT, #name);                         \
+      atomic_store_explicit(&name##_real, name##_real_local,               \
+                            memory_order_relaxed);                         \
+    }                                                                      \
+    ret result = name##_real_local(UNWRAP_VA_ARGS args);                   \
+    __sanitizer_weak_hook_##name(GET_CALLER_PC(), UNWRAP_VA_ARGS args,     \
+                                 result);                                  \
+    return result;                                                         \
+  }
+
+#elif __APPLE__
+// macOS namespace concept makes it impossible to override symbols in shared
+// library dependencies simply by defining them. Instead, the dynamic linker's
+// interpose feature is used to request that one function, identified by its
+// address, is replaced by another at runtime.
+
+typedef struct {
+  const uintptr_t interceptor;
+  const uintptr_t func;
+} interpose_t;
+
+#define INTERPOSE(_interceptor, _func)                        \
+  __attribute__((used)) static interpose_t _interpose_##_func \
+      __attribute__((section("__DATA,__interpose"))) = {      \
+          (uintptr_t)&_interceptor, (uintptr_t)&_func};
+
+#define DEFINE_LIBC_INTERCEPTOR(name, ret, params, args)               \
+  DEFINE_LIBC_HOOK(name, ret, params, args)                            \
+                                                                       \
+  __attribute__((visibility("default")))                               \
+  ret interposed_##name(UNWRAP_VA_ARGS params) {                       \
+    ret result = name(UNWRAP_VA_ARGS args);                            \
+    __sanitizer_weak_hook_##name(GET_CALLER_PC(), UNWRAP_VA_ARGS args, \
+                                 result);                              \
+    return result;                                                     \
+  }                                                                    \
+                                                                       \
+  INTERPOSE(interposed_##name, name)
+#else
+// TODO: Use https://github.com/microsoft/Detours to add Windows support.
+#error "jazzer_preload is not supported on this OS"
+#endif
+
+DEFINE_LIBC_INTERCEPTOR(bcmp, int, (const void *s1, const void *s2, size_t n),
+                        (s1, s2, n))
+DEFINE_LIBC_INTERCEPTOR(memcmp, int, (const void *s1, const void *s2, size_t n),
+                        (s1, s2, n))
+DEFINE_LIBC_INTERCEPTOR(strncmp, int,
+                        (const char *s1, const char *s2, size_t n), (s1, s2, n))
+DEFINE_LIBC_INTERCEPTOR(strncasecmp, int,
+                        (const char *s1, const char *s2, size_t n), (s1, s2, n))
+DEFINE_LIBC_INTERCEPTOR(strcmp, int, (const char *s1, const char *s2), (s1, s2))
+DEFINE_LIBC_INTERCEPTOR(strcasecmp, int, (const char *s1, const char *s2),
+                        (s1, s2))
+DEFINE_LIBC_INTERCEPTOR(strstr, char *, (const char *s1, const char *s2),
+                        (s1, s2))
+DEFINE_LIBC_INTERCEPTOR(strcasestr, char *, (const char *s1, const char *s2),
+                        (s1, s2))
+DEFINE_LIBC_INTERCEPTOR(memmem, void *,
+                        (const void *s1, size_t n1, const void *s2, size_t n2),
+                        (s1, n1, s2, n2))
+
+// Native libraries instrumented for fuzzing include references to fuzzer hooks
+// that are resolved by the dynamic linker. We need to route these to the
+// corresponding local symbols in the Jazzer driver JNI library.
+// The __sanitizer_cov_trace_* family of functions is only invoked from code
+// compiled with -fsanitize=fuzzer. We can assume that the Jazzer JNI library
+// has been loaded before any such code, which necessarily belongs to the fuzz
+// target, is executed and thus don't need NULL checks.
+#define DEFINE_TRACE_HOOK(name, params, args)                                \
+  typedef void (*trace_##name##_t)(void *, UNWRAP_VA_ARGS params);           \
+  static _Atomic trace_##name##_t trace_##name##_with_pc;                    \
+                                                                             \
+  __attribute__((visibility("default"))) void __sanitizer_cov_trace_##name(  \
+      UNWRAP_VA_ARGS params) {                                               \
+    trace_##name##_t hook =                                                  \
+        atomic_load_explicit(&trace_##name##_with_pc, memory_order_relaxed); \
+    hook(GET_CALLER_PC(), UNWRAP_VA_ARGS args);                              \
+  }
+
+#define INIT_TRACE_HOOK(handle, name)   \
+  atomic_store(&trace_##name##_with_pc, \
+               dlsym(handle, "__sanitizer_cov_trace_" #name "_with_pc"))
+
+DEFINE_TRACE_HOOK(cmp1, (uint8_t arg1, uint8_t arg2), (arg1, arg2));
+DEFINE_TRACE_HOOK(cmp2, (uint16_t arg1, uint16_t arg2), (arg1, arg2));
+DEFINE_TRACE_HOOK(cmp4, (uint32_t arg1, uint32_t arg2), (arg1, arg2));
+DEFINE_TRACE_HOOK(cmp8, (uint64_t arg1, uint64_t arg2), (arg1, arg2));
+
+DEFINE_TRACE_HOOK(const_cmp1, (uint8_t arg1, uint8_t arg2), (arg1, arg2));
+DEFINE_TRACE_HOOK(const_cmp2, (uint16_t arg1, uint16_t arg2), (arg1, arg2));
+DEFINE_TRACE_HOOK(const_cmp4, (uint32_t arg1, uint32_t arg2), (arg1, arg2));
+DEFINE_TRACE_HOOK(const_cmp8, (uint64_t arg1, uint64_t arg2), (arg1, arg2));
+
+DEFINE_TRACE_HOOK(switch, (uint64_t val, uint64_t *cases), (val, cases));
+
+DEFINE_TRACE_HOOK(div4, (uint32_t arg), (arg))
+DEFINE_TRACE_HOOK(div8, (uint64_t arg), (arg))
+
+DEFINE_TRACE_HOOK(gep, (uintptr_t arg), (arg))
+
+DEFINE_TRACE_HOOK(pc_indir, (uintptr_t arg), (arg))
+
+typedef void (*cov_8bit_counters_init_t)(uint8_t *, uint8_t *);
+static _Atomic cov_8bit_counters_init_t cov_8bit_counters_init;
+typedef void (*cov_pcs_init_t)(const uintptr_t *, const uintptr_t *);
+static _Atomic cov_pcs_init_t cov_pcs_init;
+
+__attribute__((visibility("default"))) void __sanitizer_cov_8bit_counters_init(
+    uint8_t *start, uint8_t *end) {
+  cov_8bit_counters_init_t init =
+      atomic_load_explicit(&cov_8bit_counters_init, memory_order_relaxed);
+  init(start, end);
+}
+
+__attribute__((visibility("default"))) void __sanitizer_cov_pcs_init(
+    const uintptr_t *pcs_beg, const uintptr_t *pcs_end) {
+  cov_pcs_init_t init =
+      atomic_load_explicit(&cov_pcs_init, memory_order_relaxed);
+  init(pcs_beg, pcs_end);
+}
+
+// TODO: This is never updated and thus doesn't provide any information to the
+//  fuzzer.
+__attribute__((
+    visibility("default"))) _Thread_local uintptr_t __sancov_lowest_stack = 0;
+
+__attribute__((visibility("default"))) void jazzer_preload_init(void *handle) {
+  INIT_LIBC_HOOK(handle, bcmp);
+  INIT_LIBC_HOOK(handle, memcmp);
+  INIT_LIBC_HOOK(handle, strncmp);
+  INIT_LIBC_HOOK(handle, strcmp);
+  INIT_LIBC_HOOK(handle, strncasecmp);
+  INIT_LIBC_HOOK(handle, strcasecmp);
+  INIT_LIBC_HOOK(handle, strstr);
+  INIT_LIBC_HOOK(handle, strcasestr);
+  INIT_LIBC_HOOK(handle, memmem);
+
+  INIT_TRACE_HOOK(handle, cmp1);
+  INIT_TRACE_HOOK(handle, cmp2);
+  INIT_TRACE_HOOK(handle, cmp4);
+  INIT_TRACE_HOOK(handle, cmp8);
+
+  INIT_TRACE_HOOK(handle, const_cmp1);
+  INIT_TRACE_HOOK(handle, const_cmp2);
+  INIT_TRACE_HOOK(handle, const_cmp4);
+  INIT_TRACE_HOOK(handle, const_cmp8);
+
+  INIT_TRACE_HOOK(handle, switch);
+
+  INIT_TRACE_HOOK(handle, div4);
+  INIT_TRACE_HOOK(handle, div8);
+
+  INIT_TRACE_HOOK(handle, gep);
+
+  INIT_TRACE_HOOK(handle, pc_indir);
+
+  atomic_store(&cov_8bit_counters_init,
+               dlsym(handle, "__sanitizer_cov_8bit_counters_init"));
+  atomic_store(&cov_pcs_init, dlsym(handle, "__sanitizer_cov_pcs_init"));
+}
diff --git a/src/main/resources/BUILD.bazel b/src/main/resources/BUILD.bazel
new file mode 100644
index 0000000..312ace5
--- /dev/null
+++ b/src/main/resources/BUILD.bazel
@@ -0,0 +1,5 @@
+filegroup(
+    name = "jazzer_test_engine_service",
+    srcs = ["META-INF/services/org.junit.platform.engine.TestEngine"],
+    visibility = ["//visibility:public"],
+)
diff --git a/src/main/resources/META-INF/services/org.junit.platform.engine.TestEngine b/src/main/resources/META-INF/services/org.junit.platform.engine.TestEngine
new file mode 100644
index 0000000..d5d796d
--- /dev/null
+++ b/src/main/resources/META-INF/services/org.junit.platform.engine.TestEngine
@@ -0,0 +1 @@
+com.code_intelligence.jazzer.junit.JazzerTestEngine
\ No newline at end of file
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/api/AutofuzzTest.java b/src/test/java/com/code_intelligence/jazzer/api/AutofuzzTest.java
similarity index 87%
rename from agent/src/test/java/com/code_intelligence/jazzer/api/AutofuzzTest.java
rename to src/test/java/com/code_intelligence/jazzer/api/AutofuzzTest.java
index 59ef238..ee192d4 100644
--- a/agent/src/test/java/com/code_intelligence/jazzer/api/AutofuzzTest.java
+++ b/src/test/java/com/code_intelligence/jazzer/api/AutofuzzTest.java
@@ -21,7 +21,6 @@
 
 import java.util.Arrays;
 import java.util.Collections;
-import org.junit.BeforeClass;
 import org.junit.Test;
 
 public class AutofuzzTest {
@@ -50,7 +49,7 @@
     FuzzedDataProvider data = CannedFuzzedDataProvider.create(
         Arrays.asList((byte) 1 /* do not return null */, 0 /* first class on the classpath */,
             (byte) 1 /* do not return null */, 0 /* first constructor */));
-    ImplementedInterface result = Jazzer.consume(data, ImplementedInterface.class);
+    ImplementedInterface result = Autofuzz.consume(data, ImplementedInterface.class);
     assertNotNull(result);
   }
 
@@ -58,7 +57,7 @@
   public void testConsumeFailsWithoutException() {
     FuzzedDataProvider data = CannedFuzzedDataProvider.create(Collections.singletonList(
         (byte) 1 /* do not return null without searching for implementing classes */));
-    assertNull(Jazzer.consume(data, UnimplementedInterface.class));
+    assertNull(Autofuzz.consume(data, UnimplementedInterface.class));
   }
 
   @Test
@@ -67,7 +66,7 @@
         Arrays.asList((byte) 1 /* do not return null */, 0 /* first class on the classpath */,
             (byte) 1 /* do not return null */, 0 /* first constructor */));
     assertEquals(Boolean.TRUE,
-        Jazzer.autofuzz(data, (Function1<ImplementedInterface, ?>) AutofuzzTest::implIsNotNull));
+        Autofuzz.autofuzz(data, (Function1<ImplementedInterface, ?>) AutofuzzTest::implIsNotNull));
   }
 
   @Test
@@ -75,7 +74,7 @@
     FuzzedDataProvider data = CannedFuzzedDataProvider.create(
         Collections.singletonList((byte) 1 /* do not return null */));
     try {
-      Jazzer.autofuzz(data, (Function1<UnimplementedInterface, ?>) AutofuzzTest::implIsNotNull);
+      Autofuzz.autofuzz(data, (Function1<UnimplementedInterface, ?>) AutofuzzTest::implIsNotNull);
     } catch (AutofuzzConstructionException e) {
       // Pass.
       return;
@@ -89,7 +88,7 @@
         Arrays.asList((byte) 1 /* do not return null */, 6 /* string length */, "foobar", 42,
             (byte) 5, (byte) 1 /* do not return null */, 0 /* first class on the classpath */,
             (byte) 1 /* do not return null */, 0 /* first constructor */));
-    Jazzer.autofuzz(data, AutofuzzTest::checkAllTheArguments);
+    Autofuzz.autofuzz(data, AutofuzzTest::checkAllTheArguments);
   }
 
   @Test
@@ -98,7 +97,7 @@
         CannedFuzzedDataProvider.create(Arrays.asList((byte) 1 /* do not return null */,
             6 /* string length */, "foobar", 42, (byte) 5, (byte) 0 /* *do* return null */));
     try {
-      Jazzer.autofuzz(data, AutofuzzTest::checkAllTheArguments);
+      Autofuzz.autofuzz(data, AutofuzzTest::checkAllTheArguments);
     } catch (IllegalArgumentException e) {
       // Pass.
       return;
diff --git a/src/test/java/com/code_intelligence/jazzer/api/BUILD.bazel b/src/test/java/com/code_intelligence/jazzer/api/BUILD.bazel
new file mode 100644
index 0000000..86014c7
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/api/BUILD.bazel
@@ -0,0 +1,22 @@
+java_test(
+    name = "AutofuzzTest",
+    size = "small",
+    srcs = [
+        "AutofuzzTest.java",
+    ],
+    env = {
+        # Also consider implementing classes from com.code_intelligence.jazzer.*.
+        "JAZZER_AUTOFUZZ_TESTING": "1",
+    },
+    test_class = "com.code_intelligence.jazzer.api.AutofuzzTest",
+    runtime_deps = [
+        "//src/main/java/com/code_intelligence/jazzer/autofuzz",
+        # Needed for JazzerInternal.
+        "//src/main/java/com/code_intelligence/jazzer/runtime",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/api",
+        "//src/main/native/com/code_intelligence/jazzer/driver:jazzer_driver",
+        "@maven//:junit_junit",
+    ],
+)
diff --git a/src/test/java/com/code_intelligence/jazzer/autofuzz/AutofuzzCodegenVisitorTest.java b/src/test/java/com/code_intelligence/jazzer/autofuzz/AutofuzzCodegenVisitorTest.java
new file mode 100644
index 0000000..814292e
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/autofuzz/AutofuzzCodegenVisitorTest.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2022 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.autofuzz;
+
+import static com.code_intelligence.jazzer.autofuzz.AutofuzzCodegenVisitor.escapeForLiteral;
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+public class AutofuzzCodegenVisitorTest {
+  @Test
+  public void escapeForLiteralTest() {
+    assertEquals("\\t", escapeForLiteral("\t"));
+    assertEquals("\\\\\\t", escapeForLiteral("\\\t"));
+    assertEquals("\\b", escapeForLiteral("\b"));
+    assertEquals("\\\\\\b", escapeForLiteral("\\\b"));
+    assertEquals("\\n", escapeForLiteral("\n"));
+    assertEquals("\\\\\\n", escapeForLiteral("\\\n"));
+    assertEquals("\\r", escapeForLiteral("\r"));
+    assertEquals("\\\\\\r", escapeForLiteral("\\\r"));
+    assertEquals("\\f", escapeForLiteral("\f"));
+    assertEquals("\\\\\\f", escapeForLiteral("\\\f"));
+    assertEquals("\\'", escapeForLiteral("'"));
+    assertEquals("\\\\\\'", escapeForLiteral("\\'"));
+    assertEquals("\\\"", escapeForLiteral("\""));
+    assertEquals("\\\\\\\"", escapeForLiteral("\\\""));
+    assertEquals("\\\\", escapeForLiteral("\\"));
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/autofuzz/BUILD.bazel b/src/test/java/com/code_intelligence/jazzer/autofuzz/BUILD.bazel
new file mode 100644
index 0000000..a5ee59b
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/autofuzz/BUILD.bazel
@@ -0,0 +1,93 @@
+java_test(
+    name = "MetaTest",
+    size = "small",
+    srcs = [
+        "MetaTest.java",
+    ],
+    env = {
+        "JAZZER_AUTOFUZZ_DEBUG": "1",
+        # Also consider implementing classes from com.code_intelligence.jazzer.*.
+        "JAZZER_AUTOFUZZ_TESTING": "1",
+    },
+    test_class = "com.code_intelligence.jazzer.autofuzz.MetaTest",
+    deps = [
+        ":test_helpers",
+        "//src/main/java/com/code_intelligence/jazzer/api",
+        "//src/main/java/com/code_intelligence/jazzer/autofuzz",
+        "@maven//:com_mikesamuel_json_sanitizer",
+        "@maven//:junit_junit",
+    ],
+)
+
+java_test(
+    name = "InterfaceCreationTest",
+    size = "small",
+    srcs = [
+        "InterfaceCreationTest.java",
+    ],
+    env = {
+        "JAZZER_AUTOFUZZ_DEBUG": "1",
+        # Also consider implementing classes from com.code_intelligence.jazzer.*.
+        "JAZZER_AUTOFUZZ_TESTING": "1",
+    },
+    test_class = "com.code_intelligence.jazzer.autofuzz.InterfaceCreationTest",
+    deps = [
+        ":test_helpers",
+        "@maven//:junit_junit",
+    ],
+)
+
+java_test(
+    name = "BuilderPatternTest",
+    size = "small",
+    srcs = [
+        "BuilderPatternTest.java",
+    ],
+    env = {
+        "JAZZER_AUTOFUZZ_DEBUG": "1",
+    },
+    test_class = "com.code_intelligence.jazzer.autofuzz.BuilderPatternTest",
+    deps = [
+        ":test_helpers",
+        "@maven//:junit_junit",
+    ],
+)
+
+java_test(
+    name = "SettersTest",
+    size = "small",
+    srcs = [
+        "SettersTest.java",
+    ],
+    env = {
+        "JAZZER_AUTOFUZZ_DEBUG": "1",
+    },
+    test_class = "com.code_intelligence.jazzer.autofuzz.SettersTest",
+    deps = [
+        ":test_helpers",
+        "//src/test/java/com/code_intelligence/jazzer/autofuzz/testdata:test_data",
+        "@maven//:junit_junit",
+    ],
+)
+
+java_test(
+    name = "AutofuzzCodegenVisitorTest",
+    srcs = [
+        "AutofuzzCodegenVisitorTest.java",
+    ],
+    test_class = "com.code_intelligence.jazzer.autofuzz.AutofuzzCodegenVisitorTest",
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/autofuzz",
+        "@maven//:junit_junit",
+    ],
+)
+
+java_library(
+    name = "test_helpers",
+    srcs = ["TestHelpers.java"],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/api",
+        "//src/main/java/com/code_intelligence/jazzer/autofuzz",
+        "@maven//:junit_junit",
+    ],
+)
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/autofuzz/BuilderPatternTest.java b/src/test/java/com/code_intelligence/jazzer/autofuzz/BuilderPatternTest.java
similarity index 100%
rename from agent/src/test/java/com/code_intelligence/jazzer/autofuzz/BuilderPatternTest.java
rename to src/test/java/com/code_intelligence/jazzer/autofuzz/BuilderPatternTest.java
diff --git a/src/test/java/com/code_intelligence/jazzer/autofuzz/InterfaceCreationTest.java b/src/test/java/com/code_intelligence/jazzer/autofuzz/InterfaceCreationTest.java
new file mode 100644
index 0000000..3fecb97
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/autofuzz/InterfaceCreationTest.java
@@ -0,0 +1,110 @@
+// Copyright 2021 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.autofuzz;
+
+import static com.code_intelligence.jazzer.autofuzz.TestHelpers.consumeTestCase;
+
+import java.util.Arrays;
+import java.util.Objects;
+import org.junit.Test;
+
+public class InterfaceCreationTest {
+  public interface InterfaceA {
+    void foo();
+
+    void bar();
+  }
+
+  public static abstract class ClassA1 implements InterfaceA {
+    @Override
+    public void foo() {}
+  }
+
+  public static class ClassB1 extends ClassA1 {
+    int n;
+
+    public ClassB1(int _n) {
+      n = _n;
+    }
+
+    @Override
+    public void bar() {}
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o)
+        return true;
+      if (o == null || getClass() != o.getClass())
+        return false;
+      ClassB1 classB1 = (ClassB1) o;
+      return n == classB1.n;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(n);
+    }
+  }
+
+  public static class ClassB2 implements InterfaceA {
+    String s;
+
+    public ClassB2(String _s) {
+      s = _s;
+    }
+
+    @Override
+    public void foo() {}
+
+    @Override
+    public void bar() {}
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o)
+        return true;
+      if (o == null || getClass() != o.getClass())
+        return false;
+      ClassB2 classB2 = (ClassB2) o;
+      return Objects.equals(s, classB2.s);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(s);
+    }
+  }
+  @Test
+  public void testConsumeInterface() {
+    consumeTestCase(InterfaceA.class, new ClassB1(5),
+        "(com.code_intelligence.jazzer.autofuzz.InterfaceCreationTest.InterfaceA) new com.code_intelligence.jazzer.autofuzz.InterfaceCreationTest.ClassB1(5)",
+        Arrays.asList((byte) 1, // do not return null
+            0, // pick ClassB1
+            (byte) 1, // do not return null
+            0, // pick first constructor
+            5 // arg for ClassB1 constructor
+            ));
+    consumeTestCase(InterfaceA.class, new ClassB2("test"),
+        "(com.code_intelligence.jazzer.autofuzz.InterfaceCreationTest.InterfaceA) new com.code_intelligence.jazzer.autofuzz.InterfaceCreationTest.ClassB2(\"test\")",
+        Arrays.asList((byte) 1, // do not return null
+            1, // pick ClassB2
+            (byte) 1, // do not return null
+            0, // pick first constructor
+            (byte) 1, // do not return null
+            8, // remaining bytes
+            "test" // arg for ClassB2 constructor
+            ));
+  }
+}
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/autofuzz/MetaTest.java b/src/test/java/com/code_intelligence/jazzer/autofuzz/MetaTest.java
similarity index 80%
rename from agent/src/test/java/com/code_intelligence/jazzer/autofuzz/MetaTest.java
rename to src/test/java/com/code_intelligence/jazzer/autofuzz/MetaTest.java
index 0906d1d..d2fec3a 100644
--- a/agent/src/test/java/com/code_intelligence/jazzer/autofuzz/MetaTest.java
+++ b/src/test/java/com/code_intelligence/jazzer/autofuzz/MetaTest.java
@@ -41,14 +41,13 @@
     consumeTestCase((short) 5, "(short) 5", Collections.singletonList((short) 5));
     consumeTestCase(5L, "5L", Collections.singletonList(5L));
     consumeTestCase(5.0F, "5.0F", Collections.singletonList(5.0F));
-    consumeTestCase('\n', "'\\\\n'", Collections.singletonList('\n'));
-    consumeTestCase('\'', "'\\\\''", Collections.singletonList('\''));
+    consumeTestCase('\n', "'\\n'", Collections.singletonList('\n'));
+    consumeTestCase('\'', "'\\''", Collections.singletonList('\''));
     consumeTestCase('\\', "'\\\\'", Collections.singletonList('\\'));
 
     String testString = "foo\n\t\\\"bar";
-    // The expected string is obtained from testString by escaping, wrapping into quotes and
-    // escaping again.
-    consumeTestCase(testString, "\"foo\\\\n\\\\t\\\\\\\\\"bar\"",
+    // The expected string is obtained from testString by escaping and wrapping into escaped quotes.
+    consumeTestCase(testString, "\"foo\\n\\t\\\\\\\"bar\"",
         Arrays.asList((byte) 1, // do not return null
             testString.length(), testString));
 
@@ -60,7 +59,7 @@
             2 * 3, testBooleans));
 
     char[] testChars = new char[] {'a', '\n', '\''};
-    consumeTestCase(testChars, "new char[]{'a', '\\\\n', '\\\\''}",
+    consumeTestCase(testChars, "new char[]{'a', '\\n', '\\''}",
         Arrays.asList((byte) 1, // do not return null for the array
             2 * 3 * Character.BYTES + Character.BYTES, testChars[0], 2 * 3 * Character.BYTES,
             2 * 3 * Character.BYTES, // remaining bytes, 2 times what is needed for 3 chars
@@ -84,7 +83,7 @@
             testLongs));
 
     consumeTestCase(new String[] {"foo", "bar", "foo\nbar"},
-        "new java.lang.String[]{\"foo\", \"bar\", \"foo\\\\nbar\"}",
+        "new java.lang.String[]{\"foo\", \"bar\", \"foo\\nbar\"}",
         Arrays.asList((byte) 1, // do not return null for the array
             32, // remaining bytes
             (byte) 1, // do not return null for the string
@@ -182,6 +181,39 @@
         CannedFuzzedDataProvider.create(Arrays.asList((byte) 1, // do not return null
             8, // remainingBytes
             "buzz"));
-    assertEquals("fizzbuzz", Meta.autofuzz(data, "fizz" ::concat));
+    assertEquals("fizzbuzz", new Meta(null).autofuzz(data, "fizz" ::concat));
   }
+
+  // Regression test for https://github.com/CodeIntelligenceTesting/jazzer/issues/465.
+  @Test
+  public void testPrivateInterface() {
+    autofuzzTestCase(null,
+        "com.code_intelligence.jazzer.autofuzz.OpinionatedClass.doStuffWithPrivateInterface(((java.util.function.Supplier<com.code_intelligence.jazzer.autofuzz.OpinionatedClass.PublicImplementation>) (() -> {com.code_intelligence.jazzer.autofuzz.OpinionatedClass.PublicImplementation autofuzzVariable0 = new com.code_intelligence.jazzer.autofuzz.OpinionatedClass.PublicImplementation(); return autofuzzVariable0;})).get())",
+        OpinionatedClass.class.getDeclaredMethods()[0],
+        Arrays.asList((byte) 1, // do not return null
+            0, // first (and only) class on the classpath
+            (byte) 1, // do not return null
+            0 /* first (and only) constructor*/));
+  }
+
+  Class<?>[] returnsClassArray() {
+    throw new IllegalStateException(
+        "Should not be called, only exists to construct its generic return type");
+  }
+
+  @Test
+  public void testGetRawType() throws NoSuchMethodException {
+    Type classArrayType =
+        MetaTest.class.getDeclaredMethod("returnsClassArray").getGenericReturnType();
+    assertEquals(Class[].class, Meta.getRawType(classArrayType));
+  }
+}
+
+class OpinionatedClass {
+  public static void doStuffWithPrivateInterface(
+      @SuppressWarnings("unused") PrivateInterface thing) {}
+
+  private interface PrivateInterface {}
+
+  public static class PublicImplementation implements PrivateInterface {}
 }
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/autofuzz/SettersTest.java b/src/test/java/com/code_intelligence/jazzer/autofuzz/SettersTest.java
similarity index 100%
rename from agent/src/test/java/com/code_intelligence/jazzer/autofuzz/SettersTest.java
rename to src/test/java/com/code_intelligence/jazzer/autofuzz/SettersTest.java
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/autofuzz/TestHelpers.java b/src/test/java/com/code_intelligence/jazzer/autofuzz/TestHelpers.java
similarity index 91%
rename from agent/src/test/java/com/code_intelligence/jazzer/autofuzz/TestHelpers.java
rename to src/test/java/com/code_intelligence/jazzer/autofuzz/TestHelpers.java
index d556beb..89f9c96 100644
--- a/agent/src/test/java/com/code_intelligence/jazzer/autofuzz/TestHelpers.java
+++ b/src/test/java/com/code_intelligence/jazzer/autofuzz/TestHelpers.java
@@ -16,7 +16,6 @@
 
 import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertTrue;
 
 import com.code_intelligence.jazzer.api.CannedFuzzedDataProvider;
 import com.code_intelligence.jazzer.api.FuzzedDataProvider;
@@ -61,7 +60,7 @@
       Type type, Object expectedResult, String expectedResultString, List<Object> cannedData) {
     AutofuzzCodegenVisitor visitor = new AutofuzzCodegenVisitor();
     FuzzedDataProvider data = CannedFuzzedDataProvider.create(cannedData);
-    assertGeneralEquals(expectedResult, Meta.consume(data, type, visitor));
+    assertGeneralEquals(expectedResult, new Meta(null).consume(data, type, visitor));
     assertEquals(expectedResultString, visitor.generate());
   }
 
@@ -70,9 +69,10 @@
     AutofuzzCodegenVisitor visitor = new AutofuzzCodegenVisitor();
     FuzzedDataProvider data = CannedFuzzedDataProvider.create(cannedData);
     if (func instanceof Method) {
-      assertGeneralEquals(expectedResult, Meta.autofuzz(data, (Method) func, visitor));
+      assertGeneralEquals(expectedResult, new Meta(null).autofuzz(data, (Method) func, visitor));
     } else {
-      assertGeneralEquals(expectedResult, Meta.autofuzz(data, (Constructor<?>) func, visitor));
+      assertGeneralEquals(
+          expectedResult, new Meta(null).autofuzz(data, (Constructor<?>) func, visitor));
     }
     assertEquals(expectedResultString, visitor.generate());
   }
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/autofuzz/testdata/BUILD.bazel b/src/test/java/com/code_intelligence/jazzer/autofuzz/testdata/BUILD.bazel
similarity index 100%
rename from agent/src/test/java/com/code_intelligence/jazzer/autofuzz/testdata/BUILD.bazel
rename to src/test/java/com/code_intelligence/jazzer/autofuzz/testdata/BUILD.bazel
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/autofuzz/testdata/EmployeeWithSetters.java b/src/test/java/com/code_intelligence/jazzer/autofuzz/testdata/EmployeeWithSetters.java
similarity index 100%
rename from agent/src/test/java/com/code_intelligence/jazzer/autofuzz/testdata/EmployeeWithSetters.java
rename to src/test/java/com/code_intelligence/jazzer/autofuzz/testdata/EmployeeWithSetters.java
diff --git a/src/test/java/com/code_intelligence/jazzer/driver/BUILD.bazel b/src/test/java/com/code_intelligence/jazzer/driver/BUILD.bazel
new file mode 100644
index 0000000..678ed2b
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/driver/BUILD.bazel
@@ -0,0 +1,48 @@
+java_test(
+    name = "FuzzTargetRunnerTest",
+    srcs = ["FuzzTargetRunnerTest.java"],
+    jvm_flags = ["-ea"],
+    use_testrunner = False,
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/agent:agent_installer",
+        "//src/main/java/com/code_intelligence/jazzer/api",
+        "//src/main/java/com/code_intelligence/jazzer/api:hooks",
+        "//src/main/java/com/code_intelligence/jazzer/driver:fuzz_target_finder",
+        "//src/main/java/com/code_intelligence/jazzer/driver:fuzz_target_holder",
+        "//src/main/java/com/code_intelligence/jazzer/driver:fuzz_target_runner",
+        "//src/main/java/com/code_intelligence/jazzer/runtime:coverage_map",
+        "//src/main/java/com/code_intelligence/jazzer/utils:unsafe_provider",
+    ],
+)
+
+java_test(
+    name = "FuzzedDataProviderImplTest",
+    srcs = ["FuzzedDataProviderImplTest.java"],
+    use_testrunner = False,
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/api",
+        "//src/main/java/com/code_intelligence/jazzer/driver:fuzzed_data_provider_impl",
+    ],
+)
+
+java_test(
+    name = "OptTest",
+    srcs = ["OptTest.java"],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/driver:opt",
+        "@maven//:junit_junit",
+    ],
+)
+
+java_test(
+    name = "RecordingFuzzedDataProviderTest",
+    srcs = [
+        "RecordingFuzzedDataProviderTest.java",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/api",
+        "//src/main/java/com/code_intelligence/jazzer/driver:fuzzed_data_provider_impl",
+        "//src/main/java/com/code_intelligence/jazzer/driver:recording_fuzzed_data_provider",
+        "@maven//:junit_junit",
+    ],
+)
diff --git a/driver/src/test/java/com/code_intelligence/jazzer/driver/FuzzTargetRunnerTest.java b/src/test/java/com/code_intelligence/jazzer/driver/FuzzTargetRunnerTest.java
similarity index 75%
rename from driver/src/test/java/com/code_intelligence/jazzer/driver/FuzzTargetRunnerTest.java
rename to src/test/java/com/code_intelligence/jazzer/driver/FuzzTargetRunnerTest.java
index d8f048e..e0ff313 100644
--- a/driver/src/test/java/com/code_intelligence/jazzer/driver/FuzzTargetRunnerTest.java
+++ b/src/test/java/com/code_intelligence/jazzer/driver/FuzzTargetRunnerTest.java
@@ -16,12 +16,15 @@
 
 package com.code_intelligence.jazzer.driver;
 
+import com.code_intelligence.jazzer.agent.AgentInstaller;
 import com.code_intelligence.jazzer.api.Jazzer;
 import com.code_intelligence.jazzer.runtime.CoverageMap;
-import com.code_intelligence.jazzer.runtime.UnsafeProvider;
+import com.code_intelligence.jazzer.utils.UnsafeProvider;
 import java.io.ByteArrayOutputStream;
 import java.io.PrintStream;
 import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
@@ -30,7 +33,7 @@
 
 public class FuzzTargetRunnerTest {
   private static final Pattern DEDUP_TOKEN_PATTERN =
-      Pattern.compile("(?m)^DEDUP_TOKEN: ([0-9a-f]{16})$");
+      Pattern.compile("(?m)^DEDUP_TOKEN: ([0-9a-f]{16})(?:\r\n|\r|\n)");
   private static final Unsafe UNSAFE = UnsafeProvider.getUnsafe();
   private static final ByteArrayOutputStream recordedErr = new ByteArrayOutputStream();
   private static final ByteArrayOutputStream recordedOut = new ByteArrayOutputStream();
@@ -55,22 +58,27 @@
         throw new IllegalArgumentException("not reported");
       case "crash":
         CoverageMap.recordCoverage(3);
-        throw new IllegalArgumentException("crash");
+        throw new RuntimeException("crash");
     }
   }
 
   public static void fuzzerTearDown() {
-    String errOutput = new String(recordedErr.toByteArray(), StandardCharsets.UTF_8);
-    assert errOutput.contains("== Java Exception: java.lang.IllegalArgumentException: crash");
-    String outOutput = new String(recordedOut.toByteArray(), StandardCharsets.UTF_8);
-    assert DEDUP_TOKEN_PATTERN.matcher(outOutput).find();
+    try {
+      String errOutput = new String(recordedErr.toByteArray(), StandardCharsets.UTF_8);
+      assert errOutput.contains("== Java Exception: java.lang.RuntimeException: crash");
+      String outOutput = new String(recordedOut.toByteArray(), StandardCharsets.UTF_8);
+      assert DEDUP_TOKEN_PATTERN.matcher(outOutput).find();
 
-    assert finishedAllNonCrashingRuns : "Did not finish all expected runs before crashing";
-    assert CoverageMap.getCoveredIds().equals(Stream.of(0, 1, 2, 3).collect(Collectors.toSet()));
-    assert UNSAFE.getByte(CoverageMap.countersAddress) == 2;
-    assert UNSAFE.getByte(CoverageMap.countersAddress + 1) == 2;
-    assert UNSAFE.getByte(CoverageMap.countersAddress + 2) == 2;
-    assert UNSAFE.getByte(CoverageMap.countersAddress + 3) == 1;
+      assert finishedAllNonCrashingRuns : "Did not finish all expected runs before crashing";
+      assert CoverageMap.getCoveredIds().equals(Stream.of(0, 1, 2, 3).collect(Collectors.toSet()));
+      assert UNSAFE.getByte(CoverageMap.countersAddress) == 2;
+      assert UNSAFE.getByte(CoverageMap.countersAddress + 1) == 2;
+      assert UNSAFE.getByte(CoverageMap.countersAddress + 2) == 2;
+      assert UNSAFE.getByte(CoverageMap.countersAddress + 3) == 1;
+    } catch (AssertionError e) {
+      e.printStackTrace();
+      Runtime.getRuntime().halt(1);
+    }
     // FuzzTargetRunner calls _Exit after this function, so the test would fail unless this line is
     // executed. Use halt rather than exit to get around FuzzTargetRunner's shutdown hook calling
     // fuzzerTearDown, which would otherwise result in a shutdown hook loop.
@@ -83,17 +91,28 @@
     PrintStream recordingOut = new TeeOutputStream(new PrintStream(recordedOut, true), System.out);
     System.setOut(recordingOut);
 
+    // Do not instrument any classes.
+    System.setProperty("jazzer.instrumentation_excludes", "**");
+    System.setProperty("jazzer.custom_hook_excludes", "**");
     System.setProperty("jazzer.target_class", FuzzTargetRunnerTest.class.getName());
     // Keep going past all "no crash", "first finding" and "second finding" runs, then crash.
     System.setProperty("jazzer.keep_going", "3");
 
+    AgentInstaller.install(true);
+    FuzzTargetHolder.fuzzTarget =
+        FuzzTargetFinder.findFuzzTarget(FuzzTargetRunnerTest.class.getName());
+
     // Use a loop to simulate two findings with the same stack trace and thus verify that keep_going
     // works as advertised.
     for (int i = 1; i < 3; i++) {
       int result = FuzzTargetRunner.runOne("no crash".getBytes(StandardCharsets.UTF_8));
+      if (i == 1) {
+        // Initializing FuzzTargetRunner, which happens implicitly on the first call to runOne,
+        // starts the Jazzer agent, which prints out some info messages to stdout. Ignore them.
+        recordedOut.reset();
+      }
 
       assert result == 0;
-      assert !FuzzTargetRunner.useFuzzedDataProvider;
       assert fuzzerInitializeRan;
       assert CoverageMap.getCoveredIds().equals(Stream.of(0).collect(Collectors.toSet()));
       assert UNSAFE.getByte(CoverageMap.countersAddress) == i;
@@ -102,9 +121,14 @@
       assert UNSAFE.getByte(CoverageMap.countersAddress + 3) == 0;
 
       String errOutput = new String(recordedErr.toByteArray(), StandardCharsets.UTF_8);
-      assert errOutput.isEmpty();
+      List<String> unexpectedLines = Arrays.stream(errOutput.split("\n"))
+                                         .filter(line -> !line.startsWith("INFO: "))
+                                         .collect(Collectors.toList());
+      assert unexpectedLines.isEmpty()
+          : "Unexpected output on System.err: '"
+          + String.join("\n", unexpectedLines) + "'";
       String outOutput = new String(recordedOut.toByteArray(), StandardCharsets.UTF_8);
-      assert outOutput.isEmpty();
+      assert outOutput.isEmpty() : "Non-empty System.out: '" + outOutput + "'";
     }
 
     String firstDedupToken = null;
@@ -124,7 +148,7 @@
         assert errOutput.contains(
             "== Java Exception: java.lang.IllegalArgumentException: first finding");
         Matcher dedupTokenMatcher = DEDUP_TOKEN_PATTERN.matcher(outOutput);
-        assert dedupTokenMatcher.find();
+        assert dedupTokenMatcher.matches() : "Unexpected output on System.out: '" + outOutput + "'";
         firstDedupToken = dedupTokenMatcher.group();
         recordedErr.reset();
         recordedOut.reset();
@@ -153,7 +177,7 @@
             "== Java Exception: com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow: Stack overflow (use ");
         assert !errOutput.contains("not reported");
         Matcher dedupTokenMatcher = DEDUP_TOKEN_PATTERN.matcher(outOutput);
-        assert dedupTokenMatcher.find();
+        assert dedupTokenMatcher.matches() : "Unexpected output on System.out: '" + outOutput + "'";
         assert !firstDedupToken.equals(dedupTokenMatcher.group());
         recordedErr.reset();
         recordedOut.reset();
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/runtime/FuzzedDataProviderImplTest.java b/src/test/java/com/code_intelligence/jazzer/driver/FuzzedDataProviderImplTest.java
similarity index 88%
rename from agent/src/test/java/com/code_intelligence/jazzer/runtime/FuzzedDataProviderImplTest.java
rename to src/test/java/com/code_intelligence/jazzer/driver/FuzzedDataProviderImplTest.java
index 5e922fc..26ebc0d 100644
--- a/agent/src/test/java/com/code_intelligence/jazzer/runtime/FuzzedDataProviderImplTest.java
+++ b/src/test/java/com/code_intelligence/jazzer/driver/FuzzedDataProviderImplTest.java
@@ -12,9 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.code_intelligence.jazzer.runtime;
+package com.code_intelligence.jazzer.driver;
 
 import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
 import java.util.Arrays;
 import java.util.stream.Collectors;
 
@@ -51,15 +53,16 @@
     assertEqual(true,
         Arrays.equals(new long[] {0x0123456789abdcefL, 0xfedcba9876543210L}, data.consumeLongs(2)));
 
-    assertEqual((float) 0.28969181, data.consumeProbabilityFloat());
-    assertEqual(0.086814121166605432, data.consumeProbabilityDouble());
-    assertEqual((float) 0.30104411, data.consumeProbabilityFloat());
-    assertEqual(0.96218831486039413, data.consumeProbabilityDouble());
+    assertAtLeastAsPrecise((float) 0.28969181, data.consumeProbabilityFloat());
+    assertAtLeastAsPrecise(0.086814121166605432, data.consumeProbabilityDouble());
+    assertAtLeastAsPrecise((float) 0.30104411, data.consumeProbabilityFloat());
+    assertAtLeastAsPrecise(0.96218831486039413, data.consumeProbabilityDouble());
 
-    assertEqual((float) -2.8546307e+38, data.consumeRegularFloat());
-    assertEqual(8.0940194040236032e+307, data.consumeRegularDouble());
-    assertEqual((float) 271.49084, data.consumeRegularFloat((float) 123.0, (float) 777.0));
-    assertEqual(30.859126145478349, data.consumeRegularDouble(13.37, 31.337));
+    assertAtLeastAsPrecise((float) -2.8546307e+38, data.consumeRegularFloat());
+    assertAtLeastAsPrecise(8.0940194040236032e+307, data.consumeRegularDouble());
+    assertAtLeastAsPrecise(
+        (float) 271.49084, data.consumeRegularFloat((float) 123.0, (float) 777.0));
+    assertAtLeastAsPrecise(30.859126145478349, data.consumeRegularDouble(13.37, 31.337));
 
     assertEqual((float) 0.0, data.consumeFloat());
     assertEqual((float) -0.0, data.consumeFloat());
@@ -112,6 +115,16 @@
     assertEqual("", data.consumeString(100));
   }
 
+  private static void assertAtLeastAsPrecise(double expected, double actual) {
+    BigDecimal exactExpected = BigDecimal.valueOf(expected);
+    BigDecimal roundedActual =
+        BigDecimal.valueOf(actual).setScale(exactExpected.scale(), RoundingMode.HALF_UP);
+    if (!exactExpected.equals(roundedActual)) {
+      throw new IllegalArgumentException(
+          String.format("Expected: %s, got: %s (rounded: %s)", expected, actual, roundedActual));
+    }
+  }
+
   private static <T extends Comparable<T>> void assertEqual(T a, T b) {
     if (a.compareTo(b) != 0) {
       throw new IllegalArgumentException("Expected: " + a + ", got: " + b);
diff --git a/driver/src/test/java/com/code_intelligence/jazzer/driver/OptTest.java b/src/test/java/com/code_intelligence/jazzer/driver/OptTest.java
similarity index 95%
rename from driver/src/test/java/com/code_intelligence/jazzer/driver/OptTest.java
rename to src/test/java/com/code_intelligence/jazzer/driver/OptTest.java
index 87cda2b..6f9f03c 100644
--- a/driver/src/test/java/com/code_intelligence/jazzer/driver/OptTest.java
+++ b/src/test/java/com/code_intelligence/jazzer/driver/OptTest.java
@@ -39,6 +39,6 @@
 
   public void assertStringSplit(String str, char sep, String... tokens) {
     assertEquals(Arrays.stream(tokens).collect(Collectors.toList()),
-        Opt.splitOnUnescapedSeparator(str, sep));
+        OptParser.splitOnUnescapedSeparator(str, sep));
   }
 }
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/runtime/RecordingFuzzedDataProviderTest.java b/src/test/java/com/code_intelligence/jazzer/driver/RecordingFuzzedDataProviderTest.java
similarity index 97%
rename from agent/src/test/java/com/code_intelligence/jazzer/runtime/RecordingFuzzedDataProviderTest.java
rename to src/test/java/com/code_intelligence/jazzer/driver/RecordingFuzzedDataProviderTest.java
index d58a5ca..de8e3a4 100644
--- a/agent/src/test/java/com/code_intelligence/jazzer/runtime/RecordingFuzzedDataProviderTest.java
+++ b/src/test/java/com/code_intelligence/jazzer/driver/RecordingFuzzedDataProviderTest.java
@@ -12,10 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.code_intelligence.jazzer.runtime;
+package com.code_intelligence.jazzer.driver;
 
 import com.code_intelligence.jazzer.api.CannedFuzzedDataProvider;
 import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import com.code_intelligence.jazzer.driver.RecordingFuzzedDataProvider;
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.stream.Collectors;
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/AfterHooks.java b/src/test/java/com/code_intelligence/jazzer/instrumentor/AfterHooks.java
similarity index 100%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/AfterHooks.java
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/AfterHooks.java
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/AfterHooksPatchTest.kt b/src/test/java/com/code_intelligence/jazzer/instrumentor/AfterHooksPatchTest.kt
similarity index 62%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/AfterHooksPatchTest.kt
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/AfterHooksPatchTest.kt
index c5a2e15..5526378 100644
--- a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/AfterHooksPatchTest.kt
+++ b/src/test/java/com/code_intelligence/jazzer/instrumentor/AfterHooksPatchTest.kt
@@ -19,11 +19,6 @@
 import org.junit.Test
 import java.io.File
 
-private fun applyAfterHooks(bytecode: ByteArray): ByteArray {
-    val hooks = Hooks.loadHooks(setOf(AfterHooks::class.java.name)).first().hooks
-    return HookInstrumentor(hooks, false).instrument(bytecode)
-}
-
 private fun getOriginalAfterHooksTargetInstance(): AfterHooksTargetContract {
     return AfterHooksTarget()
 }
@@ -31,14 +26,22 @@
 private fun getNoHooksAfterHooksTargetInstance(): AfterHooksTargetContract {
     val originalBytecode = classToBytecode(AfterHooksTarget::class.java)
     // Let the bytecode pass through the hooking logic, but don't apply any hooks.
-    val patchedBytecode = HookInstrumentor(emptyList(), false).instrument(originalBytecode)
+    val patchedBytecode = HookInstrumentor(emptyList(), false, null).instrument(
+        AfterHooksTarget::class.java.name.replace('.', '/'),
+        originalBytecode,
+    )
     val patchedClass = bytecodeToClass(AfterHooksTarget::class.java.name, patchedBytecode)
     return patchedClass.getDeclaredConstructor().newInstance() as AfterHooksTargetContract
 }
 
-private fun getPatchedAfterHooksTargetInstance(): AfterHooksTargetContract {
+private fun getPatchedAfterHooksTargetInstance(classWithHooksEnabledField: Class<*>?): AfterHooksTargetContract {
     val originalBytecode = classToBytecode(AfterHooksTarget::class.java)
-    val patchedBytecode = applyAfterHooks(originalBytecode)
+    val hooks = Hooks.loadHooks(emptyList(), setOf(AfterHooks::class.java.name)).first().hooks
+    val patchedBytecode = HookInstrumentor(
+        hooks,
+        false,
+        classWithHooksEnabledField = classWithHooksEnabledField?.name?.replace('.', '/'),
+    ).instrument(AfterHooksTarget::class.java.name.replace('.', '/'), originalBytecode)
     // Make the patched class available in bazel-testlogs/.../test.outputs for manual inspection.
     val outDir = System.getenv("TEST_UNDECLARED_OUTPUTS_DIR")
     File("$outDir/${AfterHooksTarget::class.java.simpleName}.class").writeBytes(originalBytecode)
@@ -47,20 +50,40 @@
     return patchedClass.getDeclaredConstructor().newInstance() as AfterHooksTargetContract
 }
 
-class AfterHookTest {
+class AfterHooksPatchTest {
 
     @Test
-    fun testAfterHooksOriginal() {
+    fun testOriginal() {
         assertSelfCheck(getOriginalAfterHooksTargetInstance(), false)
     }
 
     @Test
-    fun testAfterHooksNoHooks() {
+    fun testPatchedWithoutHooks() {
         assertSelfCheck(getNoHooksAfterHooksTargetInstance(), false)
     }
 
     @Test
-    fun testAfterHooksPatched() {
-        assertSelfCheck(getPatchedAfterHooksTargetInstance(), true)
+    fun testPatched() {
+        assertSelfCheck(getPatchedAfterHooksTargetInstance(null), true)
+    }
+
+    object HooksEnabled {
+        @Suppress("unused")
+        const val hooksEnabled = true
+    }
+
+    object HooksDisabled {
+        @Suppress("unused")
+        const val hooksEnabled = false
+    }
+
+    @Test
+    fun testPatchedWithConditionalHooksEnabled() {
+        assertSelfCheck(getPatchedAfterHooksTargetInstance(HooksEnabled::class.java), true)
+    }
+
+    @Test
+    fun testPatchedWithConditionalHooksDisabled() {
+        assertSelfCheck(getPatchedAfterHooksTargetInstance(HooksDisabled::class.java), false)
     }
 }
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/AfterHooksTarget.java b/src/test/java/com/code_intelligence/jazzer/instrumentor/AfterHooksTarget.java
similarity index 100%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/AfterHooksTarget.java
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/AfterHooksTarget.java
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/AfterHooksTargetContract.java b/src/test/java/com/code_intelligence/jazzer/instrumentor/AfterHooksTargetContract.java
similarity index 100%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/AfterHooksTargetContract.java
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/AfterHooksTargetContract.java
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel b/src/test/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel
similarity index 77%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel
index 036559e..4fdad56 100644
--- a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel
+++ b/src/test/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel
@@ -1,4 +1,4 @@
-load("//bazel:kotlin.bzl", "wrapped_kt_jvm_test")
+load("//bazel:kotlin.bzl", "ktlint", "wrapped_kt_jvm_test")
 load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library")
 
 kt_jvm_library(
@@ -19,7 +19,7 @@
         "TraceDataFlowInstrumentationTest.kt",
     ],
     associates = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/instrumentor:instrumentor",
+        "//src/main/java/com/code_intelligence/jazzer/instrumentor:instrumentor",
     ],
     test_class = "com.code_intelligence.jazzer.instrumentor.TraceDataFlowInstrumentationTest",
     deps = [
@@ -39,11 +39,12 @@
         "MockCoverageMap.java",
     ],
     associates = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/instrumentor:instrumentor",
+        "//src/main/java/com/code_intelligence/jazzer/instrumentor:instrumentor",
     ],
     test_class = "com.code_intelligence.jazzer.instrumentor.CoverageInstrumentationTest",
     deps = [
         ":patch_test_utils",
+        "//src/main/java/com/code_intelligence/jazzer/runtime:coverage_map",
         "@com_github_jetbrains_kotlin//:kotlin-test",
         "@maven//:junit_junit",
     ],
@@ -56,7 +57,7 @@
         "DescriptorUtilsTest.kt",
     ],
     associates = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/instrumentor:instrumentor",
+        "//src/main/java/com/code_intelligence/jazzer/instrumentor:instrumentor",
     ],
     test_class = "com.code_intelligence.jazzer.instrumentor.DescriptorUtilsTest",
     deps = [
@@ -74,11 +75,11 @@
         "ValidHookMocks.java",
     ],
     associates = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/instrumentor:instrumentor",
+        "//src/main/java/com/code_intelligence/jazzer/instrumentor:instrumentor",
     ],
     test_class = "com.code_intelligence.jazzer.instrumentor.HookValidationTest",
     deps = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/api",
+        "//src/main/java/com/code_intelligence/jazzer/api",
         "@com_github_jetbrains_kotlin//:kotlin-test",
         "@maven//:junit_junit",
     ],
@@ -94,12 +95,12 @@
         "AfterHooksTargetContract.java",
     ],
     associates = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/instrumentor:instrumentor",
+        "//src/main/java/com/code_intelligence/jazzer/instrumentor:instrumentor",
     ],
-    test_class = "com.code_intelligence.jazzer.instrumentor.AfterHookTest",
+    test_class = "com.code_intelligence.jazzer.instrumentor.AfterHooksPatchTest",
     deps = [
         ":patch_test_utils",
-        "//agent/src/main/java/com/code_intelligence/jazzer/api",
+        "//src/main/java/com/code_intelligence/jazzer/api",
         "@com_github_jetbrains_kotlin//:kotlin-test",
         "@maven//:junit_junit",
     ],
@@ -115,12 +116,12 @@
         "BeforeHooksTargetContract.java",
     ],
     associates = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/instrumentor:instrumentor",
+        "//src/main/java/com/code_intelligence/jazzer/instrumentor:instrumentor",
     ],
-    test_class = "com.code_intelligence.jazzer.instrumentor.BeforeHookTest",
+    test_class = "com.code_intelligence.jazzer.instrumentor.BeforeHooksPatchTest",
     deps = [
         ":patch_test_utils",
-        "//agent/src/main/java/com/code_intelligence/jazzer/api",
+        "//src/main/java/com/code_intelligence/jazzer/api",
         "@com_github_jetbrains_kotlin//:kotlin-test",
         "@maven//:junit_junit",
     ],
@@ -137,13 +138,15 @@
         "ReplaceHooksTargetContract.java",
     ],
     associates = [
-        "//agent/src/main/java/com/code_intelligence/jazzer/instrumentor:instrumentor",
+        "//src/main/java/com/code_intelligence/jazzer/instrumentor:instrumentor",
     ],
-    test_class = "com.code_intelligence.jazzer.instrumentor.ReplaceHookTest",
+    test_class = "com.code_intelligence.jazzer.instrumentor.ReplaceHooksPatchTest",
     deps = [
         ":patch_test_utils",
-        "//agent/src/main/java/com/code_intelligence/jazzer/api",
+        "//src/main/java/com/code_intelligence/jazzer/api",
         "@com_github_jetbrains_kotlin//:kotlin-test",
         "@maven//:junit_junit",
     ],
 )
+
+ktlint()
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/BeforeHooks.java b/src/test/java/com/code_intelligence/jazzer/instrumentor/BeforeHooks.java
similarity index 100%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/BeforeHooks.java
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/BeforeHooks.java
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/BeforeHooksPatchTest.kt b/src/test/java/com/code_intelligence/jazzer/instrumentor/BeforeHooksPatchTest.kt
similarity index 62%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/BeforeHooksPatchTest.kt
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/BeforeHooksPatchTest.kt
index 4fde7ee..aae469c 100644
--- a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/BeforeHooksPatchTest.kt
+++ b/src/test/java/com/code_intelligence/jazzer/instrumentor/BeforeHooksPatchTest.kt
@@ -19,11 +19,6 @@
 import org.junit.Test
 import java.io.File
 
-private fun applyBeforeHooks(bytecode: ByteArray): ByteArray {
-    val hooks = Hooks.loadHooks(setOf(BeforeHooks::class.java.name)).first().hooks
-    return HookInstrumentor(hooks, false).instrument(bytecode)
-}
-
 private fun getOriginalBeforeHooksTargetInstance(): BeforeHooksTargetContract {
     return BeforeHooksTarget()
 }
@@ -31,14 +26,22 @@
 private fun getNoHooksBeforeHooksTargetInstance(): BeforeHooksTargetContract {
     val originalBytecode = classToBytecode(BeforeHooksTarget::class.java)
     // Let the bytecode pass through the hooking logic, but don't apply any hooks.
-    val patchedBytecode = HookInstrumentor(emptyList(), false).instrument(originalBytecode)
+    val patchedBytecode = HookInstrumentor(emptyList(), false, null).instrument(
+        BeforeHooksTarget::class.java.name.replace('.', '/'),
+        originalBytecode,
+    )
     val patchedClass = bytecodeToClass(BeforeHooksTarget::class.java.name, patchedBytecode)
     return patchedClass.getDeclaredConstructor().newInstance() as BeforeHooksTargetContract
 }
 
-private fun getPatchedBeforeHooksTargetInstance(): BeforeHooksTargetContract {
+private fun getPatchedBeforeHooksTargetInstance(classWithHooksEnabledField: Class<*>?): BeforeHooksTargetContract {
     val originalBytecode = classToBytecode(BeforeHooksTarget::class.java)
-    val patchedBytecode = applyBeforeHooks(originalBytecode)
+    val hooks = Hooks.loadHooks(emptyList(), setOf(BeforeHooks::class.java.name)).first().hooks
+    val patchedBytecode = HookInstrumentor(
+        hooks,
+        false,
+        classWithHooksEnabledField = classWithHooksEnabledField?.name?.replace('.', '/'),
+    ).instrument(BeforeHooksTarget::class.java.name.replace('.', '/'), originalBytecode)
     // Make the patched class available in bazel-testlogs/.../test.outputs for manual inspection.
     val outDir = System.getenv("TEST_UNDECLARED_OUTPUTS_DIR")
     File("$outDir/${BeforeHooksTarget::class.java.simpleName}.class").writeBytes(originalBytecode)
@@ -47,20 +50,40 @@
     return patchedClass.getDeclaredConstructor().newInstance() as BeforeHooksTargetContract
 }
 
-class BeforeHookTest {
+class BeforeHooksPatchTest {
 
     @Test
-    fun testBeforeHooksOriginal() {
+    fun testOriginal() {
         assertSelfCheck(getOriginalBeforeHooksTargetInstance(), false)
     }
 
     @Test
-    fun testBeforeHooksNoHooks() {
+    fun testPatchedWithoutHooks() {
         assertSelfCheck(getNoHooksBeforeHooksTargetInstance(), false)
     }
 
     @Test
-    fun testBeforeHooksPatched() {
-        assertSelfCheck(getPatchedBeforeHooksTargetInstance(), true)
+    fun testPatched() {
+        assertSelfCheck(getPatchedBeforeHooksTargetInstance(null), true)
+    }
+
+    object HooksEnabled {
+        @Suppress("unused")
+        const val hooksEnabled = true
+    }
+
+    object HooksDisabled {
+        @Suppress("unused")
+        const val hooksEnabled = false
+    }
+
+    @Test
+    fun testPatchedWithConditionalHooksEnabled() {
+        assertSelfCheck(getPatchedBeforeHooksTargetInstance(HooksEnabled::class.java), true)
+    }
+
+    @Test
+    fun testPatchedWithConditionalHooksDisabled() {
+        assertSelfCheck(getPatchedBeforeHooksTargetInstance(HooksDisabled::class.java), false)
     }
 }
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/BeforeHooksTarget.java b/src/test/java/com/code_intelligence/jazzer/instrumentor/BeforeHooksTarget.java
similarity index 100%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/BeforeHooksTarget.java
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/BeforeHooksTarget.java
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/BeforeHooksTargetContract.java b/src/test/java/com/code_intelligence/jazzer/instrumentor/BeforeHooksTargetContract.java
similarity index 100%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/BeforeHooksTargetContract.java
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/BeforeHooksTargetContract.java
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/CoverageInstrumentationSpecialCasesTarget.java b/src/test/java/com/code_intelligence/jazzer/instrumentor/CoverageInstrumentationSpecialCasesTarget.java
similarity index 100%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/CoverageInstrumentationSpecialCasesTarget.java
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/CoverageInstrumentationSpecialCasesTarget.java
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/CoverageInstrumentationTarget.java b/src/test/java/com/code_intelligence/jazzer/instrumentor/CoverageInstrumentationTarget.java
similarity index 100%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/CoverageInstrumentationTarget.java
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/CoverageInstrumentationTarget.java
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/CoverageInstrumentationTest.kt b/src/test/java/com/code_intelligence/jazzer/instrumentor/CoverageInstrumentationTest.kt
similarity index 90%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/CoverageInstrumentationTest.kt
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/CoverageInstrumentationTest.kt
index f2cf2f0..5a3c355 100644
--- a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/CoverageInstrumentationTest.kt
+++ b/src/test/java/com/code_intelligence/jazzer/instrumentor/CoverageInstrumentationTest.kt
@@ -32,28 +32,24 @@
             mv: MethodVisitor,
             edgeId: Int,
             variable: Int,
-            coverageMapInternalClassName: String
+            coverageMapInternalClassName: String,
         ) {
             strategy.instrumentControlFlowEdge(mv, edgeId, variable, coverageMapInternalClassName)
             mv.visitMethodInsn(Opcodes.INVOKESTATIC, coverageMapInternalClassName, "updated", "()V", false)
         }
     }
 
-private fun applyInstrumentation(bytecode: ByteArray): ByteArray {
-    return EdgeCoverageInstrumentor(
-        makeTestable(ClassInstrumentor.defaultEdgeCoverageStrategy),
-        MockCoverageMap::class.java,
-        0
-    ).instrument(bytecode)
-}
-
 private fun getOriginalInstrumentationTargetInstance(): DynamicTestContract {
     return CoverageInstrumentationTarget()
 }
 
 private fun getInstrumentedInstrumentationTargetInstance(): DynamicTestContract {
     val originalBytecode = classToBytecode(CoverageInstrumentationTarget::class.java)
-    val patchedBytecode = applyInstrumentation(originalBytecode)
+    val patchedBytecode = EdgeCoverageInstrumentor(
+        makeTestable(ClassInstrumentor.defaultEdgeCoverageStrategy),
+        MockCoverageMap::class.java,
+        0,
+    ).instrument(CoverageInstrumentationTarget::class.java.name.replace('.', '/'), originalBytecode)
     // Make the patched class available in bazel-testlogs/.../test.outputs for manual inspection.
     val outDir = System.getenv("TEST_UNDECLARED_OUTPUTS_DIR")
     File("$outDir/${CoverageInstrumentationTarget::class.java.simpleName}.class").writeBytes(originalBytecode)
@@ -123,18 +119,19 @@
         }.toList()
         val forControlFlow = forFirstRunControlFlow + forSecondRunControlFlow
         val fooCallControlFlow = listOf(
-            barAfterPutInvocation, fooAfterBarInvocation, afterFooInvocation
+            barAfterPutInvocation,
+            fooAfterBarInvocation,
+            afterFooInvocation,
         )
         assertControlFlow(
             listOf(constructorReturn) +
                 mapControlFlow +
                 ifControlFlow +
                 forControlFlow +
-                fooCallControlFlow
+                fooCallControlFlow,
         )
     }
 
-    @OptIn(ExperimentalUnsignedTypes::class)
     @Test
     fun testCounters() {
         MockCoverageMap.clear()
@@ -161,12 +158,16 @@
     @Test
     fun testSpecialCases() {
         val originalBytecode = classToBytecode(CoverageInstrumentationSpecialCasesTarget::class.java)
-        val patchedBytecode = applyInstrumentation(originalBytecode)
+        val patchedBytecode = EdgeCoverageInstrumentor(
+            makeTestable(ClassInstrumentor.defaultEdgeCoverageStrategy),
+            MockCoverageMap::class.java,
+            0,
+        ).instrument(CoverageInstrumentationSpecialCasesTarget::class.java.name.replace('.', '/'), originalBytecode)
         // Make the patched class available in bazel-testlogs/.../test.outputs for manual inspection.
         val outDir = System.getenv("TEST_UNDECLARED_OUTPUTS_DIR")
         File("$outDir/${CoverageInstrumentationSpecialCasesTarget::class.simpleName}.class").writeBytes(originalBytecode)
         File("$outDir/${CoverageInstrumentationSpecialCasesTarget::class.simpleName}.patched.class").writeBytes(
-            patchedBytecode
+            patchedBytecode,
         )
         val patchedClass = bytecodeToClass(CoverageInstrumentationSpecialCasesTarget::class.java.name, patchedBytecode)
         // Trigger a class load
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/DescriptorUtilsTest.kt b/src/test/java/com/code_intelligence/jazzer/instrumentor/DescriptorUtilsTest.kt
similarity index 93%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/DescriptorUtilsTest.kt
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/DescriptorUtilsTest.kt
index e7e1feb..c1a3584 100644
--- a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/DescriptorUtilsTest.kt
+++ b/src/test/java/com/code_intelligence/jazzer/instrumentor/DescriptorUtilsTest.kt
@@ -14,7 +14,6 @@
 
 package com.code_intelligence.jazzer.instrumentor
 
-import com.code_intelligence.jazzer.utils.descriptor
 import org.junit.Test
 import kotlin.test.assertEquals
 
@@ -41,28 +40,28 @@
             Triple(
                 String::class.java.getMethod("equals", Object::class.java),
                 listOf("Ljava/lang/Object;"),
-                "Z"
+                "Z",
             ),
             Triple(
                 String::class.java.getMethod("regionMatches", Boolean::class.javaPrimitiveType, Int::class.javaPrimitiveType, String::class.java, Int::class.javaPrimitiveType, Integer::class.javaPrimitiveType),
                 listOf("Z", "I", "Ljava/lang/String;", "I", "I"),
-                "Z"
+                "Z",
             ),
             Triple(
                 String::class.java.getMethod("getChars", Integer::class.javaPrimitiveType, Int::class.javaPrimitiveType, CharArray::class.java, Int::class.javaPrimitiveType),
                 listOf("I", "I", "[C", "I"),
-                "V"
+                "V",
             ),
             Triple(
                 String::class.java.getMethod("subSequence", Integer::class.javaPrimitiveType, Integer::class.javaPrimitiveType),
                 listOf("I", "I"),
-                "Ljava/lang/CharSequence;"
+                "Ljava/lang/CharSequence;",
             ),
             Triple(
                 String::class.java.getConstructor(),
                 emptyList(),
-                "V"
-            )
+                "V",
+            ),
         )
         for ((executable, parameterDescriptors, returnTypeDescriptor) in testCases) {
             val descriptor = executable.descriptor
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/DynamicTestContract.java b/src/test/java/com/code_intelligence/jazzer/instrumentor/DynamicTestContract.java
similarity index 100%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/DynamicTestContract.java
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/DynamicTestContract.java
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/HookValidationTest.kt b/src/test/java/com/code_intelligence/jazzer/instrumentor/HookValidationTest.kt
similarity index 92%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/HookValidationTest.kt
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/HookValidationTest.kt
index ac263dc..bf02da7 100644
--- a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/HookValidationTest.kt
+++ b/src/test/java/com/code_intelligence/jazzer/instrumentor/HookValidationTest.kt
@@ -22,7 +22,7 @@
 class HookValidationTest {
     @Test
     fun testValidHooks() {
-        val hooks = Hooks.loadHooks(setOf(ValidHookMocks::class.java.name)).first().hooks
+        val hooks = Hooks.loadHooks(emptyList(), setOf(ValidHookMocks::class.java.name)).first().hooks
         assertEquals(5, hooks.size)
     }
 
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/InvalidHookMocks.java b/src/test/java/com/code_intelligence/jazzer/instrumentor/InvalidHookMocks.java
similarity index 100%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/InvalidHookMocks.java
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/InvalidHookMocks.java
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/MockCoverageMap.java b/src/test/java/com/code_intelligence/jazzer/instrumentor/MockCoverageMap.java
similarity index 100%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/MockCoverageMap.java
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/MockCoverageMap.java
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/MockTraceDataFlowCallbacks.java b/src/test/java/com/code_intelligence/jazzer/instrumentor/MockTraceDataFlowCallbacks.java
similarity index 100%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/MockTraceDataFlowCallbacks.java
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/MockTraceDataFlowCallbacks.java
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/PatchTestUtils.kt b/src/test/java/com/code_intelligence/jazzer/instrumentor/PatchTestUtils.kt
similarity index 93%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/PatchTestUtils.kt
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/PatchTestUtils.kt
index 00279c3..de2cc18 100644
--- a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/PatchTestUtils.kt
+++ b/src/test/java/com/code_intelligence/jazzer/instrumentor/PatchTestUtils.kt
@@ -33,7 +33,7 @@
     }
 
     @JvmStatic
-    public fun dumpBytecode(outDir: String, name: String, originalBytecode: ByteArray) {
+    fun dumpBytecode(outDir: String, name: String, originalBytecode: ByteArray) {
         FileOutputStream("$outDir/$name.class").use { fos -> fos.write(originalBytecode) }
     }
 
@@ -44,8 +44,9 @@
     class BytecodeClassLoader(val className: String, private val classBytecode: ByteArray) :
         ClassLoader(BytecodeClassLoader::class.java.classLoader) {
         override fun loadClass(name: String): Class<*> {
-            if (name != className)
+            if (name != className) {
                 return super.loadClass(name)
+            }
             return defineClass(className, classBytecode, 0, classBytecode.size)
         }
     }
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/ReplaceHooks.java b/src/test/java/com/code_intelligence/jazzer/instrumentor/ReplaceHooks.java
similarity index 100%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/ReplaceHooks.java
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/ReplaceHooks.java
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/ReplaceHooksInit.java b/src/test/java/com/code_intelligence/jazzer/instrumentor/ReplaceHooksInit.java
similarity index 100%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/ReplaceHooksInit.java
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/ReplaceHooksInit.java
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/ReplaceHooksPatchTest.kt b/src/test/java/com/code_intelligence/jazzer/instrumentor/ReplaceHooksPatchTest.kt
similarity index 62%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/ReplaceHooksPatchTest.kt
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/ReplaceHooksPatchTest.kt
index b6266d1..275c43f 100644
--- a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/ReplaceHooksPatchTest.kt
+++ b/src/test/java/com/code_intelligence/jazzer/instrumentor/ReplaceHooksPatchTest.kt
@@ -19,11 +19,6 @@
 import org.junit.Test
 import java.io.File
 
-private fun applyReplaceHooks(bytecode: ByteArray): ByteArray {
-    val hooks = Hooks.loadHooks(setOf(ReplaceHooks::class.java.name)).first().hooks
-    return HookInstrumentor(hooks, false).instrument(bytecode)
-}
-
 private fun getOriginalReplaceHooksTargetInstance(): ReplaceHooksTargetContract {
     return ReplaceHooksTarget()
 }
@@ -31,14 +26,22 @@
 private fun getNoHooksReplaceHooksTargetInstance(): ReplaceHooksTargetContract {
     val originalBytecode = classToBytecode(ReplaceHooksTarget::class.java)
     // Let the bytecode pass through the hooking logic, but don't apply any hooks.
-    val patchedBytecode = HookInstrumentor(emptyList(), false).instrument(originalBytecode)
+    val patchedBytecode = HookInstrumentor(emptyList(), false, null).instrument(
+        ReplaceHooksTarget::class.java.name.replace('.', '/'),
+        originalBytecode,
+    )
     val patchedClass = bytecodeToClass(ReplaceHooksTarget::class.java.name, patchedBytecode)
     return patchedClass.getDeclaredConstructor().newInstance() as ReplaceHooksTargetContract
 }
 
-private fun getPatchedReplaceHooksTargetInstance(): ReplaceHooksTargetContract {
+private fun getPatchedReplaceHooksTargetInstance(classWithHooksEnabledField: Class<*>?): ReplaceHooksTargetContract {
     val originalBytecode = classToBytecode(ReplaceHooksTarget::class.java)
-    val patchedBytecode = applyReplaceHooks(originalBytecode)
+    val hooks = Hooks.loadHooks(emptyList(), setOf(ReplaceHooks::class.java.name)).first().hooks
+    val patchedBytecode = HookInstrumentor(
+        hooks,
+        false,
+        classWithHooksEnabledField = classWithHooksEnabledField?.name?.replace('.', '/'),
+    ).instrument(ReplaceHooksTarget::class.java.name.replace('.', '/'), originalBytecode)
     // Make the patched class available in bazel-testlogs/.../test.outputs for manual inspection.
     val outDir = System.getenv("TEST_UNDECLARED_OUTPUTS_DIR")
     File("$outDir/${ReplaceHooksTarget::class.java.simpleName}.class").writeBytes(originalBytecode)
@@ -47,20 +50,40 @@
     return patchedClass.getDeclaredConstructor().newInstance() as ReplaceHooksTargetContract
 }
 
-class ReplaceHookTest {
+class ReplaceHooksPatchTest {
 
     @Test
-    fun testReplaceHooksOriginal() {
+    fun testOriginal() {
         assertSelfCheck(getOriginalReplaceHooksTargetInstance(), false)
     }
 
     @Test
-    fun testReplaceHooksNoHooks() {
+    fun testPatchedWithoutHooks() {
         assertSelfCheck(getNoHooksReplaceHooksTargetInstance(), false)
     }
 
     @Test
-    fun testReplaceHooksPatched() {
-        assertSelfCheck(getPatchedReplaceHooksTargetInstance(), true)
+    fun testPatched() {
+        assertSelfCheck(getPatchedReplaceHooksTargetInstance(null), true)
+    }
+
+    object HooksEnabled {
+        @Suppress("unused")
+        const val hooksEnabled = true
+    }
+
+    object HooksDisabled {
+        @Suppress("unused")
+        const val hooksEnabled = false
+    }
+
+    @Test
+    fun testPatchedWithConditionalHooksEnabled() {
+        assertSelfCheck(getPatchedReplaceHooksTargetInstance(HooksEnabled::class.java), true)
+    }
+
+    @Test
+    fun testPatchedWithConditionalHooksDisabled() {
+        assertSelfCheck(getPatchedReplaceHooksTargetInstance(HooksDisabled::class.java), false)
     }
 }
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/ReplaceHooksTarget.java b/src/test/java/com/code_intelligence/jazzer/instrumentor/ReplaceHooksTarget.java
similarity index 100%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/ReplaceHooksTarget.java
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/ReplaceHooksTarget.java
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/ReplaceHooksTargetContract.java b/src/test/java/com/code_intelligence/jazzer/instrumentor/ReplaceHooksTargetContract.java
similarity index 100%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/ReplaceHooksTargetContract.java
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/ReplaceHooksTargetContract.java
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/TraceDataFlowInstrumentationTarget.java b/src/test/java/com/code_intelligence/jazzer/instrumentor/TraceDataFlowInstrumentationTarget.java
similarity index 100%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/TraceDataFlowInstrumentationTarget.java
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/TraceDataFlowInstrumentationTarget.java
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/TraceDataFlowInstrumentationTest.kt b/src/test/java/com/code_intelligence/jazzer/instrumentor/TraceDataFlowInstrumentationTest.kt
similarity index 93%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/TraceDataFlowInstrumentationTest.kt
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/TraceDataFlowInstrumentationTest.kt
index 4d4b031..b7383f1 100644
--- a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/TraceDataFlowInstrumentationTest.kt
+++ b/src/test/java/com/code_intelligence/jazzer/instrumentor/TraceDataFlowInstrumentationTest.kt
@@ -19,24 +19,20 @@
 import org.junit.Test
 import java.io.File
 
-private fun applyInstrumentation(bytecode: ByteArray): ByteArray {
-    return TraceDataFlowInstrumentor(
-        setOf(
-            InstrumentationType.CMP,
-            InstrumentationType.DIV,
-            InstrumentationType.GEP
-        ),
-        MockTraceDataFlowCallbacks::class.java
-    ).instrument(bytecode)
-}
-
 private fun getOriginalInstrumentationTargetInstance(): DynamicTestContract {
     return TraceDataFlowInstrumentationTarget()
 }
 
 private fun getInstrumentedInstrumentationTargetInstance(): DynamicTestContract {
     val originalBytecode = classToBytecode(TraceDataFlowInstrumentationTarget::class.java)
-    val patchedBytecode = applyInstrumentation(originalBytecode)
+    val patchedBytecode = TraceDataFlowInstrumentor(
+        setOf(
+            InstrumentationType.CMP,
+            InstrumentationType.DIV,
+            InstrumentationType.GEP,
+        ),
+        MockTraceDataFlowCallbacks::class.java.name.replace('.', '/'),
+    ).instrument(TraceDataFlowInstrumentationTarget::class.java.name.replace('.', '/'), originalBytecode)
     // Make the patched class available in bazel-testlogs/.../test.outputs for manual inspection.
     val outDir = System.getenv("TEST_UNDECLARED_OUTPUTS_DIR")
     File("$outDir/${TraceDataFlowInstrumentationTarget::class.simpleName}.class").writeBytes(originalBytecode)
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/instrumentor/ValidHookMocks.java b/src/test/java/com/code_intelligence/jazzer/instrumentor/ValidHookMocks.java
similarity index 100%
rename from agent/src/test/java/com/code_intelligence/jazzer/instrumentor/ValidHookMocks.java
rename to src/test/java/com/code_intelligence/jazzer/instrumentor/ValidHookMocks.java
diff --git a/src/test/java/com/code_intelligence/jazzer/junit/AutofuzzTest.java b/src/test/java/com/code_intelligence/jazzer/junit/AutofuzzTest.java
new file mode 100644
index 0000000..b9abd3f
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/junit/AutofuzzTest.java
@@ -0,0 +1,162 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.junit;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.Truth8.assertThat;
+import static org.junit.Assume.assumeFalse;
+import static org.junit.Assume.assumeTrue;
+import static org.junit.platform.engine.discovery.DiscoverySelectors.selectMethod;
+import static org.junit.platform.testkit.engine.EventConditions.abortedWithReason;
+import static org.junit.platform.testkit.engine.EventConditions.container;
+import static org.junit.platform.testkit.engine.EventConditions.displayName;
+import static org.junit.platform.testkit.engine.EventConditions.event;
+import static org.junit.platform.testkit.engine.EventConditions.finishedSuccessfully;
+import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure;
+import static org.junit.platform.testkit.engine.EventConditions.test;
+import static org.junit.platform.testkit.engine.EventConditions.type;
+import static org.junit.platform.testkit.engine.EventConditions.uniqueIdSubstrings;
+import static org.junit.platform.testkit.engine.EventType.DYNAMIC_TEST_REGISTERED;
+import static org.junit.platform.testkit.engine.EventType.FINISHED;
+import static org.junit.platform.testkit.engine.EventType.REPORTING_ENTRY_PUBLISHED;
+import static org.junit.platform.testkit.engine.EventType.STARTED;
+import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.platform.testkit.engine.EngineExecutionResults;
+import org.junit.platform.testkit.engine.EngineTestKit;
+import org.junit.rules.TemporaryFolder;
+import org.opentest4j.TestAbortedException;
+
+public class AutofuzzTest {
+  @Rule public TemporaryFolder temp = new TemporaryFolder();
+
+  @Test
+  public void fuzzingEnabled() throws IOException {
+    assumeFalse(System.getenv("JAZZER_FUZZ").isEmpty());
+
+    Path baseDir = temp.getRoot().toPath();
+    // Create a fake test resource directory structure to verify that Jazzer uses it and emits a
+    // crash file into it.
+    Path testResourceDir = baseDir.resolve("src").resolve("test").resolve("resources");
+    Files.createDirectories(testResourceDir);
+    Path inputsDirectory = testResourceDir.resolve("com")
+                               .resolve("example")
+                               .resolve("AutofuzzFuzzTestInputs")
+                               .resolve("autofuzz");
+
+    EngineExecutionResults results =
+        EngineTestKit.engine("junit-jupiter")
+            .selectors(selectMethod(
+                "com.example.AutofuzzFuzzTest#autofuzz(java.lang.String,com.example.AutofuzzFuzzTest$IntHolder)"))
+            .configurationParameter("jazzer.internal.basedir", baseDir.toAbsolutePath().toString())
+            .execute();
+
+    final String engine = "engine:junit-jupiter";
+    final String clazz = "class:com.example.AutofuzzFuzzTest";
+    final String autofuzz =
+        "test-template:autofuzz(java.lang.String, com.example.AutofuzzFuzzTest$IntHolder)";
+    final String invocation = "test-template-invocation:#";
+
+    results.containerEvents().assertEventsMatchExactly(event(type(STARTED), container(engine)),
+        event(type(STARTED), container(uniqueIdSubstrings(engine, clazz))),
+        event(type(STARTED), container(uniqueIdSubstrings(engine, clazz, autofuzz))),
+        event(type(FINISHED), container(uniqueIdSubstrings(engine, clazz, autofuzz)),
+            finishedSuccessfully()),
+        event(type(FINISHED), container(uniqueIdSubstrings(engine, clazz)), finishedSuccessfully()),
+        event(type(FINISHED), container(engine), finishedSuccessfully()));
+
+    results.testEvents().assertEventsMatchExactly(event(type(DYNAMIC_TEST_REGISTERED)),
+        event(type(STARTED)),
+        event(test(uniqueIdSubstrings(engine, clazz, autofuzz, invocation + 1)),
+            displayName("<empty input>"),
+            abortedWithReason(instanceOf(TestAbortedException.class))),
+        event(type(DYNAMIC_TEST_REGISTERED), test(uniqueIdSubstrings(engine, clazz, autofuzz))),
+        event(type(STARTED), test(uniqueIdSubstrings(engine, clazz, autofuzz, invocation + 2)),
+            displayName("Fuzzing...")),
+        event(type(FINISHED), test(uniqueIdSubstrings(engine, clazz, autofuzz, invocation + 2)),
+            displayName("Fuzzing..."), finishedWithFailure(instanceOf(RuntimeException.class))));
+
+    // Should crash on an input that contains "jazzer", with the crash emitted into the
+    // automatically created inputs directory.
+    Path crashingInput;
+    try (Stream<Path> crashFiles =
+             Files.list(inputsDirectory)
+                 .filter(path -> path.getFileName().toString().startsWith("crash-"))) {
+      List<Path> crashFilesList = crashFiles.collect(Collectors.toList());
+      assertWithMessage("Expected crashing input in " + baseDir).that(crashFilesList).hasSize(1);
+      crashingInput = crashFilesList.get(0);
+    }
+    assertThat(new String(Files.readAllBytes(crashingInput), StandardCharsets.UTF_8))
+        .contains("jazzer");
+
+    try (Stream<Path> seeds = Files.list(baseDir).filter(Files::isRegularFile)) {
+      assertThat(seeds).isEmpty();
+    }
+
+    // Verify that the engine created the generated corpus directory. Since the crash was not found
+    // on a seed, it should not be empty.
+    Path generatedCorpus =
+        baseDir.resolve(".cifuzz-corpus").resolve("com.example.AutofuzzFuzzTest");
+    assertThat(Files.isDirectory(generatedCorpus)).isTrue();
+    try (Stream<Path> entries = Files.list(generatedCorpus)) {
+      assertThat(entries).isNotEmpty();
+    }
+  }
+
+  @Test
+  public void fuzzingDisabled() {
+    assumeTrue(System.getenv("JAZZER_FUZZ").isEmpty());
+
+    EngineExecutionResults results =
+        EngineTestKit.engine("junit-jupiter")
+            .selectors(selectMethod(
+                "com.example.AutofuzzWithCorpusFuzzTest#autofuzzWithCorpus(java.lang.String,int)"))
+            .execute();
+
+    final String engine = "engine:junit-jupiter";
+    final String clazz = "class:com.example.AutofuzzWithCorpusFuzzTest";
+    final String autofuzzWithCorpus = "test-template:autofuzzWithCorpus(java.lang.String, int)";
+
+    results.containerEvents().assertEventsMatchExactly(event(type(STARTED), container(engine)),
+        event(type(STARTED), container(uniqueIdSubstrings(engine, clazz))),
+        event(type(STARTED), container(uniqueIdSubstrings(engine, clazz, autofuzzWithCorpus))),
+        // "No fuzzing has been performed..."
+        event(type(REPORTING_ENTRY_PUBLISHED),
+            container(uniqueIdSubstrings(engine, clazz, autofuzzWithCorpus))),
+        event(type(FINISHED), container(uniqueIdSubstrings(engine, clazz, autofuzzWithCorpus)),
+            finishedSuccessfully()),
+        event(type(FINISHED), container(uniqueIdSubstrings(engine, clazz)), finishedSuccessfully()),
+        event(type(FINISHED), container(engine), finishedSuccessfully()));
+
+    results.testEvents().assertEventsMatchExactly(event(type(DYNAMIC_TEST_REGISTERED)),
+        event(type(STARTED)),
+        event(test("autofuzzWithCorpus", "<empty input>"), finishedSuccessfully()),
+        event(type(DYNAMIC_TEST_REGISTERED)), event(type(STARTED)),
+        event(test("autofuzzWithCorpus", "crashing_input"),
+            finishedWithFailure(instanceOf(RuntimeException.class))));
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/junit/BUILD.bazel b/src/test/java/com/code_intelligence/jazzer/junit/BUILD.bazel
new file mode 100644
index 0000000..a9a5e2e
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/junit/BUILD.bazel
@@ -0,0 +1,321 @@
+load("@contrib_rules_jvm//java:defs.bzl", "JUNIT5_DEPS", "java_junit5_test")
+
+java_library(
+    name = "test-method",
+    srcs = ["TestMethod.java"],
+    visibility = ["//src/test/java/com/code_intelligence/jazzer/junit:__pkg__"],
+    deps = [
+        "@maven//:org_junit_platform_junit_platform_engine",
+    ],
+)
+
+java_junit5_test(
+    name = "UtilsTest",
+    size = "small",
+    srcs = ["UtilsTest.java"],
+    deps = JUNIT5_DEPS + [
+        "//src/main/java/com/code_intelligence/jazzer/junit:utils",
+        "@maven//:com_google_truth_extensions_truth_java8_extension",
+        "@maven//:com_google_truth_truth",
+        "@maven//:org_junit_jupiter_junit_jupiter_api",
+        "@maven//:org_junit_jupiter_junit_jupiter_params",
+    ],
+)
+
+java_test(
+    name = "RegressionTestTest",
+    srcs = ["RegressionTestTest.java"],
+    test_class = "com.code_intelligence.jazzer.junit.RegressionTestTest",
+    runtime_deps = [
+        "//examples/junit/src/test/java/com/example:ExampleFuzzTests_deploy.jar",
+        "@maven//:org_junit_jupiter_junit_jupiter_engine",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/api:hooks",
+        "@maven//:com_google_truth_extensions_truth_java8_extension",
+        "@maven//:com_google_truth_truth",
+        "@maven//:junit_junit",
+        "@maven//:org_junit_jupiter_junit_jupiter_api",
+        "@maven//:org_junit_platform_junit_platform_engine",
+        "@maven//:org_junit_platform_junit_platform_testkit",
+        "@maven//:org_opentest4j_opentest4j",
+    ],
+)
+
+[
+    java_test(
+        name = "FuzzingWithCrashTest" + JAZZER_FUZZ,
+        srcs = ["FuzzingWithCrashTest.java"],
+        env = {
+            "JAZZER_FUZZ": JAZZER_FUZZ,
+        },
+        test_class = "com.code_intelligence.jazzer.junit.FuzzingWithCrashTest",
+        runtime_deps = [
+            "//examples/junit/src/test/java/com/example:ExampleFuzzTests_deploy.jar",
+            "@maven//:org_junit_jupiter_junit_jupiter_engine",
+        ],
+        deps = [
+            "//src/main/java/com/code_intelligence/jazzer/api:hooks",
+            "//src/test/java/com/code_intelligence/jazzer/junit:test-method",
+            "@maven//:com_google_truth_extensions_truth_java8_extension",
+            "@maven//:com_google_truth_truth",
+            "@maven//:junit_junit",
+            "@maven//:org_assertj_assertj_core",
+            "@maven//:org_junit_platform_junit_platform_engine",
+            "@maven//:org_junit_platform_junit_platform_launcher",
+            "@maven//:org_junit_platform_junit_platform_testkit",
+            "@maven//:org_opentest4j_opentest4j",
+        ],
+    )
+    for JAZZER_FUZZ in [
+        "",
+        "_fuzzing",
+    ]
+]
+
+[
+    java_test(
+        name = "FuzzingWithoutCrashTest" + JAZZER_FUZZ,
+        srcs = ["FuzzingWithoutCrashTest.java"],
+        env = {
+            "JAZZER_FUZZ": JAZZER_FUZZ,
+        },
+        test_class = "com.code_intelligence.jazzer.junit.FuzzingWithoutCrashTest",
+        runtime_deps = [
+            "//examples/junit/src/test/java/com/example:ExampleFuzzTests_deploy.jar",
+            "@maven//:org_junit_jupiter_junit_jupiter_engine",
+        ],
+        deps = [
+            "//src/main/java/com/code_intelligence/jazzer/api:hooks",
+            "@maven//:com_google_truth_extensions_truth_java8_extension",
+            "@maven//:com_google_truth_truth",
+            "@maven//:junit_junit",
+            "@maven//:org_assertj_assertj_core",
+            "@maven//:org_junit_platform_junit_platform_engine",
+            "@maven//:org_junit_platform_junit_platform_testkit",
+            "@maven//:org_opentest4j_opentest4j",
+        ],
+    )
+    for JAZZER_FUZZ in [
+        "",
+        "_fuzzing",
+    ]
+]
+
+[
+    java_test(
+        name = "ValueProfileTest_" + str(JAZZER_VALUE_PROFILE),
+        srcs = ["ValueProfileTest.java"],
+        env = {
+            "JAZZER_FUZZ": "true",
+            "JAZZER_VALUE_PROFILE": str(JAZZER_VALUE_PROFILE),
+        },
+        # The test is both CPU-intensive and sensitive to timing, which causes it to be flaky on
+        # slow runners (particularly macOS on GitHub Actions). Since we need to distinguish the two
+        # test variants by whether they find a finding, we can't just increase the timeout without
+        # the risk to make the other variant flaky.
+        tags = ["exclusive"] if JAZZER_VALUE_PROFILE else [],
+        test_class = "com.code_intelligence.jazzer.junit.ValueProfileTest",
+        runtime_deps = [
+            "//examples/junit/src/test/java/com/example:ExampleFuzzTests_deploy.jar",
+            "@maven//:org_junit_jupiter_junit_jupiter_engine",
+        ],
+        deps = [
+            "//src/main/java/com/code_intelligence/jazzer/api:hooks",
+            "@maven//:com_google_truth_extensions_truth_java8_extension",
+            "@maven//:com_google_truth_truth",
+            "@maven//:junit_junit",
+            "@maven//:org_junit_platform_junit_platform_engine",
+            "@maven//:org_junit_platform_junit_platform_testkit",
+        ],
+    )
+    for JAZZER_VALUE_PROFILE in [
+        True,
+        False,
+    ]
+]
+
+[
+    java_test(
+        name = "DirectoryInputsTest" + JAZZER_FUZZ,
+        srcs = ["DirectoryInputsTest.java"],
+        args = [
+            # Add a test resource root containing the seed corpus directory in a Maven layout to
+            # the classpath rather than seeds in a resource directory packaged in a JAR, as
+            # would happen if we added the directory to java_test's resources.
+            "--main_advice_classpath=$(rootpath test_resources_root)",
+        ],
+        data = ["test_resources_root"],
+        env = {
+            "JAZZER_FUZZ": JAZZER_FUZZ,
+        },
+        test_class = "com.code_intelligence.jazzer.junit.DirectoryInputsTest",
+        runtime_deps = [
+            "//examples/junit/src/test/java/com/example:ExampleFuzzTests_deploy.jar",
+            "@maven//:org_junit_jupiter_junit_jupiter_engine",
+        ],
+        deps = [
+            "//src/main/java/com/code_intelligence/jazzer/api:hooks",
+            "@maven//:com_google_truth_extensions_truth_java8_extension",
+            "@maven//:com_google_truth_truth",
+            "@maven//:junit_junit",
+            "@maven//:org_junit_platform_junit_platform_engine",
+            "@maven//:org_junit_platform_junit_platform_testkit",
+        ],
+    )
+    for JAZZER_FUZZ in [
+        "",
+        "_fuzzing",
+    ]
+]
+
+[
+    java_test(
+        name = "CorpusDirectoryTest" + JAZZER_FUZZ,
+        srcs = ["CorpusDirectoryTest.java"],
+        args = [
+            # Add a test resource root containing the seed corpus directory in a Maven layout to
+            # the classpath rather than seeds in a resource directory packaged in a JAR, as
+            # would happen if we added the directory to java_test's resources.
+            "--main_advice_classpath=$(rootpath test_resources_root)",
+        ],
+        data = ["test_resources_root"],
+        env = {
+            "JAZZER_FUZZ": JAZZER_FUZZ,
+        },
+        test_class = "com.code_intelligence.jazzer.junit.CorpusDirectoryTest",
+        runtime_deps = [
+            "//examples/junit/src/test/java/com/example:ExampleFuzzTests_deploy.jar",
+            "@maven//:org_junit_jupiter_junit_jupiter_engine",
+        ],
+        deps = [
+            "//src/main/java/com/code_intelligence/jazzer/api:hooks",
+            "@maven//:com_google_truth_extensions_truth_java8_extension",
+            "@maven//:com_google_truth_truth",
+            "@maven//:junit_junit",
+            "@maven//:org_junit_platform_junit_platform_engine",
+            "@maven//:org_junit_platform_junit_platform_testkit",
+        ],
+    )
+    for JAZZER_FUZZ in [
+        "",
+        "_fuzzing",
+    ]
+]
+
+[
+    java_test(
+        name = "AutofuzzTest" + JAZZER_FUZZ,
+        srcs = ["AutofuzzTest.java"],
+        env = {
+            "JAZZER_FUZZ": JAZZER_FUZZ,
+        },
+        test_class = "com.code_intelligence.jazzer.junit.AutofuzzTest",
+        runtime_deps = [
+            "//examples/junit/src/test/java/com/example:ExampleFuzzTests_deploy.jar",
+            "@maven//:org_junit_jupiter_junit_jupiter_engine",
+        ],
+        deps = [
+            "//src/main/java/com/code_intelligence/jazzer/api:hooks",
+            "@maven//:com_google_truth_extensions_truth_java8_extension",
+            "@maven//:com_google_truth_truth",
+            "@maven//:junit_junit",
+            "@maven//:org_junit_platform_junit_platform_engine",
+            "@maven//:org_junit_platform_junit_platform_testkit",
+            "@maven//:org_opentest4j_opentest4j",
+        ],
+    )
+    for JAZZER_FUZZ in [
+        "",
+        "_fuzzing",
+    ]
+]
+
+[
+    java_test(
+        name = "LifecycleTest" + JAZZER_FUZZ,
+        srcs = ["LifecycleTest.java"],
+        env = {
+            "JAZZER_FUZZ": JAZZER_FUZZ,
+        },
+        test_class = "com.code_intelligence.jazzer.junit.LifecycleTest",
+        runtime_deps = [
+            "//examples/junit/src/test/java/com/example:ExampleFuzzTests_deploy.jar",
+            "@maven//:org_junit_jupiter_junit_jupiter_engine",
+        ],
+        deps = [
+            "//src/main/java/com/code_intelligence/jazzer/api:hooks",
+            "@maven//:junit_junit",
+            "@maven//:org_junit_platform_junit_platform_engine",
+            "@maven//:org_junit_platform_junit_platform_testkit",
+        ],
+    )
+    for JAZZER_FUZZ in [
+        "",
+        "_fuzzing",
+    ]
+]
+
+java_test(
+    name = "HermeticInstrumentationTest",
+    srcs = ["HermeticInstrumentationTest.java"],
+    test_class = "com.code_intelligence.jazzer.junit.HermeticInstrumentationTest",
+    runtime_deps = [
+        "//examples/junit/src/test/java/com/example:ExampleFuzzTests_deploy.jar",
+        "@maven//:org_junit_jupiter_junit_jupiter_engine",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/api:hooks",
+        "@maven//:com_google_truth_truth",
+        "@maven//:junit_junit",
+        "@maven//:org_junit_platform_junit_platform_engine",
+        "@maven//:org_junit_platform_junit_platform_testkit",
+    ],
+)
+
+java_test(
+    name = "FindingsBaseDirTest",
+    srcs = ["FindingsBaseDirTest.java"],
+    env = {
+        "JAZZER_FUZZ": "1",
+    },
+    test_class = "com.code_intelligence.jazzer.junit.FindingsBaseDirTest",
+    runtime_deps = [
+        "//examples/junit/src/test/java/com/example:ExampleFuzzTests_deploy.jar",
+        "@maven//:org_junit_jupiter_junit_jupiter_engine",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/api:hooks",
+        "@maven//:com_google_truth_extensions_truth_java8_extension",
+        "@maven//:com_google_truth_truth",
+        "@maven//:junit_junit",
+        "@maven//:org_junit_platform_junit_platform_engine",
+        "@maven//:org_junit_platform_junit_platform_testkit",
+    ],
+)
+
+[
+    java_test(
+        name = "MutatorTest" + JAZZER_FUZZ,
+        srcs = ["MutatorTest.java"],
+        env = {
+            "JAZZER_FUZZ": JAZZER_FUZZ,
+        },
+        test_class = "com.code_intelligence.jazzer.junit.MutatorTest",
+        runtime_deps = [
+            "//examples/junit/src/test/java/com/example:ExampleFuzzTests_deploy.jar",
+            "@maven//:org_junit_jupiter_junit_jupiter_engine",
+        ],
+        deps = [
+            "//src/main/java/com/code_intelligence/jazzer/api:hooks",
+            "@maven//:junit_junit",
+            "@maven//:org_assertj_assertj_core",
+            "@maven//:org_junit_platform_junit_platform_engine",
+            "@maven//:org_junit_platform_junit_platform_testkit",
+        ],
+    )
+    for JAZZER_FUZZ in [
+        "",
+        "_fuzzing",
+    ]
+]
diff --git a/src/test/java/com/code_intelligence/jazzer/junit/CorpusDirectoryTest.java b/src/test/java/com/code_intelligence/jazzer/junit/CorpusDirectoryTest.java
new file mode 100644
index 0000000..372718e
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/junit/CorpusDirectoryTest.java
@@ -0,0 +1,183 @@
+// Copyright 2023 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.junit;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static org.junit.Assume.assumeFalse;
+import static org.junit.Assume.assumeTrue;
+import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
+import static org.junit.platform.testkit.engine.EventConditions.container;
+import static org.junit.platform.testkit.engine.EventConditions.displayName;
+import static org.junit.platform.testkit.engine.EventConditions.event;
+import static org.junit.platform.testkit.engine.EventConditions.finishedSuccessfully;
+import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure;
+import static org.junit.platform.testkit.engine.EventConditions.test;
+import static org.junit.platform.testkit.engine.EventConditions.type;
+import static org.junit.platform.testkit.engine.EventConditions.uniqueIdSubstrings;
+import static org.junit.platform.testkit.engine.EventType.DYNAMIC_TEST_REGISTERED;
+import static org.junit.platform.testkit.engine.EventType.FINISHED;
+import static org.junit.platform.testkit.engine.EventType.REPORTING_ENTRY_PUBLISHED;
+import static org.junit.platform.testkit.engine.EventType.STARTED;
+import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf;
+
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.stream.Stream;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.platform.testkit.engine.EngineExecutionResults;
+import org.junit.platform.testkit.engine.EngineTestKit;
+import org.junit.rules.TemporaryFolder;
+
+public class CorpusDirectoryTest {
+  private static final String ENGINE = "engine:junit-jupiter";
+  private static final String CLAZZ = "class:com.example.CorpusDirectoryFuzzTest";
+  private static final String INPUTS_FUZZ =
+      "test-template:corpusDirectoryFuzz(com.code_intelligence.jazzer.api.FuzzedDataProvider)";
+  private static final String INVOCATION = "test-template-invocation:#";
+
+  @Rule public TemporaryFolder temp = new TemporaryFolder();
+  Path baseDir;
+
+  @Before
+  public void setup() {
+    baseDir = temp.getRoot().toPath();
+  }
+
+  @Test
+  public void fuzzingEnabled() throws IOException {
+    assumeFalse(System.getenv("JAZZER_FUZZ").isEmpty());
+
+    // Create a fake test resource directory structure with an inputs directory to verify that
+    // Jazzer uses it and emits a crash file into it.
+    Path artifactsDirectory = baseDir.resolve(Paths.get("src", "test", "resources", "com",
+        "example", "CorpusDirectoryFuzzTestInputs", "corpusDirectoryFuzz"));
+    Files.createDirectories(artifactsDirectory);
+
+    // An explicitly stated corpus directory should be used to save new corpus entries.
+    Path explicitGeneratedCorpus = baseDir.resolve(Paths.get("corpus"));
+    Files.createDirectories(explicitGeneratedCorpus);
+
+    // The default generated corpus directory should only be used if no explicit corpus directory
+    // is given.
+    Path defaultGeneratedCorpus = baseDir.resolve(
+        Paths.get(".cifuzz-corpus", "com.example.CorpusDirectoryFuzzTest", "corpusDirectoryFuzz"));
+
+    EngineExecutionResults results =
+        EngineTestKit.engine("junit-jupiter")
+            .selectors(selectClass("com.example.CorpusDirectoryFuzzTest"))
+            .configurationParameter("jazzer.internal.basedir", baseDir.toAbsolutePath().toString())
+            // Add corpus directory as initial libFuzzer parameter.
+            .configurationParameter("jazzer.internal.arg.0", "fake_test_argv0")
+            .configurationParameter(
+                "jazzer.internal.arg.1", explicitGeneratedCorpus.toAbsolutePath().toString())
+            .execute();
+
+    results.containerEvents().assertEventsMatchExactly(event(type(STARTED), container(ENGINE)),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ))),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ)),
+            finishedSuccessfully()),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ)), finishedSuccessfully()),
+        event(type(FINISHED), container(ENGINE), finishedSuccessfully()));
+
+    results.testEvents().assertEventsMatchExactly(
+        event(type(DYNAMIC_TEST_REGISTERED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ))),
+        event(type(STARTED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ, INVOCATION + 1))),
+        event(type(FINISHED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ, INVOCATION + 1)),
+            displayName("<empty input>"), finishedSuccessfully()),
+        event(type(DYNAMIC_TEST_REGISTERED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ))),
+        event(type(STARTED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ, INVOCATION + 2))),
+        event(type(FINISHED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ, INVOCATION + 2)),
+            displayName("seed"), finishedSuccessfully()),
+        event(type(DYNAMIC_TEST_REGISTERED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ))),
+        event(type(STARTED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ, INVOCATION + 3)),
+            displayName("Fuzzing...")),
+        event(type(FINISHED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ, INVOCATION + 3)),
+            displayName("Fuzzing..."),
+            finishedWithFailure(instanceOf(FuzzerSecurityIssueMedium.class))));
+
+    // Crash file should be emitted into the artifacts directory and not into corpus directory.
+    assertCrashFileExistsIn(artifactsDirectory);
+    assertNoCrashFileExistsIn(baseDir);
+    assertNoCrashFileExistsIn(explicitGeneratedCorpus);
+    assertNoCrashFileExistsIn(defaultGeneratedCorpus);
+
+    // Verify that corpus files are written to given corpus directory and not generated one.
+    assertThat(Files.list(explicitGeneratedCorpus)).isNotEmpty();
+    assertThat(Files.list(defaultGeneratedCorpus)).isEmpty();
+  }
+
+  @Test
+  public void fuzzingDisabled() throws IOException {
+    assumeTrue(System.getenv("JAZZER_FUZZ").isEmpty());
+
+    Path corpusDirectory = baseDir.resolve(Paths.get("corpus"));
+    Files.createDirectories(corpusDirectory);
+    Files.createFile(corpusDirectory.resolve("corpus_entry"));
+
+    EngineExecutionResults results =
+        EngineTestKit.engine("junit-jupiter")
+            .selectors(selectClass("com.example.CorpusDirectoryFuzzTest"))
+            .configurationParameter("jazzer.internal.basedir", baseDir.toAbsolutePath().toString())
+            // Add corpus directory as initial libFuzzer parameter.
+            .configurationParameter("jazzer.internal.arg.0", "fake_test_argv0")
+            .configurationParameter(
+                "jazzer.internal.arg.1", corpusDirectory.toAbsolutePath().toString())
+            .execute();
+
+    results.containerEvents().assertEventsMatchExactly(event(type(STARTED), container(ENGINE)),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ))),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ))),
+        event(type(REPORTING_ENTRY_PUBLISHED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ)), finishedSuccessfully()),
+        event(type(FINISHED), container(ENGINE), finishedSuccessfully()));
+
+    // Verify that corpus_entry is not picked up and corpus directory is ignored in regression mode.
+    results.testEvents().assertEventsMatchExactly(
+        event(type(DYNAMIC_TEST_REGISTERED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ))),
+        event(type(STARTED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ, INVOCATION + 1))),
+        event(type(FINISHED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ, INVOCATION + 1)),
+            displayName("<empty input>"), finishedSuccessfully()),
+        event(type(DYNAMIC_TEST_REGISTERED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ))),
+        event(type(STARTED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ, INVOCATION + 2))),
+        event(type(FINISHED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ, INVOCATION + 2)),
+            displayName("seed"), finishedSuccessfully()));
+  }
+
+  private static void assertCrashFileExistsIn(Path artifactsDirectory) throws IOException {
+    try (Stream<Path> crashFiles =
+             Files.list(artifactsDirectory)
+                 .filter(path -> path.getFileName().toString().startsWith("crash-"))) {
+      assertThat(crashFiles).isNotEmpty();
+    }
+  }
+
+  private static void assertNoCrashFileExistsIn(Path generatedCorpus) throws IOException {
+    try (Stream<Path> crashFiles =
+             Files.list(generatedCorpus)
+                 .filter(path -> path.getFileName().toString().startsWith("crash-"))) {
+      assertThat(crashFiles).isEmpty();
+    }
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/junit/DirectoryInputsTest.java b/src/test/java/com/code_intelligence/jazzer/junit/DirectoryInputsTest.java
new file mode 100644
index 0000000..7ef27a3
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/junit/DirectoryInputsTest.java
@@ -0,0 +1,162 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.junit;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static org.junit.Assume.assumeFalse;
+import static org.junit.Assume.assumeTrue;
+import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
+import static org.junit.platform.testkit.engine.EventConditions.container;
+import static org.junit.platform.testkit.engine.EventConditions.displayName;
+import static org.junit.platform.testkit.engine.EventConditions.event;
+import static org.junit.platform.testkit.engine.EventConditions.finishedSuccessfully;
+import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure;
+import static org.junit.platform.testkit.engine.EventConditions.test;
+import static org.junit.platform.testkit.engine.EventConditions.type;
+import static org.junit.platform.testkit.engine.EventConditions.uniqueIdSubstrings;
+import static org.junit.platform.testkit.engine.EventType.DYNAMIC_TEST_REGISTERED;
+import static org.junit.platform.testkit.engine.EventType.FINISHED;
+import static org.junit.platform.testkit.engine.EventType.REPORTING_ENTRY_PUBLISHED;
+import static org.junit.platform.testkit.engine.EventType.STARTED;
+import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf;
+
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.stream.Stream;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.platform.testkit.engine.EngineExecutionResults;
+import org.junit.platform.testkit.engine.EngineTestKit;
+import org.junit.rules.TemporaryFolder;
+
+public class DirectoryInputsTest {
+  private static final String ENGINE = "engine:junit-jupiter";
+  private static final String CLAZZ = "class:com.example.DirectoryInputsFuzzTest";
+  private static final String INPUTS_FUZZ =
+      "test-template:inputsFuzz(com.code_intelligence.jazzer.api.FuzzedDataProvider)";
+  private static final String INVOCATION = "test-template-invocation:#";
+
+  @Rule public TemporaryFolder temp = new TemporaryFolder();
+  Path baseDir;
+
+  @Before
+  public void setup() {
+    baseDir = temp.getRoot().toPath();
+  }
+
+  @Test
+  public void fuzzingEnabled() throws IOException {
+    assumeFalse(System.getenv("JAZZER_FUZZ").isEmpty());
+
+    // Create a fake test resource directory structure with an inputs directory to verify that
+    // Jazzer uses it and emits a crash file into it.
+    Path inputsDirectory = baseDir.resolve(Paths.get("src", "test", "resources", "com", "example",
+        "DirectoryInputsFuzzTestInputs", "inputsFuzz"));
+    Files.createDirectories(inputsDirectory);
+
+    EngineExecutionResults results =
+        EngineTestKit.engine("junit-jupiter")
+            .selectors(selectClass("com.example.DirectoryInputsFuzzTest"))
+            .configurationParameter("jazzer.internal.basedir", baseDir.toAbsolutePath().toString())
+            .execute();
+
+    results.containerEvents().assertEventsMatchExactly(event(type(STARTED), container(ENGINE)),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ))),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ)),
+            finishedSuccessfully()),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ)), finishedSuccessfully()),
+        event(type(FINISHED), container(ENGINE), finishedSuccessfully()));
+
+    results.testEvents().assertEventsMatchExactly(
+        event(type(DYNAMIC_TEST_REGISTERED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ))),
+        event(type(STARTED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ, INVOCATION + 1))),
+        event(type(FINISHED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ, INVOCATION + 1)),
+            displayName("<empty input>"), finishedSuccessfully()),
+        event(type(DYNAMIC_TEST_REGISTERED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ))),
+        event(type(STARTED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ, INVOCATION + 2))),
+        event(type(FINISHED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ, INVOCATION + 2)),
+            displayName("seed"), finishedWithFailure(instanceOf(FuzzerSecurityIssueMedium.class))),
+        event(type(DYNAMIC_TEST_REGISTERED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ))),
+        event(type(STARTED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ, INVOCATION + 3)),
+            displayName("Fuzzing...")),
+        event(type(FINISHED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ, INVOCATION + 3)),
+            displayName("Fuzzing..."),
+            finishedWithFailure(instanceOf(FuzzerSecurityIssueMedium.class))));
+
+    // Should crash on the exact input "directory" as provided by the seed, with the crash emitted
+    // into the seed corpus.
+    try (Stream<Path> crashFiles = Files.list(baseDir).filter(
+             path -> path.getFileName().toString().startsWith("crash-"))) {
+      assertThat(crashFiles).isEmpty();
+    }
+    try (Stream<Path> seeds = Files.list(inputsDirectory)) {
+      assertThat(seeds).containsExactly(
+          inputsDirectory.resolve("crash-8d392f56d616a516ceabb82ed8906418bce4647d"));
+    }
+    assertThat(Files.readAllBytes(
+                   inputsDirectory.resolve("crash-8d392f56d616a516ceabb82ed8906418bce4647d")))
+        .isEqualTo("directory".getBytes(StandardCharsets.UTF_8));
+
+    // Verify that the engine created the generated corpus directory. Since the crash was found on a
+    // seed, it should be empty.
+    Path generatedCorpus = baseDir.resolve(
+        Paths.get(".cifuzz-corpus", "com.example.DirectoryInputsFuzzTest", "inputsFuzz"));
+    assertThat(Files.isDirectory(generatedCorpus)).isTrue();
+    try (Stream<Path> entries = Files.list(generatedCorpus)) {
+      assertThat(entries).isEmpty();
+    }
+  }
+
+  @Test
+  public void fuzzingDisabled() {
+    assumeTrue(System.getenv("JAZZER_FUZZ").isEmpty());
+
+    EngineExecutionResults results =
+        EngineTestKit.engine("junit-jupiter")
+            .selectors(selectClass("com.example.DirectoryInputsFuzzTest"))
+            .execute();
+
+    results.containerEvents().assertEventsMatchExactly(event(type(STARTED), container(ENGINE)),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ))),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ))),
+        event(type(REPORTING_ENTRY_PUBLISHED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ)), finishedSuccessfully()),
+        event(type(FINISHED), container(ENGINE), finishedSuccessfully()));
+
+    results.testEvents().assertEventsMatchExactly(
+        event(type(DYNAMIC_TEST_REGISTERED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ))),
+        event(type(STARTED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ, INVOCATION + 1))),
+        event(type(FINISHED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ, INVOCATION + 1)),
+            displayName("<empty input>"), finishedSuccessfully()),
+        event(type(DYNAMIC_TEST_REGISTERED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ))),
+        event(type(STARTED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ, INVOCATION + 2))),
+        event(type(FINISHED), test(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ, INVOCATION + 2)),
+            displayName("seed"), finishedWithFailure(instanceOf(FuzzerSecurityIssueMedium.class))));
+
+    // Verify that the generated corpus directory hasn't been created.
+    Path generatedCorpus =
+        baseDir.resolve(Paths.get(".cifuzz-corpus", "com.example.DirectoryInputsFuzzTest"));
+    assertThat(Files.notExists(generatedCorpus)).isTrue();
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/junit/FindingsBaseDirTest.java b/src/test/java/com/code_intelligence/jazzer/junit/FindingsBaseDirTest.java
new file mode 100644
index 0000000..b310014
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/junit/FindingsBaseDirTest.java
@@ -0,0 +1,83 @@
+// Copyright 2023 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.junit;
+
+import static com.google.common.truth.Truth8.assertThat;
+import static org.junit.Assume.assumeFalse;
+import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
+import static org.junit.platform.testkit.engine.EventConditions.container;
+import static org.junit.platform.testkit.engine.EventConditions.event;
+import static org.junit.platform.testkit.engine.EventConditions.finishedSuccessfully;
+import static org.junit.platform.testkit.engine.EventConditions.type;
+import static org.junit.platform.testkit.engine.EventConditions.uniqueIdSubstrings;
+import static org.junit.platform.testkit.engine.EventType.FINISHED;
+import static org.junit.platform.testkit.engine.EventType.REPORTING_ENTRY_PUBLISHED;
+import static org.junit.platform.testkit.engine.EventType.STARTED;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.stream.Stream;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.platform.testkit.engine.EngineExecutionResults;
+import org.junit.platform.testkit.engine.EngineTestKit;
+import org.junit.rules.TemporaryFolder;
+
+public class FindingsBaseDirTest {
+  private static final String ENGINE = "engine:junit-jupiter";
+  private static final String CLAZZ = "class:com.example.ThrowingFuzzTest";
+  private static final String INPUTS_FUZZ =
+      "test-template:throwingFuzz(com.code_intelligence.jazzer.api.FuzzedDataProvider)";
+
+  @Rule public TemporaryFolder temp = new TemporaryFolder();
+
+  private Path baseDir;
+
+  @Before
+  public void setup() {
+    baseDir = temp.getRoot().toPath();
+  }
+
+  @Test
+  public void fuzzingEnabledNoFindingsDir() throws IOException {
+    assumeFalse(System.getenv("JAZZER_FUZZ").isEmpty());
+
+    EngineExecutionResults results =
+        EngineTestKit.engine("junit-jupiter")
+            .selectors(selectClass("com.example.ThrowingFuzzTest"))
+            .configurationParameter("jazzer.internal.basedir", baseDir.toAbsolutePath().toString())
+            .execute();
+
+    results.containerEvents().assertEventsMatchExactly(event(type(STARTED), container(ENGINE)),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ))),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ))),
+        // Warning because the inputs directory hasn't been found in the source tree.
+        event(type(REPORTING_ENTRY_PUBLISHED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ, INPUTS_FUZZ)),
+            finishedSuccessfully()),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ)), finishedSuccessfully()),
+        event(type(FINISHED), container(ENGINE), finishedSuccessfully()));
+
+    // Crash should be emitted into the base directory, as no findings dir available.
+    try (Stream<Path> baseDirFiles = Files.list(baseDir)) {
+      Stream<Path> crashFiles =
+          baseDirFiles.filter(f -> f.getFileName().toString().startsWith("crash-"));
+      assertThat(crashFiles).hasSize(1);
+    }
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/junit/FuzzingWithCrashTest.java b/src/test/java/com/code_intelligence/jazzer/junit/FuzzingWithCrashTest.java
new file mode 100644
index 0000000..5cc2d1c
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/junit/FuzzingWithCrashTest.java
@@ -0,0 +1,206 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.junit;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static org.junit.Assume.assumeFalse;
+import static org.junit.Assume.assumeTrue;
+import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
+import static org.junit.platform.testkit.engine.EventConditions.container;
+import static org.junit.platform.testkit.engine.EventConditions.displayName;
+import static org.junit.platform.testkit.engine.EventConditions.event;
+import static org.junit.platform.testkit.engine.EventConditions.finishedSuccessfully;
+import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure;
+import static org.junit.platform.testkit.engine.EventConditions.test;
+import static org.junit.platform.testkit.engine.EventConditions.type;
+import static org.junit.platform.testkit.engine.EventConditions.uniqueIdSubstrings;
+import static org.junit.platform.testkit.engine.EventType.DYNAMIC_TEST_REGISTERED;
+import static org.junit.platform.testkit.engine.EventType.FINISHED;
+import static org.junit.platform.testkit.engine.EventType.REPORTING_ENTRY_PUBLISHED;
+import static org.junit.platform.testkit.engine.EventType.SKIPPED;
+import static org.junit.platform.testkit.engine.EventType.STARTED;
+import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.stream.Stream;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.platform.launcher.TagFilter;
+import org.junit.platform.testkit.engine.EngineExecutionResults;
+import org.junit.platform.testkit.engine.EngineTestKit;
+import org.junit.rules.TemporaryFolder;
+import org.opentest4j.AssertionFailedError;
+
+public class FuzzingWithCrashTest {
+  private static final String CRASHING_SEED_NAME = "crashing_seed";
+  // Crashes ByteFuzzTest since 'b' % 2 == 0.
+  private static final byte[] CRASHING_SEED_CONTENT = new byte[] {'b', 'a', 'c'};
+  private static final String CRASHING_SEED_DIGEST = "5e4dec23c9afa48bd5bee3daa2a0ab66e147012b";
+  private static final String ENGINE = "engine:junit-jupiter";
+  private static final String INVOCATION = "test-template-invocation:#";
+
+  private static final String CLAZZ_NAME = "com.example.ValidFuzzTests";
+
+  private static final String CLAZZ = "class:" + CLAZZ_NAME;
+  private static final TestMethod BYTE_FUZZ = new TestMethod(CLAZZ_NAME, "byteFuzz([B)");
+  private static final TestMethod NO_CRASH_FUZZ = new TestMethod(CLAZZ_NAME, "noCrashFuzz([B)");
+  private static final TestMethod DATA_FUZZ =
+      new TestMethod(CLAZZ_NAME, "dataFuzz(com.code_intelligence.jazzer.api.FuzzedDataProvider)");
+
+  @Rule public TemporaryFolder temp = new TemporaryFolder();
+  Path baseDir;
+  Path inputsDirectory;
+
+  @Before
+  public void setup() throws IOException {
+    baseDir = temp.getRoot().toPath();
+    // Create a fake test resource directory structure with an inputs directory to verify that
+    // Jazzer uses it and emits a crash file into it.
+    inputsDirectory = baseDir.resolve(
+        Paths.get("src", "test", "resources", "com", "example", "ValidFuzzTestsInputs"));
+    // populate the same seed in all test directories
+    for (String method :
+        Arrays.asList(BYTE_FUZZ.getName(), NO_CRASH_FUZZ.getName(), DATA_FUZZ.getName())) {
+      Path methodInputsDirectory = inputsDirectory.resolve(method);
+      Files.createDirectories(methodInputsDirectory);
+      Files.write(methodInputsDirectory.resolve(CRASHING_SEED_NAME), CRASHING_SEED_CONTENT);
+    }
+  }
+
+  private EngineExecutionResults executeTests() {
+    return EngineTestKit.engine("junit-jupiter")
+        .selectors(selectClass("com.example.ValidFuzzTests"))
+        .filters(TagFilter.includeTags("jazzer"))
+        .configurationParameter(
+            "jazzer.instrument", "com.other.package.**,com.example.**,com.yet.another.package.*")
+        .configurationParameter("jazzer.internal.basedir", baseDir.toAbsolutePath().toString())
+        .execute();
+  }
+
+  @Test
+  public void fuzzingEnabled() throws IOException {
+    assumeFalse(System.getenv("JAZZER_FUZZ").isEmpty());
+
+    EngineExecutionResults results = executeTests();
+
+    results.containerEvents().assertEventsMatchExactly(event(type(STARTED), container(ENGINE)),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ))),
+        event(type(STARTED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, BYTE_FUZZ.getDescriptorId()))),
+        event(type(FINISHED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, BYTE_FUZZ.getDescriptorId())),
+            finishedSuccessfully()),
+        event(type(SKIPPED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, NO_CRASH_FUZZ.getDescriptorId()))),
+        event(type(SKIPPED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, DATA_FUZZ.getDescriptorId()))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ)), finishedSuccessfully()),
+        event(type(FINISHED), container(ENGINE), finishedSuccessfully()));
+
+    results.testEvents().assertEventsMatchLooselyInOrder(
+        event(type(DYNAMIC_TEST_REGISTERED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, BYTE_FUZZ.getDescriptorId()))),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, BYTE_FUZZ.getDescriptorId(), INVOCATION)),
+            displayName("Fuzzing...")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, BYTE_FUZZ.getDescriptorId(), INVOCATION)),
+            displayName("Fuzzing..."),
+            finishedWithFailure(instanceOf(AssertionFailedError.class))));
+
+    // Jazzer first tries the empty input, which doesn't crash the ByteFuzzTest. The second input is
+    // the seed we planted, which is crashing, so verify that a crash file with the same content is
+    // created in our fake seed corpus, but not in the current working directory.
+    try (Stream<Path> crashFiles = Files.list(baseDir).filter(
+             path -> path.getFileName().toString().startsWith("crash-"))) {
+      assertThat(crashFiles).isEmpty();
+    }
+
+    // the crashing input will be created in the directory for the fuzzed test, in this case
+    // byteFuzz and will not exist in the directories of the other tests
+    Path byteFuzzInputDirectory = inputsDirectory.resolve(BYTE_FUZZ.getName());
+    try (Stream<Path> seeds = Files.list(byteFuzzInputDirectory)) {
+      assertThat(seeds).containsExactly(
+          byteFuzzInputDirectory.resolve("crash-" + CRASHING_SEED_DIGEST),
+          byteFuzzInputDirectory.resolve(CRASHING_SEED_NAME));
+    }
+    assertThat(Files.readAllBytes(byteFuzzInputDirectory.resolve("crash-" + CRASHING_SEED_DIGEST)))
+        .isEqualTo(CRASHING_SEED_CONTENT);
+
+    // check that the others only include 1 file
+    for (String method : Arrays.asList(NO_CRASH_FUZZ.getName(), DATA_FUZZ.getName())) {
+      Path methodInputsDirectory = inputsDirectory.resolve(method);
+      try (Stream<Path> seeds = Files.list(methodInputsDirectory)) {
+        assertThat(seeds).containsExactly(methodInputsDirectory.resolve(CRASHING_SEED_NAME));
+      }
+    }
+
+    // Verify that the engine created the generated corpus directory. As a seed produced the crash,
+    // it should be empty.
+    Path generatedCorpus =
+        baseDir.resolve(Paths.get(".cifuzz-corpus", CLAZZ_NAME, BYTE_FUZZ.getName()));
+    assertThat(Files.isDirectory(generatedCorpus)).isTrue();
+    try (Stream<Path> entries = Files.list(generatedCorpus)) {
+      assertThat(entries).isEmpty();
+    }
+  }
+
+  @Test
+  public void fuzzingDisabled() throws IOException {
+    assumeTrue(System.getenv("JAZZER_FUZZ").isEmpty());
+
+    EngineExecutionResults results = executeTests();
+
+    results.containerEvents().assertEventsMatchExactly(event(type(STARTED), container(ENGINE)),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ))),
+        event(type(STARTED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, BYTE_FUZZ.getDescriptorId()))),
+        event(type(REPORTING_ENTRY_PUBLISHED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, BYTE_FUZZ.getDescriptorId()))),
+        event(type(FINISHED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, BYTE_FUZZ.getDescriptorId())),
+            finishedSuccessfully()),
+        event(type(STARTED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, NO_CRASH_FUZZ.getDescriptorId()))),
+        event(type(REPORTING_ENTRY_PUBLISHED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, NO_CRASH_FUZZ.getDescriptorId()))),
+        event(type(FINISHED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, NO_CRASH_FUZZ.getDescriptorId()))),
+        event(type(STARTED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, DATA_FUZZ.getDescriptorId()))),
+        event(type(REPORTING_ENTRY_PUBLISHED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, DATA_FUZZ.getDescriptorId()))),
+        event(type(FINISHED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, DATA_FUZZ.getDescriptorId()))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ)), finishedSuccessfully()),
+        event(type(FINISHED), container(ENGINE), finishedSuccessfully()));
+
+    // No fuzzing means no crashes means no new seeds.
+    // Check against all methods' input directories
+    for (String method :
+        Arrays.asList(BYTE_FUZZ.getName(), NO_CRASH_FUZZ.getName(), DATA_FUZZ.getName())) {
+      Path methodInputsDirectory = inputsDirectory.resolve(method);
+      try (Stream<Path> seeds = Files.list(methodInputsDirectory)) {
+        assertThat(seeds).containsExactly(methodInputsDirectory.resolve(CRASHING_SEED_NAME));
+      }
+    }
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/junit/FuzzingWithoutCrashTest.java b/src/test/java/com/code_intelligence/jazzer/junit/FuzzingWithoutCrashTest.java
new file mode 100644
index 0000000..01fe625
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/junit/FuzzingWithoutCrashTest.java
@@ -0,0 +1,149 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.junit;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static org.junit.Assume.assumeFalse;
+import static org.junit.Assume.assumeTrue;
+import static org.junit.platform.engine.discovery.DiscoverySelectors.selectMethod;
+import static org.junit.platform.testkit.engine.EventConditions.container;
+import static org.junit.platform.testkit.engine.EventConditions.displayName;
+import static org.junit.platform.testkit.engine.EventConditions.event;
+import static org.junit.platform.testkit.engine.EventConditions.finishedSuccessfully;
+import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure;
+import static org.junit.platform.testkit.engine.EventConditions.test;
+import static org.junit.platform.testkit.engine.EventConditions.type;
+import static org.junit.platform.testkit.engine.EventConditions.uniqueIdSubstrings;
+import static org.junit.platform.testkit.engine.EventType.DYNAMIC_TEST_REGISTERED;
+import static org.junit.platform.testkit.engine.EventType.FINISHED;
+import static org.junit.platform.testkit.engine.EventType.REPORTING_ENTRY_PUBLISHED;
+import static org.junit.platform.testkit.engine.EventType.SKIPPED;
+import static org.junit.platform.testkit.engine.EventType.STARTED;
+import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf;
+
+import com.google.common.truth.Truth8;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+import org.assertj.core.api.Condition;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.platform.testkit.engine.EngineExecutionResults;
+import org.junit.platform.testkit.engine.EngineTestKit;
+import org.junit.platform.testkit.engine.Event;
+import org.junit.platform.testkit.engine.EventType;
+import org.junit.platform.testkit.engine.Events;
+import org.junit.rules.TemporaryFolder;
+import org.opentest4j.AssertionFailedError;
+
+public class FuzzingWithoutCrashTest {
+  private static final String ENGINE = "engine:junit-jupiter";
+  private static final String CLAZZ = "class:com.example.ValidFuzzTests";
+  private static final String NO_CRASH_FUZZ = "test-template:noCrashFuzz([B)";
+  private static final String INVOCATION = "test-template-invocation:#";
+  @Rule public TemporaryFolder temp = new TemporaryFolder();
+  Path baseDir;
+
+  @Before
+  public void setup() {
+    baseDir = temp.getRoot().toPath();
+  }
+
+  private EngineExecutionResults executeTests() {
+    return EngineTestKit.engine("junit-jupiter")
+        .selectors(selectMethod("com.example.ValidFuzzTests#noCrashFuzz(byte[])"))
+        .configurationParameter(
+            "jazzer.instrument", "com.other.package.**,com.example.**,com.yet.another.package.*")
+        .configurationParameter("jazzer.internal.basedir", baseDir.toAbsolutePath().toString())
+        .execute();
+  }
+
+  @Test
+  public void fuzzingEnabled() throws IOException {
+    assumeFalse(System.getenv("JAZZER_FUZZ").isEmpty());
+
+    EngineExecutionResults results = executeTests();
+
+    results.containerEvents().assertEventsMatchExactly(event(type(STARTED), container(ENGINE)),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ))),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ, NO_CRASH_FUZZ))),
+        // Warning because the inputs directory hasn't been found in the source tree.
+        event(type(REPORTING_ENTRY_PUBLISHED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, NO_CRASH_FUZZ))),
+        // Warning because the inputs directory has been found on the classpath, but only in a JAR.
+        event(type(REPORTING_ENTRY_PUBLISHED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, NO_CRASH_FUZZ))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ, NO_CRASH_FUZZ)),
+            finishedSuccessfully()),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ)), finishedSuccessfully()),
+        event(type(FINISHED), container(ENGINE), finishedSuccessfully()));
+
+    results.testEvents().assertEventsMatchLooselyInOrder(
+        event(
+            type(DYNAMIC_TEST_REGISTERED), test(uniqueIdSubstrings(ENGINE, CLAZZ, NO_CRASH_FUZZ))),
+        event(type(STARTED), test(uniqueIdSubstrings(ENGINE, CLAZZ, NO_CRASH_FUZZ, INVOCATION)),
+            displayName("Fuzzing...")),
+        event(type(FINISHED), test(uniqueIdSubstrings(ENGINE, CLAZZ, NO_CRASH_FUZZ, INVOCATION)),
+            displayName("Fuzzing..."), finishedSuccessfully()));
+
+    // Verify that the engine created the generated corpus directory. As the fuzz test produces
+    // coverage (but no crash), it should not be empty.
+    Path generatedCorpus =
+        baseDir.resolve(Paths.get(".cifuzz-corpus", "com.example.ValidFuzzTests", "noCrashFuzz"));
+    assertThat(Files.isDirectory(generatedCorpus)).isTrue();
+    try (Stream<Path> entries = Files.list(generatedCorpus)) {
+      assertThat(entries).isNotEmpty();
+    }
+  }
+
+  @Test
+  public void fuzzingDisabled() {
+    assumeTrue(System.getenv("JAZZER_FUZZ").isEmpty());
+
+    EngineExecutionResults results = executeTests();
+
+    results.containerEvents().assertEventsMatchExactly(event(type(STARTED), container(ENGINE)),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ))),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ, NO_CRASH_FUZZ))),
+        event(type(REPORTING_ENTRY_PUBLISHED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, NO_CRASH_FUZZ))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ, NO_CRASH_FUZZ))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ)), finishedSuccessfully()),
+        event(type(FINISHED), container(ENGINE), finishedSuccessfully()));
+
+    results.testEvents().assertEventsMatchExactly(
+        IntStream.rangeClosed(1, 6)
+            .boxed()
+            .flatMap(i
+                -> Stream.of(event(type(DYNAMIC_TEST_REGISTERED),
+                                 test(uniqueIdSubstrings(ENGINE, CLAZZ, NO_CRASH_FUZZ))),
+                    event(type(STARTED),
+                        test(uniqueIdSubstrings(ENGINE, CLAZZ, NO_CRASH_FUZZ, INVOCATION + i))),
+                    event(type(FINISHED),
+                        test(uniqueIdSubstrings(ENGINE, CLAZZ, NO_CRASH_FUZZ, INVOCATION + i)),
+                        finishedSuccessfully())))
+            .toArray(Condition[] ::new));
+
+    // Verify that the generated corpus directory hasn't been created.
+    Path generatedCorpus =
+        baseDir.resolve(Paths.get(".cifuzz-corpus", "com.example.ValidFuzzTests", "noCrashFuzz"));
+    assertThat(Files.notExists(generatedCorpus)).isTrue();
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/junit/HermeticInstrumentationTest.java b/src/test/java/com/code_intelligence/jazzer/junit/HermeticInstrumentationTest.java
new file mode 100644
index 0000000..dabbf35
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/junit/HermeticInstrumentationTest.java
@@ -0,0 +1,108 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.junit;
+
+import static org.junit.Assume.assumeTrue;
+import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
+import static org.junit.platform.testkit.engine.EventConditions.container;
+import static org.junit.platform.testkit.engine.EventConditions.displayName;
+import static org.junit.platform.testkit.engine.EventConditions.event;
+import static org.junit.platform.testkit.engine.EventConditions.finishedSuccessfully;
+import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure;
+import static org.junit.platform.testkit.engine.EventConditions.test;
+import static org.junit.platform.testkit.engine.EventConditions.type;
+import static org.junit.platform.testkit.engine.EventConditions.uniqueIdSubstrings;
+import static org.junit.platform.testkit.engine.EventType.DYNAMIC_TEST_REGISTERED;
+import static org.junit.platform.testkit.engine.EventType.FINISHED;
+import static org.junit.platform.testkit.engine.EventType.REPORTING_ENTRY_PUBLISHED;
+import static org.junit.platform.testkit.engine.EventType.STARTED;
+import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf;
+
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow;
+import java.nio.file.Path;
+import java.util.regex.PatternSyntaxException;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.platform.testkit.engine.EngineExecutionResults;
+import org.junit.platform.testkit.engine.EngineTestKit;
+import org.junit.rules.TemporaryFolder;
+
+public class HermeticInstrumentationTest {
+  private static final String ENGINE = "engine:junit-jupiter";
+  private static final String CLAZZ = "class:com.example.HermeticInstrumentationFuzzTest";
+  private static final String FUZZ_TEST_1 = "test-template:fuzzTest1([B)";
+  private static final String FUZZ_TEST_2 = "test-template:fuzzTest2([B)";
+  private static final String UNIT_TEST_1 = "method:unitTest1()";
+  private static final String UNIT_TEST_2 = "method:unitTest2()";
+  private static final String INVOCATION = "test-template-invocation:#1";
+  @Rule public TemporaryFolder temp = new TemporaryFolder();
+  Path baseDir;
+
+  @Before
+  public void setup() {
+    baseDir = temp.getRoot().toPath();
+  }
+
+  private EngineExecutionResults executeTests() {
+    return EngineTestKit.engine("junit-jupiter")
+        .selectors(selectClass("com.example.HermeticInstrumentationFuzzTest"))
+        .configurationParameter(
+            "jazzer.instrument", "com.other.package.**,com.example.**,com.yet.another.package.*")
+        .configurationParameter("jazzer.internal.basedir", baseDir.toAbsolutePath().toString())
+        .configurationParameter("junit.jupiter.execution.parallel.enabled", "true")
+        .execute();
+  }
+
+  @Test
+  public void fuzzingDisabled() {
+    assumeTrue(System.getenv("JAZZER_FUZZ") == null);
+
+    EngineExecutionResults results = executeTests();
+
+    results.containerEvents().assertEventsMatchLoosely(event(type(STARTED), container(ENGINE)),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ))),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ, FUZZ_TEST_1))),
+        event(type(REPORTING_ENTRY_PUBLISHED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, FUZZ_TEST_1))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ, FUZZ_TEST_1))),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ, FUZZ_TEST_2))),
+        event(type(REPORTING_ENTRY_PUBLISHED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, FUZZ_TEST_2))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ, FUZZ_TEST_2))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ)), finishedSuccessfully()),
+        event(type(FINISHED), container(ENGINE), finishedSuccessfully()));
+
+    results.testEvents().assertEventsMatchLoosely(
+        event(type(DYNAMIC_TEST_REGISTERED), test(uniqueIdSubstrings(ENGINE, CLAZZ, FUZZ_TEST_1))),
+        event(type(STARTED), test(uniqueIdSubstrings(ENGINE, CLAZZ, FUZZ_TEST_1, INVOCATION)),
+            displayName("<empty input>")),
+        event(type(FINISHED), test(uniqueIdSubstrings(ENGINE, CLAZZ, FUZZ_TEST_1, INVOCATION)),
+            displayName("<empty input>"),
+            finishedWithFailure(instanceOf(FuzzerSecurityIssueLow.class))),
+        event(type(STARTED), test(uniqueIdSubstrings(ENGINE, CLAZZ, UNIT_TEST_1))),
+        event(type(FINISHED), test(uniqueIdSubstrings(ENGINE, CLAZZ, UNIT_TEST_1)),
+            finishedWithFailure(instanceOf(PatternSyntaxException.class))),
+        event(type(DYNAMIC_TEST_REGISTERED), test(uniqueIdSubstrings(ENGINE, CLAZZ, FUZZ_TEST_2))),
+        event(type(STARTED), test(uniqueIdSubstrings(ENGINE, CLAZZ, FUZZ_TEST_2, INVOCATION)),
+            displayName("<empty input>")),
+        event(type(FINISHED), test(uniqueIdSubstrings(ENGINE, CLAZZ, FUZZ_TEST_2, INVOCATION)),
+            displayName("<empty input>"),
+            finishedWithFailure(instanceOf(FuzzerSecurityIssueLow.class))),
+        event(type(STARTED), test(uniqueIdSubstrings(ENGINE, CLAZZ, UNIT_TEST_2))),
+        event(type(FINISHED), test(uniqueIdSubstrings(ENGINE, CLAZZ, UNIT_TEST_2)),
+            finishedWithFailure(instanceOf(PatternSyntaxException.class))));
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/junit/LifecycleTest.java b/src/test/java/com/code_intelligence/jazzer/junit/LifecycleTest.java
new file mode 100644
index 0000000..29dfc66
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/junit/LifecycleTest.java
@@ -0,0 +1,132 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.junit;
+
+import static org.junit.Assume.assumeFalse;
+import static org.junit.Assume.assumeTrue;
+import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
+import static org.junit.platform.testkit.engine.EventConditions.container;
+import static org.junit.platform.testkit.engine.EventConditions.displayName;
+import static org.junit.platform.testkit.engine.EventConditions.event;
+import static org.junit.platform.testkit.engine.EventConditions.finishedSuccessfully;
+import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure;
+import static org.junit.platform.testkit.engine.EventConditions.test;
+import static org.junit.platform.testkit.engine.EventConditions.type;
+import static org.junit.platform.testkit.engine.EventConditions.uniqueIdSubstrings;
+import static org.junit.platform.testkit.engine.EventType.DYNAMIC_TEST_REGISTERED;
+import static org.junit.platform.testkit.engine.EventType.FINISHED;
+import static org.junit.platform.testkit.engine.EventType.REPORTING_ENTRY_PUBLISHED;
+import static org.junit.platform.testkit.engine.EventType.SKIPPED;
+import static org.junit.platform.testkit.engine.EventType.STARTED;
+import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.platform.testkit.engine.EngineExecutionResults;
+import org.junit.platform.testkit.engine.EngineTestKit;
+import org.junit.rules.TemporaryFolder;
+
+public class LifecycleTest {
+  private static final String ENGINE = "engine:junit-jupiter";
+  private static final String CLAZZ = "class:com.example.LifecycleFuzzTest";
+  private static final String DISABLED_FUZZ = "test-template:disabledFuzz([B)";
+  private static final String LIFECYCLE_FUZZ = "test-template:lifecycleFuzz([B)";
+  private static final String INVOCATION = "test-template-invocation:#";
+  @Rule public TemporaryFolder temp = new TemporaryFolder();
+  Path baseDir;
+
+  @Before
+  public void setup() {
+    baseDir = temp.getRoot().toPath();
+  }
+
+  private EngineExecutionResults executeTests() {
+    return EngineTestKit.engine("junit-jupiter")
+        .selectors(selectClass("com.example.LifecycleFuzzTest"))
+        .configurationParameter(
+            "jazzer.instrument", "com.other.package.**,com.example.**,com.yet.another.package.*")
+        .configurationParameter("jazzer.internal.basedir", baseDir.toAbsolutePath().toString())
+        .execute();
+  }
+
+  @Test
+  public void fuzzingEnabled() {
+    assumeFalse(System.getenv("JAZZER_FUZZ").isEmpty());
+
+    EngineExecutionResults results = executeTests();
+
+    results.containerEvents().assertEventsMatchExactly(event(type(STARTED), container(ENGINE)),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ))),
+        event(type(SKIPPED), container(uniqueIdSubstrings(ENGINE, CLAZZ, DISABLED_FUZZ))),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ))),
+        // Warning because the seed corpus directory hasn't been found.
+        event(type(REPORTING_ENTRY_PUBLISHED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ)),
+            finishedSuccessfully()),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ)),
+            finishedWithFailure(instanceOf(IOException.class))),
+        event(type(FINISHED), container(ENGINE), finishedSuccessfully()));
+
+    results.testEvents().assertEventsMatchExactly(
+        event(
+            type(DYNAMIC_TEST_REGISTERED), test(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ))),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ, INVOCATION + 1)),
+            displayName("<empty input>")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ, INVOCATION + 1)),
+            displayName("<empty input>"), finishedSuccessfully()),
+        event(
+            type(DYNAMIC_TEST_REGISTERED), test(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ))),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ, INVOCATION + 2)),
+            displayName("Fuzzing...")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ, INVOCATION + 2)),
+            displayName("Fuzzing..."), finishedSuccessfully()));
+  }
+
+  @Test
+  public void fuzzingDisabled() {
+    assumeTrue(System.getenv("JAZZER_FUZZ").isEmpty());
+
+    EngineExecutionResults results = executeTests();
+
+    results.containerEvents().assertEventsMatchExactly(event(type(STARTED), container(ENGINE)),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ))),
+        event(type(SKIPPED), container(uniqueIdSubstrings(ENGINE, CLAZZ, DISABLED_FUZZ))),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ))),
+        event(type(REPORTING_ENTRY_PUBLISHED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ)),
+            finishedWithFailure(instanceOf(IOException.class))),
+        event(type(FINISHED), container(ENGINE), finishedSuccessfully()));
+
+    results.testEvents().assertEventsMatchExactly(
+        event(
+            type(DYNAMIC_TEST_REGISTERED), test(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ))),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ, INVOCATION + 1)),
+            displayName("<empty input>")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ, INVOCATION + 1)),
+            displayName("<empty input>"), finishedSuccessfully()));
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/junit/MutatorTest.java b/src/test/java/com/code_intelligence/jazzer/junit/MutatorTest.java
new file mode 100644
index 0000000..3fcc163
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/junit/MutatorTest.java
@@ -0,0 +1,165 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.junit;
+
+import static org.junit.Assume.assumeFalse;
+import static org.junit.Assume.assumeTrue;
+import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
+import static org.junit.platform.testkit.engine.EventConditions.container;
+import static org.junit.platform.testkit.engine.EventConditions.displayName;
+import static org.junit.platform.testkit.engine.EventConditions.event;
+import static org.junit.platform.testkit.engine.EventConditions.finishedSuccessfully;
+import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure;
+import static org.junit.platform.testkit.engine.EventConditions.reportEntry;
+import static org.junit.platform.testkit.engine.EventConditions.test;
+import static org.junit.platform.testkit.engine.EventConditions.type;
+import static org.junit.platform.testkit.engine.EventConditions.uniqueIdSubstrings;
+import static org.junit.platform.testkit.engine.EventType.DYNAMIC_TEST_REGISTERED;
+import static org.junit.platform.testkit.engine.EventType.FINISHED;
+import static org.junit.platform.testkit.engine.EventType.REPORTING_ENTRY_PUBLISHED;
+import static org.junit.platform.testkit.engine.EventType.STARTED;
+import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.assertj.core.api.Condition;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.platform.engine.reporting.ReportEntry;
+import org.junit.platform.testkit.engine.EngineExecutionResults;
+import org.junit.platform.testkit.engine.EngineTestKit;
+import org.junit.platform.testkit.engine.Event;
+import org.junit.rules.TemporaryFolder;
+
+public class MutatorTest {
+  private static final String ENGINE = "engine:junit-jupiter";
+  private static final String CLASS_NAME = "com.example.MutatorFuzzTest";
+  private static final String CLAZZ = "class:" + CLASS_NAME;
+  private static final String LIFECYCLE_FUZZ = "test-template:mutatorFuzz(java.util.List)";
+  private static final String INVOCATION = "test-template-invocation:#";
+  private static final String INVALID_SIGNATURE_ENTRY =
+      "Some files in the seed corpus do not match the fuzz target signature.\n"
+      + "This indicates that they were generated with a different signature and may cause issues reproducing previous findings.";
+
+  @Rule public TemporaryFolder temp = new TemporaryFolder();
+  private Path baseDir;
+
+  @Before
+  public void setup() throws IOException {
+    baseDir = temp.getRoot().toPath();
+    Path inputsDirectory = baseDir.resolve(Paths.get(
+        "src", "test", "resources", "com", "example", "MutatorFuzzTestInputs", "mutatorFuzz"));
+    Files.createDirectories(inputsDirectory);
+    Files.write(inputsDirectory.resolve("invalid"), "invalid input".getBytes());
+  }
+
+  private EngineExecutionResults executeTests() {
+    System.setProperty("jazzer.experimental_mutator", "true");
+    return EngineTestKit.engine("junit-jupiter")
+        .selectors(selectClass(CLASS_NAME))
+        .configurationParameter("jazzer.instrument", "com.example.**")
+        .configurationParameter("jazzer.internal.basedir", baseDir.toAbsolutePath().toString())
+        .execute();
+  }
+
+  @Test
+  public void fuzzingEnabled() {
+    assumeFalse(System.getenv("JAZZER_FUZZ").isEmpty());
+
+    EngineExecutionResults results = executeTests();
+
+    results.containerEvents().assertEventsMatchExactly(event(type(STARTED), container(ENGINE)),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ))),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ))),
+        // Invalid corpus input warning
+        event(type(REPORTING_ENTRY_PUBLISHED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ)),
+            new Condition<>(
+                Event.byPayload(ReportEntry.class,
+                    (it) -> it.getKeyValuePairs().values().contains(INVALID_SIGNATURE_ENTRY)),
+                "has invalid signature entry reporting entry")),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ)),
+            finishedSuccessfully()),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ)), finishedSuccessfully()),
+        event(type(FINISHED), container(ENGINE), finishedSuccessfully()));
+
+    results.testEvents().assertEventsMatchExactly(
+        event(type(DYNAMIC_TEST_REGISTERED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ, INVOCATION + 1))),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ, INVOCATION + 1)),
+            displayName("<empty input>")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ, INVOCATION + 1)),
+            displayName("<empty input>"), finishedSuccessfully()),
+        event(type(DYNAMIC_TEST_REGISTERED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ, INVOCATION + 2))),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ, INVOCATION + 2)),
+            displayName("invalid")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ, INVOCATION + 2)),
+            displayName("invalid"), finishedSuccessfully()),
+        event(
+            type(DYNAMIC_TEST_REGISTERED), test(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ))),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ, INVOCATION + 3)),
+            displayName("Fuzzing...")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ, INVOCATION + 3)),
+            displayName("Fuzzing..."), finishedWithFailure(instanceOf(AssertionError.class))));
+  }
+
+  @Test
+  public void fuzzingDisabled() {
+    assumeTrue(System.getenv("JAZZER_FUZZ").isEmpty());
+
+    EngineExecutionResults results = executeTests();
+
+    results.containerEvents().assertEventsMatchExactly(event(type(STARTED), container(ENGINE)),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ))),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ))),
+        // Deactivated fuzzing warning
+        event(type(REPORTING_ENTRY_PUBLISHED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ))),
+        // Invalid corpus input warning
+        event(type(REPORTING_ENTRY_PUBLISHED),
+            container(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ)), finishedSuccessfully()),
+        event(type(FINISHED), container(ENGINE), finishedSuccessfully()));
+
+    results.testEvents().assertEventsMatchExactly(
+        event(type(DYNAMIC_TEST_REGISTERED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ, INVOCATION + 1))),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ, INVOCATION + 1)),
+            displayName("<empty input>")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ, INVOCATION + 1)),
+            displayName("<empty input>"), finishedSuccessfully()),
+        event(type(DYNAMIC_TEST_REGISTERED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ, INVOCATION + 2))),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ, INVOCATION + 2)),
+            displayName("invalid")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, LIFECYCLE_FUZZ, INVOCATION + 2)),
+            displayName("invalid"), finishedSuccessfully()));
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/junit/RegressionTestTest.java b/src/test/java/com/code_intelligence/jazzer/junit/RegressionTestTest.java
new file mode 100644
index 0000000..008b8a4
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/junit/RegressionTestTest.java
@@ -0,0 +1,254 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.junit;
+
+import static com.google.common.truth.Truth8.assertThat;
+import static org.junit.Assume.assumeTrue;
+import static org.junit.platform.engine.discovery.DiscoverySelectors.selectPackage;
+import static org.junit.platform.testkit.engine.EventConditions.container;
+import static org.junit.platform.testkit.engine.EventConditions.displayName;
+import static org.junit.platform.testkit.engine.EventConditions.event;
+import static org.junit.platform.testkit.engine.EventConditions.finishedSuccessfully;
+import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure;
+import static org.junit.platform.testkit.engine.EventConditions.test;
+import static org.junit.platform.testkit.engine.EventConditions.type;
+import static org.junit.platform.testkit.engine.EventConditions.uniqueIdSubstrings;
+import static org.junit.platform.testkit.engine.EventType.DYNAMIC_TEST_REGISTERED;
+import static org.junit.platform.testkit.engine.EventType.FINISHED;
+import static org.junit.platform.testkit.engine.EventType.REPORTING_ENTRY_PUBLISHED;
+import static org.junit.platform.testkit.engine.EventType.STARTED;
+import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf;
+import static org.junit.platform.testkit.engine.TestExecutionResultConditions.message;
+
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueCritical;
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueHigh;
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow;
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium;
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import org.junit.Test;
+import org.junit.platform.testkit.engine.EngineExecutionResults;
+import org.junit.platform.testkit.engine.EngineTestKit;
+import org.opentest4j.AssertionFailedError;
+
+public class RegressionTestTest {
+  private static final String ENGINE = "engine:junit-jupiter";
+  private static final String BYTE_FUZZ_TEST = "class:com.example.ByteFuzzTest";
+  private static final String VALID_FUZZ_TESTS = "class:com.example.ValidFuzzTests";
+  private static final String INVALID_FUZZ_TESTS = "class:com.example.InvalidFuzzTests";
+  private static final String AUTOFUZZ_WITH_CORPUS_FUZZ_TEST =
+      "class:com.example.AutofuzzWithCorpusFuzzTest";
+  private static final String BYTE_FUZZ = "test-template:byteFuzz([B)";
+  private static final String NO_CRASH_FUZZ = "test-template:noCrashFuzz([B)";
+  private static final String DATA_FUZZ =
+      "test-template:dataFuzz(com.code_intelligence.jazzer.api.FuzzedDataProvider)";
+  private static final String INVALID_PARAMETER_COUNT_FUZZ =
+      "test-template:invalidParameterCountFuzz()";
+  private static final String AUTOFUZZ_WITH_CORPUS =
+      "test-template:autofuzzWithCorpus(java.lang.String, int)";
+  private static final String INVOCATION = "test-template-invocation:#";
+
+  private static EngineExecutionResults executeTests() {
+    return EngineTestKit.engine("junit-jupiter")
+        .selectors(selectPackage("com.example"))
+        .configurationParameter(
+            "jazzer.instrument", "com.other.package.**,com.example.**,com.yet.another.package.*")
+        .execute();
+  }
+
+  @Test
+  public void regressionTestEnabled() {
+    assumeTrue(System.getenv("JAZZER_FUZZ") == null);
+
+    // Record Jazzer's stderr.
+    PrintStream stderr = System.err;
+    ByteArrayOutputStream recordedStderr = new ByteArrayOutputStream();
+    System.setErr(new PrintStream(recordedStderr));
+
+    EngineExecutionResults results = executeTests();
+    System.setErr(stderr);
+
+    // Verify that Jazzer doesn't print any warning or errors.
+    String[] stderrLines =
+        new String(recordedStderr.toByteArray(), StandardCharsets.UTF_8).split("\n");
+    for (String line : stderrLines) {
+      System.err.println(line);
+    }
+    assertThat(Arrays.stream(stderrLines)
+                   .filter(line -> line.startsWith("WARN:") || line.startsWith("ERROR:")))
+        .isEmpty();
+
+    results.containerEvents().debug().assertEventsMatchLoosely(
+        event(type(STARTED), container(ENGINE)),
+        event(
+            type(STARTED), container(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS, NO_CRASH_FUZZ))),
+        event(type(REPORTING_ENTRY_PUBLISHED),
+            container(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS, NO_CRASH_FUZZ))),
+        event(type(FINISHED),
+            container(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS, NO_CRASH_FUZZ)),
+            finishedSuccessfully()),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS, DATA_FUZZ))),
+        event(type(REPORTING_ENTRY_PUBLISHED),
+            container(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS, DATA_FUZZ))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS, DATA_FUZZ)),
+            finishedSuccessfully()),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS)),
+            finishedSuccessfully()),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, AUTOFUZZ_WITH_CORPUS_FUZZ_TEST))),
+        event(type(STARTED),
+            container(
+                uniqueIdSubstrings(ENGINE, AUTOFUZZ_WITH_CORPUS_FUZZ_TEST, AUTOFUZZ_WITH_CORPUS))),
+        event(type(REPORTING_ENTRY_PUBLISHED),
+            container(
+                uniqueIdSubstrings(ENGINE, AUTOFUZZ_WITH_CORPUS_FUZZ_TEST, AUTOFUZZ_WITH_CORPUS))),
+        event(type(FINISHED),
+            container(
+                uniqueIdSubstrings(ENGINE, AUTOFUZZ_WITH_CORPUS_FUZZ_TEST, AUTOFUZZ_WITH_CORPUS)),
+            finishedSuccessfully()),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, AUTOFUZZ_WITH_CORPUS_FUZZ_TEST)),
+            finishedSuccessfully()),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, BYTE_FUZZ_TEST))),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, BYTE_FUZZ_TEST, BYTE_FUZZ))),
+        event(type(REPORTING_ENTRY_PUBLISHED),
+            container(uniqueIdSubstrings(ENGINE, BYTE_FUZZ_TEST, BYTE_FUZZ))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, BYTE_FUZZ_TEST, BYTE_FUZZ)),
+            finishedSuccessfully()),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, INVALID_FUZZ_TESTS))),
+        event(type(STARTED),
+            container(
+                uniqueIdSubstrings(ENGINE, INVALID_FUZZ_TESTS, INVALID_PARAMETER_COUNT_FUZZ))),
+        event(type(FINISHED),
+            container(uniqueIdSubstrings(ENGINE, INVALID_FUZZ_TESTS, INVALID_PARAMETER_COUNT_FUZZ)),
+            finishedWithFailure(instanceOf(IllegalArgumentException.class),
+                message("Methods annotated with @FuzzTest must take at least one parameter"))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, INVALID_FUZZ_TESTS)),
+            finishedSuccessfully()),
+        event(type(FINISHED), container(ENGINE), finishedSuccessfully()));
+
+    results.testEvents().debug().assertEventsMatchLoosely(
+        event(type(DYNAMIC_TEST_REGISTERED),
+            test(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS, DATA_FUZZ, INVOCATION)),
+            displayName("<empty input>")),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS, DATA_FUZZ, INVOCATION)),
+            displayName("<empty input>")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS, DATA_FUZZ, INVOCATION)),
+            displayName("<empty input>"),
+            finishedWithFailure(instanceOf(FuzzerSecurityIssueMedium.class))),
+        event(type(DYNAMIC_TEST_REGISTERED),
+            test(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS, DATA_FUZZ, INVOCATION)),
+            displayName("no_crash")),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS, DATA_FUZZ, INVOCATION)),
+            displayName("no_crash")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS, DATA_FUZZ, INVOCATION)),
+            displayName("no_crash"), finishedSuccessfully()),
+        event(type(DYNAMIC_TEST_REGISTERED),
+            test(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS, DATA_FUZZ, INVOCATION)),
+            displayName("assert")),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS, DATA_FUZZ, INVOCATION)),
+            displayName("assert")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS, DATA_FUZZ, INVOCATION)),
+            displayName("assert"), finishedWithFailure(instanceOf(AssertionFailedError.class))),
+        event(type(DYNAMIC_TEST_REGISTERED),
+            test(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS, DATA_FUZZ, INVOCATION)),
+            displayName("honeypot")),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS, DATA_FUZZ, INVOCATION)),
+            displayName("honeypot")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS, DATA_FUZZ, INVOCATION)),
+            displayName("honeypot"),
+            finishedWithFailure(instanceOf(FuzzerSecurityIssueHigh.class))),
+        event(type(DYNAMIC_TEST_REGISTERED),
+            test(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS, DATA_FUZZ, INVOCATION)),
+            displayName("sanitizer_internal_class")),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS, DATA_FUZZ, INVOCATION)),
+            displayName("sanitizer_internal_class")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS, DATA_FUZZ, INVOCATION)),
+            displayName("sanitizer_internal_class"),
+            finishedWithFailure(instanceOf(FuzzerSecurityIssueCritical.class))),
+        event(type(DYNAMIC_TEST_REGISTERED),
+            test(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS, DATA_FUZZ, INVOCATION)),
+            displayName("sanitizer_user_class")),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS, DATA_FUZZ, INVOCATION)),
+            displayName("sanitizer_user_class")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(ENGINE, VALID_FUZZ_TESTS, DATA_FUZZ, INVOCATION)),
+            displayName("sanitizer_user_class"),
+            finishedWithFailure(instanceOf(FuzzerSecurityIssueLow.class))),
+        event(type(DYNAMIC_TEST_REGISTERED),
+            test(uniqueIdSubstrings(ENGINE, BYTE_FUZZ_TEST, BYTE_FUZZ, INVOCATION)),
+            displayName("<empty input>")),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(ENGINE, BYTE_FUZZ_TEST, BYTE_FUZZ, INVOCATION)),
+            displayName("<empty input>")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(ENGINE, BYTE_FUZZ_TEST, BYTE_FUZZ, INVOCATION)),
+            displayName("<empty input>"), finishedSuccessfully()),
+        event(type(DYNAMIC_TEST_REGISTERED),
+            test(uniqueIdSubstrings(ENGINE, BYTE_FUZZ_TEST, BYTE_FUZZ, INVOCATION)),
+            displayName("succeeds")),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(ENGINE, BYTE_FUZZ_TEST, BYTE_FUZZ, INVOCATION)),
+            displayName("succeeds")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(ENGINE, BYTE_FUZZ_TEST, BYTE_FUZZ, INVOCATION)),
+            displayName("succeeds"), finishedSuccessfully()),
+        event(type(DYNAMIC_TEST_REGISTERED),
+            test(uniqueIdSubstrings(ENGINE, BYTE_FUZZ_TEST, BYTE_FUZZ, INVOCATION)),
+            displayName("fails")),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(ENGINE, BYTE_FUZZ_TEST, BYTE_FUZZ, INVOCATION)),
+            displayName("fails")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(ENGINE, BYTE_FUZZ_TEST, BYTE_FUZZ, INVOCATION)),
+            displayName("fails"), finishedWithFailure(instanceOf(AssertionFailedError.class))),
+        event(type(DYNAMIC_TEST_REGISTERED),
+            test(uniqueIdSubstrings(
+                ENGINE, AUTOFUZZ_WITH_CORPUS_FUZZ_TEST, AUTOFUZZ_WITH_CORPUS, INVOCATION)),
+            displayName("<empty input>")),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(
+                ENGINE, AUTOFUZZ_WITH_CORPUS_FUZZ_TEST, AUTOFUZZ_WITH_CORPUS, INVOCATION)),
+            displayName("<empty input>")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(
+                ENGINE, AUTOFUZZ_WITH_CORPUS_FUZZ_TEST, AUTOFUZZ_WITH_CORPUS, INVOCATION)),
+            displayName("<empty input>"), finishedSuccessfully()),
+        event(type(DYNAMIC_TEST_REGISTERED),
+            test(uniqueIdSubstrings(
+                ENGINE, AUTOFUZZ_WITH_CORPUS_FUZZ_TEST, AUTOFUZZ_WITH_CORPUS, INVOCATION)),
+            displayName("crashing_input")),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(
+                ENGINE, AUTOFUZZ_WITH_CORPUS_FUZZ_TEST, AUTOFUZZ_WITH_CORPUS, INVOCATION)),
+            displayName("crashing_input")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(
+                ENGINE, AUTOFUZZ_WITH_CORPUS_FUZZ_TEST, AUTOFUZZ_WITH_CORPUS, INVOCATION)),
+            displayName("crashing_input"),
+            finishedWithFailure(instanceOf(RuntimeException.class))));
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/junit/TestMethod.java b/src/test/java/com/code_intelligence/jazzer/junit/TestMethod.java
new file mode 100644
index 0000000..bb542cc
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/junit/TestMethod.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.junit;
+
+import static org.junit.platform.engine.discovery.DiscoverySelectors.selectMethod;
+
+import java.lang.reflect.Method;
+
+/**
+ * Small class that allows us to capture the methods that we're using as test data. We need similar
+ * but slightly different data at various points:
+ * 1. the method name with parameters for finding the method initially and for referring to it in
+ * JUnit
+ * 2. the method name without parameters for the findings directories
+ */
+public class TestMethod {
+  Method method;
+  String nameWithParams;
+
+  TestMethod(String className, String methodName) {
+    nameWithParams = methodName;
+    method = selectMethod(className + "#" + methodName).getJavaMethod();
+  }
+
+  /**
+   * Returns the {@link org.junit.platform.engine.TestDescriptor} ID for this method
+   */
+  String getDescriptorId() {
+    return "test-template:" + nameWithParams;
+  }
+
+  /**
+   * Returns just the name of the method without parameters
+   */
+  String getName() {
+    return method.getName();
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/junit/UtilsTest.java b/src/test/java/com/code_intelligence/jazzer/junit/UtilsTest.java
new file mode 100644
index 0000000..da4a734
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/junit/UtilsTest.java
@@ -0,0 +1,151 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.junit;
+
+import static com.code_intelligence.jazzer.junit.Utils.durationStringToSeconds;
+import static com.code_intelligence.jazzer.junit.Utils.getMarkedArguments;
+import static com.code_intelligence.jazzer.junit.Utils.getMarkedInstance;
+import static com.code_intelligence.jazzer.junit.Utils.isMarkedInstance;
+import static com.code_intelligence.jazzer.junit.Utils.isMarkedInvocation;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static java.nio.file.Files.createDirectories;
+import static java.nio.file.Files.createFile;
+import static java.util.Arrays.stream;
+import static java.util.Collections.singletonList;
+import static java.util.stream.Collectors.joining;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.reflect.Method;
+import java.nio.file.Path;
+import java.util.AbstractList;
+import java.util.AbstractMap;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.InvocationInterceptor;
+import org.junit.jupiter.api.extension.ReflectiveInvocationContext;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+public class UtilsTest implements InvocationInterceptor {
+  @TempDir Path temp;
+
+  @Test
+  void testDurationStringToSeconds() {
+    assertThat(durationStringToSeconds("1m")).isEqualTo(60);
+    assertThat(durationStringToSeconds("1min")).isEqualTo(60);
+    assertThat(durationStringToSeconds("1h")).isEqualTo(60 * 60);
+    assertThat(durationStringToSeconds("1h   2m 30s")).isEqualTo(60 * 60 + 2 * 60 + 30);
+    assertThat(durationStringToSeconds("1hr2min30sec")).isEqualTo(60 * 60 + 2 * 60 + 30);
+    assertThat(durationStringToSeconds("1h2m30s")).isEqualTo(60 * 60 + 2 * 60 + 30);
+  }
+
+  @ValueSource(classes = {int.class, Class.class, Object.class, String.class, HashMap.class,
+                   Map.class, int[].class, int[][].class, AbstractMap.class, AbstractList.class})
+  @ParameterizedTest
+  void
+  testMarkedInstances(Class<?> clazz) {
+    Object instance = getMarkedInstance(clazz);
+    if (clazz == int.class) {
+      assertThat(instance).isInstanceOf(Integer.class);
+    } else {
+      assertThat(instance).isInstanceOf(clazz);
+    }
+    assertThat(isMarkedInstance(instance)).isTrue();
+    assertThat(getMarkedInstance(clazz)).isSameInstanceAs(instance);
+  }
+
+  static Stream<Arguments> testWithMarkedNamedParametersSource() {
+    Method testMethod =
+        stream(UtilsTest.class.getDeclaredMethods())
+            .filter(method -> method.getName().equals("testWithMarkedNamedParameters"))
+            .findFirst()
+            .get();
+    return Stream.of(
+        arguments("foo", 0, new HashMap<>(), singletonList(5), UtilsTest.class, new int[] {1}),
+        getMarkedArguments(testMethod, "some name"),
+        arguments("baz", 1, new LinkedHashMap<>(), Arrays.asList(5, 7), String.class, new int[0]),
+        getMarkedArguments(testMethod, "some other name"));
+  }
+
+  @MethodSource("testWithMarkedNamedParametersSource")
+  @ExtendWith(UtilsTest.class)
+  @ParameterizedTest
+  void testWithMarkedNamedParameters(String str, int num, AbstractMap<String, String> map,
+      List<Integer> list, Class<?> clazz, int[] array) {}
+
+  boolean argumentsExpectedToBeMarked = false;
+
+  @Override
+  public void interceptTestTemplateMethod(Invocation<Void> invocation,
+      ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext)
+      throws Throwable {
+    assertThat(isMarkedInvocation(invocationContext)).isEqualTo(argumentsExpectedToBeMarked);
+    argumentsExpectedToBeMarked = !argumentsExpectedToBeMarked;
+    invocation.proceed();
+  }
+
+  @Test
+  public void testGetClassPathBasedInstrumentationFilter() throws IOException {
+    Path firstDir = createDirectories(temp.resolve("first_dir"));
+    Path orgExample = createDirectories(firstDir.resolve("org").resolve("example"));
+    createFile(orgExample.resolve("Application.class"));
+
+    Path nonExistentDir = temp.resolve("does not exist");
+
+    Path secondDir = createDirectories(temp.resolve("second").resolve("dir"));
+    createFile(secondDir.resolve("Root.class"));
+    Path comExampleProject =
+        createDirectories(secondDir.resolve("com").resolve("example").resolve("project"));
+    createFile(comExampleProject.resolve("Main.class"));
+    Path comExampleOtherProject =
+        createDirectories(secondDir.resolve("com").resolve("example").resolve("other_project"));
+    createFile(comExampleOtherProject.resolve("Lib.class"));
+
+    Path emptyDir = createDirectories(temp.resolve("some").resolve("empty").resolve("dir"));
+
+    Path firstJar = createFile(temp.resolve("first.jar"));
+    Path secondJar = createFile(temp.resolve("second.jar"));
+
+    assertThat(Utils.getClassPathBasedInstrumentationFilter(makeClassPath(
+                   firstDir, firstJar, nonExistentDir, secondDir, secondJar, emptyDir)))
+        .hasValue("*,com.example.other_project.**,com.example.project.**,org.example.**");
+  }
+
+  @Test
+  public void testGetClassPathBasedInstrumentationFilter_noDirs() throws IOException {
+    Path firstJar = createFile(temp.resolve("first.jar"));
+    Path secondJar = createFile(temp.resolve("second.jar"));
+
+    assertThat(Utils.getClassPathBasedInstrumentationFilter(makeClassPath(firstJar, secondJar)))
+        .isEmpty();
+  }
+
+  private static String makeClassPath(Path... paths) {
+    return Arrays.stream(paths).map(Path::toString).collect(joining(File.pathSeparator));
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/junit/ValueProfileTest.java b/src/test/java/com/code_intelligence/jazzer/junit/ValueProfileTest.java
new file mode 100644
index 0000000..a1cc21c
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/junit/ValueProfileTest.java
@@ -0,0 +1,204 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.junit;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static org.junit.Assume.assumeFalse;
+import static org.junit.Assume.assumeTrue;
+import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
+import static org.junit.platform.testkit.engine.EventConditions.container;
+import static org.junit.platform.testkit.engine.EventConditions.displayName;
+import static org.junit.platform.testkit.engine.EventConditions.event;
+import static org.junit.platform.testkit.engine.EventConditions.finishedSuccessfully;
+import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure;
+import static org.junit.platform.testkit.engine.EventConditions.test;
+import static org.junit.platform.testkit.engine.EventConditions.type;
+import static org.junit.platform.testkit.engine.EventConditions.uniqueIdSubstrings;
+import static org.junit.platform.testkit.engine.EventType.DYNAMIC_TEST_REGISTERED;
+import static org.junit.platform.testkit.engine.EventType.FINISHED;
+import static org.junit.platform.testkit.engine.EventType.SKIPPED;
+import static org.junit.platform.testkit.engine.EventType.STARTED;
+import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf;
+
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.stream.Stream;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.platform.testkit.engine.EngineExecutionResults;
+import org.junit.platform.testkit.engine.EngineTestKit;
+import org.junit.platform.testkit.engine.EventType;
+import org.junit.rules.TemporaryFolder;
+
+public class ValueProfileTest {
+  private static final boolean VALUE_PROFILE_ENABLED =
+      "True".equals(System.getenv("JAZZER_VALUE_PROFILE"));
+
+  private static final String ENGINE = "engine:junit-jupiter";
+  private static final String CLAZZ = "class:com.example.ValueProfileFuzzTest";
+  private static final String VALUE_PROFILE_FUZZ = "test-template:valueProfileFuzz([B)";
+  private static final String INVOCATION = "test-template-invocation:#";
+
+  @Rule public TemporaryFolder temp = new TemporaryFolder();
+  Path baseDir;
+  Path inputsDirectories;
+
+  @Before
+  public void setup() throws IOException {
+    baseDir = temp.getRoot().toPath();
+    // Create a fake test resource directory structure with an input directory to verify that
+    // Jazzer uses it and emits a crash file into it.
+    inputsDirectories = baseDir.resolve(Paths.get("src", "test", "resources", "com", "example",
+        "ValueProfileFuzzTestInputs", "valueProfileFuzz"));
+    Files.createDirectories(inputsDirectories);
+  }
+
+  private EngineExecutionResults executeTests() {
+    return EngineTestKit.engine("junit-jupiter")
+        .selectors(selectClass("com.example.ValueProfileFuzzTest"))
+        .configurationParameter(
+            "jazzer.instrument", "com.other.package.**,com.example.**,com.yet.another.package.*")
+        .configurationParameter("jazzer.valueprofile", System.getenv("JAZZER_VALUE_PROFILE"))
+        .configurationParameter("jazzer.internal.basedir", baseDir.toAbsolutePath().toString())
+        .execute();
+  }
+
+  @Test
+  public void valueProfileEnabled() throws IOException {
+    assumeTrue(VALUE_PROFILE_ENABLED);
+
+    EngineExecutionResults results = executeTests();
+
+    results.containerEvents().assertEventsMatchExactly(event(type(STARTED), container(ENGINE)),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ))),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ, VALUE_PROFILE_FUZZ))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ, VALUE_PROFILE_FUZZ)),
+            finishedSuccessfully()),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ)), finishedSuccessfully()),
+        event(type(FINISHED), container(ENGINE), finishedSuccessfully()));
+
+    results.testEvents().assertEventsMatchExactly(
+        event(type(DYNAMIC_TEST_REGISTERED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, VALUE_PROFILE_FUZZ))),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, VALUE_PROFILE_FUZZ, INVOCATION + 1)),
+            displayName("<empty input>")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, VALUE_PROFILE_FUZZ, INVOCATION + 1)),
+            displayName("<empty input>"), finishedSuccessfully()),
+        event(type(DYNAMIC_TEST_REGISTERED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, VALUE_PROFILE_FUZZ))),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, VALUE_PROFILE_FUZZ, INVOCATION + 2)),
+            displayName("empty_seed")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, VALUE_PROFILE_FUZZ, INVOCATION + 2)),
+            displayName("empty_seed"), finishedSuccessfully()),
+        event(type(DYNAMIC_TEST_REGISTERED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, VALUE_PROFILE_FUZZ))),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, VALUE_PROFILE_FUZZ, INVOCATION + 3)),
+            displayName("Fuzzing...")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, VALUE_PROFILE_FUZZ, INVOCATION + 3)),
+            displayName("Fuzzing..."),
+            finishedWithFailure(instanceOf(FuzzerSecurityIssueMedium.class))));
+
+    // Should crash on the exact input "Jazzer", with the crash emitted into the seed corpus.
+    try (Stream<Path> crashFiles = Files.list(baseDir).filter(
+             path -> path.getFileName().toString().startsWith("crash-"))) {
+      assertThat(crashFiles).isEmpty();
+    }
+    try (Stream<Path> seeds = Files.list(inputsDirectories)) {
+      assertThat(seeds).containsExactly(
+          inputsDirectories.resolve("crash-131db69c7fadc408fe5031079dad3a441df09aff"));
+    }
+    assertThat(Files.readAllBytes(
+                   inputsDirectories.resolve("crash-131db69c7fadc408fe5031079dad3a441df09aff")))
+        .isEqualTo("Jazzer".getBytes(StandardCharsets.UTF_8));
+
+    // Verify that the engine created the generated corpus directory and emitted inputs into it.
+    Path generatedCorpus =
+        baseDir.resolve(Paths.get(".cifuzz-corpus", "com.example.ValueProfileFuzzTest"));
+    assertThat(Files.isDirectory(generatedCorpus)).isTrue();
+    try (Stream<Path> entries = Files.list(generatedCorpus)) {
+      assertThat(entries).isNotEmpty();
+    }
+  }
+
+  @Test
+  public void valueProfileDisabled() throws IOException {
+    assumeFalse(VALUE_PROFILE_ENABLED);
+
+    EngineExecutionResults results = executeTests();
+
+    results.containerEvents().assertEventsMatchExactly(event(type(STARTED), container(ENGINE)),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ))),
+        event(type(STARTED), container(uniqueIdSubstrings(ENGINE, CLAZZ, VALUE_PROFILE_FUZZ))),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ, VALUE_PROFILE_FUZZ)),
+            finishedSuccessfully()),
+        event(type(FINISHED), container(uniqueIdSubstrings(ENGINE, CLAZZ)), finishedSuccessfully()),
+        event(type(FINISHED), container(ENGINE), finishedSuccessfully()));
+
+    results.testEvents().assertEventsMatchExactly(
+        event(type(DYNAMIC_TEST_REGISTERED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, VALUE_PROFILE_FUZZ))),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, VALUE_PROFILE_FUZZ, INVOCATION + 1)),
+            displayName("<empty input>")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, VALUE_PROFILE_FUZZ, INVOCATION + 1)),
+            displayName("<empty input>"), finishedSuccessfully()),
+        event(type(DYNAMIC_TEST_REGISTERED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, VALUE_PROFILE_FUZZ))),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, VALUE_PROFILE_FUZZ, INVOCATION + 2)),
+            displayName("empty_seed")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, VALUE_PROFILE_FUZZ, INVOCATION + 2)),
+            displayName("empty_seed"), finishedSuccessfully()),
+        event(type(DYNAMIC_TEST_REGISTERED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, VALUE_PROFILE_FUZZ))),
+        event(type(STARTED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, VALUE_PROFILE_FUZZ, INVOCATION + 3)),
+            displayName("Fuzzing...")),
+        event(type(FINISHED),
+            test(uniqueIdSubstrings(ENGINE, CLAZZ, VALUE_PROFILE_FUZZ, INVOCATION + 3)),
+            displayName("Fuzzing..."), finishedSuccessfully()));
+
+    // No crash means no crashing input is emitted anywhere.
+    try (Stream<Path> crashFiles = Files.list(baseDir).filter(
+             path -> path.getFileName().toString().startsWith("crash-"))) {
+      assertThat(crashFiles).isEmpty();
+    }
+    try (Stream<Path> seeds = Files.list(inputsDirectories)) {
+      assertThat(seeds).isEmpty();
+    }
+
+    // Verify that the engine created the generated corpus directory and emitted inputs into it.
+    Path generatedCorpus =
+        baseDir.resolve(Paths.get(".cifuzz-corpus", "com.example.ValueProfileFuzzTest"));
+    assertThat(Files.isDirectory(generatedCorpus)).isTrue();
+    try (Stream<Path> entries = Files.list(generatedCorpus)) {
+      assertThat(entries).isNotEmpty();
+    }
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/junit/test_resources_root/com/example/CorpusDirectoryFuzzTestInputs/corpusDirectoryFuzz/seed b/src/test/java/com/code_intelligence/jazzer/junit/test_resources_root/com/example/CorpusDirectoryFuzzTestInputs/corpusDirectoryFuzz/seed
new file mode 100644
index 0000000..e31de1f
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/junit/test_resources_root/com/example/CorpusDirectoryFuzzTestInputs/corpusDirectoryFuzz/seed
@@ -0,0 +1 @@
+seed
diff --git a/src/test/java/com/code_intelligence/jazzer/junit/test_resources_root/com/example/DirectoryInputsFuzzTestInputs/inputsFuzz/seed b/src/test/java/com/code_intelligence/jazzer/junit/test_resources_root/com/example/DirectoryInputsFuzzTestInputs/inputsFuzz/seed
new file mode 100644
index 0000000..6d0450c
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/junit/test_resources_root/com/example/DirectoryInputsFuzzTestInputs/inputsFuzz/seed
@@ -0,0 +1 @@
+directory
\ No newline at end of file
diff --git a/src/test/java/com/code_intelligence/jazzer/junit/test_resources_root/com/example/DirectoryInputsFuzzTestInputs/nested_dir/seed b/src/test/java/com/code_intelligence/jazzer/junit/test_resources_root/com/example/DirectoryInputsFuzzTestInputs/nested_dir/seed
new file mode 100644
index 0000000..6d0450c
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/junit/test_resources_root/com/example/DirectoryInputsFuzzTestInputs/nested_dir/seed
@@ -0,0 +1 @@
+directory
\ No newline at end of file
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/ArgumentsMutatorTest.java b/src/test/java/com/code_intelligence/jazzer/mutation/ArgumentsMutatorTest.java
new file mode 100644
index 0000000..9a5bafd
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/ArgumentsMutatorTest.java
@@ -0,0 +1,298 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation;
+
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.mockPseudoRandom;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static java.util.Collections.singletonList;
+
+import com.code_intelligence.jazzer.mutation.annotation.NotNull;
+import com.code_intelligence.jazzer.mutation.mutator.Mutators;
+import com.code_intelligence.jazzer.mutation.support.TestSupport.MockPseudoRandom;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.ResourceLock;
+
+@SuppressWarnings("OptionalGetWithoutIsPresent")
+class ArgumentsMutatorTest {
+  private static List<List<Boolean>> fuzzThisFunctionArgument1;
+  private static List<Boolean> fuzzThisFunctionArgument2;
+
+  public static void fuzzThisFunction(List<List<@NotNull Boolean>> list, List<Boolean> otherList) {
+    fuzzThisFunctionArgument1 = list;
+    fuzzThisFunctionArgument2 = otherList;
+  }
+
+  @Test
+  @ResourceLock(value = "fuzzThisFunction")
+  void testStaticMethod() throws Throwable {
+    Method method =
+        ArgumentsMutatorTest.class.getMethod("fuzzThisFunction", List.class, List.class);
+    Optional<ArgumentsMutator> maybeMutator =
+        ArgumentsMutator.forStaticMethod(Mutators.newFactory(), method);
+    assertThat(maybeMutator).isPresent();
+    ArgumentsMutator mutator = maybeMutator.get();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // outer list not null
+             false,
+             // outer list size 1
+             1,
+             // inner list not null
+             false,
+             // inner list size 1
+             1,
+             // boolean
+             true,
+             // outer list not null
+             false,
+             // outer list size 1
+             1,
+             // Boolean not null
+             false,
+             // boolean
+             false)) {
+      mutator.init(prng);
+    }
+
+    fuzzThisFunctionArgument1 = null;
+    fuzzThisFunctionArgument2 = null;
+    mutator.invoke(true);
+    assertThat(fuzzThisFunctionArgument1).containsExactly(singletonList(true));
+    assertThat(fuzzThisFunctionArgument2).containsExactly(false);
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate first argument
+             0,
+             // Nullable mutator
+             false,
+             // Action mutate in outer list
+             2,
+             // Mutate one element,
+             1,
+             // index to get to inner list
+             0,
+             // Nullable mutator
+             false,
+             // Action mutate inner list
+             2,
+             // Mutate one element,
+             1,
+             // index to get boolean value
+             0)) {
+      mutator.mutate(prng);
+    }
+
+    fuzzThisFunctionArgument1 = null;
+    fuzzThisFunctionArgument2 = null;
+    mutator.invoke(true);
+    assertThat(fuzzThisFunctionArgument1).containsExactly(singletonList(false));
+    assertThat(fuzzThisFunctionArgument2).containsExactly(false);
+
+    // Modify the arguments passed to the function.
+    fuzzThisFunctionArgument1.get(0).clear();
+    fuzzThisFunctionArgument2.clear();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate first argument
+             0,
+             // Nullable mutator
+             false,
+             // Action mutate in outer list
+             2,
+             // Mutate one element,
+             1,
+             // index to get to inner list
+             0,
+             // Nullable mutator
+             false,
+             // Action mutate inner list
+             2,
+             // Mutate one element,
+             1,
+             // index to get boolean value
+             0)) {
+      mutator.mutate(prng);
+    }
+
+    fuzzThisFunctionArgument1 = null;
+    fuzzThisFunctionArgument2 = null;
+    mutator.invoke(false);
+    assertThat(fuzzThisFunctionArgument1).containsExactly(singletonList(true));
+    assertThat(fuzzThisFunctionArgument2).containsExactly(false);
+  }
+
+  private List<List<Boolean>> mutableFuzzThisFunctionArgument1;
+  private List<Boolean> mutableFuzzThisFunctionArgument2;
+
+  public void mutableFuzzThisFunction(List<List<@NotNull Boolean>> list, List<Boolean> otherList) {
+    mutableFuzzThisFunctionArgument1 = list;
+    mutableFuzzThisFunctionArgument2 = otherList;
+  }
+
+  @Test
+  void testInstanceMethod() throws Throwable {
+    Method method =
+        ArgumentsMutatorTest.class.getMethod("mutableFuzzThisFunction", List.class, List.class);
+    Optional<ArgumentsMutator> maybeMutator =
+        ArgumentsMutator.forInstanceMethod(Mutators.newFactory(), this, method);
+    assertThat(maybeMutator).isPresent();
+    ArgumentsMutator mutator = maybeMutator.get();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // outer list not null
+             false,
+             // outer list size 1
+             1,
+             // inner list not null
+             false,
+             // inner list size 1
+             1,
+             // boolean
+             true,
+             // outer list not null
+             false,
+             // outer list size 1
+             1,
+             // Boolean not null
+             false,
+             // boolean
+             false)) {
+      mutator.init(prng);
+    }
+
+    mutableFuzzThisFunctionArgument1 = null;
+    mutableFuzzThisFunctionArgument2 = null;
+    mutator.invoke(true);
+    assertThat(mutableFuzzThisFunctionArgument1).containsExactly(singletonList(true));
+    assertThat(mutableFuzzThisFunctionArgument2).containsExactly(false);
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate first argument
+             0,
+             // Nullable mutator
+             false,
+             // Action mutate in outer list
+             2,
+             // Mutate one element,
+             1,
+             // index to get to inner list
+             0,
+             // Nullable mutator
+             false,
+             // Action mutate inner list
+             2,
+             // Mutate one element,
+             1,
+             // index to get boolean value
+             0)) {
+      mutator.mutate(prng);
+    }
+
+    mutableFuzzThisFunctionArgument1 = null;
+    mutableFuzzThisFunctionArgument2 = null;
+    mutator.invoke(true);
+    assertThat(mutableFuzzThisFunctionArgument1).containsExactly(singletonList(false));
+    assertThat(mutableFuzzThisFunctionArgument2).containsExactly(false);
+
+    // Modify the arguments passed to the function.
+    mutableFuzzThisFunctionArgument1.get(0).clear();
+    mutableFuzzThisFunctionArgument2.clear();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate first argument
+             0,
+             // Nullable mutator
+             false,
+             // Action mutate in outer list
+             2,
+             // Mutate one element,
+             1,
+             // index to get to inner list
+             0,
+             // Nullable mutator
+             false,
+             // Action mutate inner list
+             2,
+             // Mutate one element,
+             1,
+             // index to get boolean value
+             0)) {
+      mutator.mutate(prng);
+    }
+
+    mutableFuzzThisFunctionArgument1 = null;
+    mutableFuzzThisFunctionArgument2 = null;
+    mutator.invoke(false);
+    assertThat(mutableFuzzThisFunctionArgument1).containsExactly(singletonList(true));
+    assertThat(mutableFuzzThisFunctionArgument2).containsExactly(false);
+  }
+
+  @SuppressWarnings("unused")
+  public void crossOverFunction(List<Boolean> list) {}
+
+  @Test
+  @SuppressWarnings("unchecked")
+  void testCrossOver() throws Throwable {
+    Method method = ArgumentsMutatorTest.class.getMethod("crossOverFunction", List.class);
+    Optional<ArgumentsMutator> maybeMutator =
+        ArgumentsMutator.forInstanceMethod(Mutators.newFactory(), this, method);
+    assertThat(maybeMutator).isPresent();
+    ArgumentsMutator mutator = maybeMutator.get();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // list not null
+             false,
+             // list size 1
+             1,
+             // not null,
+             false,
+             // boolean
+             true)) {
+      mutator.init(prng);
+    }
+    ByteArrayOutputStream baos1 = new ByteArrayOutputStream();
+    mutator.write(baos1);
+    byte[] out1 = baos1.toByteArray();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // list not null
+             false,
+             // list size 1
+             1,
+             // not null
+             false,
+             // boolean
+             false)) {
+      mutator.init(prng);
+    }
+    ByteArrayOutputStream baos2 = new ByteArrayOutputStream();
+    mutator.write(baos2);
+    byte[] out2 = baos1.toByteArray();
+
+    mutator.crossOver(new ByteArrayInputStream(out1), new ByteArrayInputStream(out2), 12345);
+    Object[] arguments = mutator.getArguments();
+
+    assertThat(arguments).isNotEmpty();
+    assertThat((List<Boolean>) arguments[0]).isNotEmpty();
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/BUILD.bazel b/src/test/java/com/code_intelligence/jazzer/mutation/BUILD.bazel
new file mode 100644
index 0000000..9d39757
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/BUILD.bazel
@@ -0,0 +1,15 @@
+load("@contrib_rules_jvm//java:defs.bzl", "java_test_suite")
+
+java_test_suite(
+    name = "MutationTests",
+    size = "small",
+    srcs = glob(["*Test.java"]),
+    runner = "junit5",
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/mutation",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/annotation",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/api",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/mutator",
+        "//src/test/java/com/code_intelligence/jazzer/mutation/support:test_support",
+    ],
+)
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/combinator/BUILD.bazel b/src/test/java/com/code_intelligence/jazzer/mutation/combinator/BUILD.bazel
new file mode 100644
index 0000000..033c03b
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/combinator/BUILD.bazel
@@ -0,0 +1,14 @@
+load("@contrib_rules_jvm//java:defs.bzl", "java_test_suite")
+
+java_test_suite(
+    name = "CompositeTests",
+    size = "small",
+    srcs = glob(["*.java"]),
+    runner = "junit5",
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/mutation/api",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/combinator",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/support",
+        "//src/test/java/com/code_intelligence/jazzer/mutation/support:test_support",
+    ],
+)
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/combinator/MutatorCombinatorsTest.java b/src/test/java/com/code_intelligence/jazzer/mutation/combinator/MutatorCombinatorsTest.java
new file mode 100644
index 0000000..d0d06f2
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/combinator/MutatorCombinatorsTest.java
@@ -0,0 +1,526 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.combinator;
+
+import static com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators.assemble;
+import static com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators.combine;
+import static com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators.mutateProduct;
+import static com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators.mutateProperty;
+import static com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators.mutateSumInPlace;
+import static com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators.mutateThenMapToImmutable;
+import static com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators.mutateViaView;
+import static com.code_intelligence.jazzer.mutation.support.InputStreamSupport.infiniteZeros;
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.mockCrossOver;
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.mockCrossOverInPlace;
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.mockInitInPlace;
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.mockInitializer;
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.mockMutator;
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.mockPseudoRandom;
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.nullDataOutputStream;
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.Collections.singletonList;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import com.code_intelligence.jazzer.mutation.api.Debuggable;
+import com.code_intelligence.jazzer.mutation.api.InPlaceMutator;
+import com.code_intelligence.jazzer.mutation.api.PseudoRandom;
+import com.code_intelligence.jazzer.mutation.api.Serializer;
+import com.code_intelligence.jazzer.mutation.api.SerializingInPlaceMutator;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.code_intelligence.jazzer.mutation.support.TestSupport.MockPseudoRandom;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Predicate;
+import java.util.function.ToIntFunction;
+import org.junit.jupiter.api.Test;
+
+class MutatorCombinatorsTest {
+  @Test
+  void testMutateProperty() {
+    InPlaceMutator<Foo> mutator =
+        mutateProperty(Foo::getValue, mockMutator(21, value -> 2 * value), Foo::setValue);
+
+    assertThat(mutator.toString()).isEqualTo("Foo.Integer");
+
+    Foo foo = new Foo(0, singletonList(13));
+
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      mutator.initInPlace(foo, prng);
+    }
+    assertThat(foo.getValue()).isEqualTo(21);
+    assertThat(foo.getList()).containsExactly(13);
+
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      mutator.mutateInPlace(foo, prng);
+    }
+
+    assertThat(foo.getValue()).isEqualTo(42);
+    assertThat(foo.getList()).containsExactly(13);
+  }
+
+  @Test
+  void testCrossOverProperty() {
+    InPlaceMutator<Foo> mutator =
+        mutateProperty(Foo::getValue, mockCrossOver((a, b) -> 42), Foo::setValue);
+    Foo foo = new Foo(0);
+    Foo otherFoo = new Foo(1);
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // use foo value
+             0)) {
+      mutator.crossOverInPlace(foo, otherFoo, prng);
+      assertThat(foo.getValue()).isEqualTo(0);
+    }
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // use otherFoo value
+             1)) {
+      mutator.crossOverInPlace(foo, otherFoo, prng);
+      assertThat(foo.getValue()).isEqualTo(1);
+    }
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // use property type cross over
+             2)) {
+      mutator.crossOverInPlace(foo, otherFoo, prng);
+      assertThat(foo.getValue()).isEqualTo(42);
+    }
+  }
+
+  @Test
+  void testMutateViaView() {
+    InPlaceMutator<Foo> mutator = mutateViaView(Foo::getList, new InPlaceMutator<List<Integer>>() {
+      @Override
+      public void initInPlace(List<Integer> reference, PseudoRandom prng) {
+        reference.clear();
+        reference.add(21);
+      }
+
+      @Override
+      public void mutateInPlace(List<Integer> reference, PseudoRandom prng) {
+        reference.add(reference.get(reference.size() - 1) + 1);
+      }
+
+      @Override
+      public void crossOverInPlace(
+          List<Integer> reference, List<Integer> otherReference, PseudoRandom prng) {}
+
+      @Override
+      public String toDebugString(Predicate<Debuggable> isInCycle) {
+        return "List<Integer>";
+      }
+    });
+
+    assertThat(mutator.toString()).isEqualTo("Foo via List<Integer>");
+
+    Foo foo = new Foo(13, singletonList(13));
+
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      mutator.initInPlace(foo, prng);
+    }
+    assertThat(foo.getValue()).isEqualTo(13);
+    assertThat(foo.getList()).containsExactly(21);
+
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      mutator.mutateInPlace(foo, prng);
+    }
+
+    assertThat(foo.getValue()).isEqualTo(13);
+    assertThat(foo.getList()).containsExactly(21, 22);
+  }
+
+  @Test
+  void testCrossOverViaView() {
+    InPlaceMutator<Foo> mutator = mutateViaView(Foo::getList, mockCrossOverInPlace((a, b) -> {
+      a.clear();
+      a.add(42);
+    }));
+
+    Foo foo = new Foo(0, singletonList(0));
+    Foo otherFoo = new Foo(0, singletonList(1));
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      mutator.crossOverInPlace(foo, otherFoo, prng);
+      assertThat(foo.getList()).containsExactly(42);
+    }
+  }
+
+  @Test
+  void testMutateCombine() {
+    InPlaceMutator<Foo> valueMutator =
+        mutateProperty(Foo::getValue, mockMutator(21, value -> 2 * value), Foo::setValue);
+
+    InPlaceMutator<Foo> listMutator =
+        mutateViaView(Foo::getList, new InPlaceMutator<List<Integer>>() {
+          @Override
+          public void initInPlace(List<Integer> reference, PseudoRandom prng) {
+            reference.clear();
+            reference.add(21);
+          }
+
+          @Override
+          public void mutateInPlace(List<Integer> reference, PseudoRandom prng) {
+            reference.add(reference.get(reference.size() - 1) + 1);
+          }
+
+          @Override
+          public void crossOverInPlace(
+              List<Integer> reference, List<Integer> otherReference, PseudoRandom prng) {}
+
+          @Override
+          public String toDebugString(Predicate<Debuggable> isInCycle) {
+            return "List<Integer>";
+          }
+        });
+    InPlaceMutator<Foo> mutator = combine(valueMutator, listMutator);
+
+    assertThat(mutator.toString()).isEqualTo("{Foo.Integer, Foo via List<Integer>}");
+
+    Foo foo = new Foo(13, singletonList(13));
+
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      mutator.initInPlace(foo, prng);
+    }
+    assertThat(foo.getValue()).isEqualTo(21);
+    assertThat(foo.getList()).containsExactly(21);
+
+    try (MockPseudoRandom prng = mockPseudoRandom(/* use valueMutator */ 0)) {
+      mutator.mutateInPlace(foo, prng);
+    }
+    assertThat(foo.getValue()).isEqualTo(42);
+    assertThat(foo.getList()).containsExactly(21);
+
+    try (MockPseudoRandom prng = mockPseudoRandom(/* use listMutator */ 1)) {
+      mutator.mutateInPlace(foo, prng);
+    }
+    assertThat(foo.getValue()).isEqualTo(42);
+    assertThat(foo.getList()).containsExactly(21, 22);
+  }
+
+  @Test
+  void testCrossOverCombine() {
+    InPlaceMutator<Foo> valueMutator =
+        mutateProperty(Foo::getValue, mockCrossOver((a, b) -> 42), Foo::setValue);
+    InPlaceMutator<Foo> listMutator = mutateViaView(Foo::getList, mockCrossOverInPlace((a, b) -> {
+      a.clear();
+      a.add(42);
+    }));
+    InPlaceMutator<Foo> mutator = combine(valueMutator, listMutator);
+
+    Foo foo = new Foo(0, singletonList(0));
+    Foo fooOther = new Foo(1, singletonList(1));
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // call cross over in property mutator
+             2)) {
+      mutator.crossOverInPlace(foo, fooOther, prng);
+    }
+    assertThat(foo.getValue()).isEqualTo(42);
+    assertThat(foo.getList()).containsExactly(42);
+  }
+
+  @Test
+  void testCrossOverEmptyCombine() {
+    Foo foo = new Foo(0, singletonList(0));
+    Foo fooOther = new Foo(1, singletonList(1));
+    InPlaceMutator<Foo> emptyCombineMutator = combine();
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      emptyCombineMutator.crossOverInPlace(foo, fooOther, prng);
+    }
+    assertThat(foo.getValue()).isEqualTo(0);
+    assertThat(foo.getList()).containsExactly(0);
+  }
+
+  @Test
+  void testMutateAssemble() {
+    InPlaceMutator<Foo> valueMutator =
+        mutateProperty(Foo::getValue, mockMutator(21, value -> 2 * value), Foo::setValue);
+
+    InPlaceMutator<Foo> listMutator =
+        mutateViaView(Foo::getList, new InPlaceMutator<List<Integer>>() {
+          @Override
+          public void initInPlace(List<Integer> reference, PseudoRandom prng) {
+            reference.clear();
+            reference.add(21);
+          }
+
+          @Override
+          public void mutateInPlace(List<Integer> reference, PseudoRandom prng) {
+            reference.add(reference.get(reference.size() - 1) + 1);
+          }
+
+          @Override
+          public void crossOverInPlace(
+              List<Integer> reference, List<Integer> otherReference, PseudoRandom prng) {}
+
+          @Override
+          public String toDebugString(Predicate<Debuggable> isInCycle) {
+            return "List<Integer>";
+          }
+        });
+
+    SerializingInPlaceMutator<Foo> mutator =
+        assemble((m) -> {}, () -> new Foo(0, singletonList(0)), new Serializer<Foo>() {
+          @Override
+          public Foo read(DataInputStream in) {
+            return null;
+          }
+
+          @Override
+          public void write(Foo value, DataOutputStream out) {}
+
+          @Override
+          public Foo detach(Foo value) {
+            return null;
+          }
+        }, () -> combine(valueMutator, listMutator));
+
+    assertThat(mutator.toString()).isEqualTo("{Foo.Integer, Foo via List<Integer>}");
+
+    Foo foo = new Foo(13, singletonList(13));
+
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      mutator.initInPlace(foo, prng);
+    }
+    assertThat(foo.getValue()).isEqualTo(21);
+    assertThat(foo.getList()).containsExactly(21);
+
+    try (MockPseudoRandom prng = mockPseudoRandom(/* use valueMutator */ 0)) {
+      mutator.mutateInPlace(foo, prng);
+    }
+    assertThat(foo.getValue()).isEqualTo(42);
+    assertThat(foo.getList()).containsExactly(21);
+
+    try (MockPseudoRandom prng = mockPseudoRandom(/* use listMutator */ 1)) {
+      mutator.mutateInPlace(foo, prng);
+    }
+    assertThat(foo.getValue()).isEqualTo(42);
+    assertThat(foo.getList()).containsExactly(21, 22);
+  }
+
+  @Test
+  void testCrossOverAssemble() {
+    InPlaceMutator<Foo> valueMutator =
+        mutateProperty(Foo::getValue, mockCrossOver((a, b) -> 42), Foo::setValue);
+
+    InPlaceMutator<Foo> listMutator = mutateViaView(Foo::getList, mockCrossOverInPlace((a, b) -> {
+      a.clear();
+      a.add(42);
+    }));
+
+    SerializingInPlaceMutator<Foo> mutator =
+        assemble((m) -> {}, () -> new Foo(0, singletonList(0)), new Serializer<Foo>() {
+          @Override
+          public Foo read(DataInputStream in) {
+            return null;
+          }
+
+          @Override
+          public void write(Foo value, DataOutputStream out) {}
+
+          @Override
+          public Foo detach(Foo value) {
+            return null;
+          }
+        }, () -> combine(valueMutator, listMutator));
+
+    Foo foo = new Foo(0, singletonList(0));
+    Foo fooOther = new Foo(1, singletonList(1));
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // cross over in property mutator
+             2)) {
+      mutator.crossOverInPlace(foo, fooOther, prng);
+    }
+    assertThat(foo.getValue()).isEqualTo(42);
+    assertThat(foo.getList()).containsExactly(42);
+  }
+
+  @Test
+  void testMutateThenMapToImmutable() throws IOException {
+    SerializingMutator<char[]> charMutator =
+        mockMutator(new char[] {'H', 'e', 'l', 'l', 'o'}, chars -> {
+          for (int i = 0; i < chars.length; i++) {
+            chars[i] ^= (1 << 5);
+          }
+          chars[chars.length - 1]++;
+          return chars;
+        });
+    SerializingMutator<String> mutator =
+        mutateThenMapToImmutable(charMutator, String::new, String::toCharArray);
+
+    assertThat(mutator.toString()).isEqualTo("char[] -> String");
+
+    String value = mutator.read(new DataInputStream(infiniteZeros()));
+    assertThat(value).isEqualTo("Hello");
+
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      value = mutator.mutate(value, prng);
+    }
+    assertThat(value).isEqualTo("hELLP");
+
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      value = mutator.mutate(value, prng);
+    }
+    assertThat(value).isEqualTo("Hellq");
+
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      value = mutator.init(prng);
+    }
+    assertThat(value).isEqualTo("Hello");
+
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      value = mutator.mutate(value, prng);
+    }
+    assertThat(value).isEqualTo("hELLP");
+
+    final String capturedValue = value;
+    assertThrows(UnsupportedOperationException.class,
+        () -> mutator.write(capturedValue, nullDataOutputStream()));
+  }
+
+  @Test
+  void testCrossOverThenMapToImmutable() {
+    SerializingMutator<char[]> charMutator = mockCrossOver((a, b) -> {
+      assertThat(a).isEqualTo(new char[] {'H', 'e', 'l', 'l', 'o'});
+      assertThat(b).isEqualTo(new char[] {'W', 'o', 'r', 'l', 'd'});
+      return new char[] {'T', 'e', 's', 't', 'e', 'd'};
+    });
+    SerializingMutator<String> mutator =
+        mutateThenMapToImmutable(charMutator, String::new, String::toCharArray);
+
+    String crossedOver;
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      crossedOver = mutator.crossOver("Hello", "World", prng);
+    }
+    assertThat(crossedOver).isEqualTo("Tested");
+  }
+
+  @Test
+  void testCrossOverProduct() {
+    SerializingMutator<Boolean> mutator1 = mockCrossOver((a, b) -> true);
+    SerializingMutator<Integer> mutator2 = mockCrossOver((a, b) -> 42);
+    ProductMutator mutator = mutateProduct(mutator1, mutator2);
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // use first value in mutator1
+             0,
+             // use second value in mutator2
+             0)) {
+      Object[] crossedOver =
+          mutator.crossOver(new Object[] {false, 0}, new Object[] {true, 1}, prng);
+      assertThat(crossedOver).isEqualTo(new Object[] {false, 0});
+    }
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // use first value in mutator1
+             1,
+             // use second value in mutator2
+             1)) {
+      Object[] crossedOver =
+          mutator.crossOver(new Object[] {false, 0}, new Object[] {true, 1}, prng);
+      assertThat(crossedOver).isEqualTo(new Object[] {true, 1});
+    }
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // use cross over in mutator1
+             2,
+             // use cross over in mutator2
+             2)) {
+      Object[] crossedOver =
+          mutator.crossOver(new Object[] {false, 0}, new Object[] {true, 2}, prng);
+      assertThat(crossedOver).isEqualTo(new Object[] {true, 42});
+    }
+  }
+
+  @Test
+  void testCrossOverSumInPlaceSameType() {
+    ToIntFunction<List<Integer>> mutotarIndexFromValue = (r) -> 0;
+    InPlaceMutator<List<Integer>> mutator1 = mockCrossOverInPlace((a, b) -> { a.add(42); });
+    InPlaceMutator<List<Integer>> mutator2 = mockCrossOverInPlace((a, b) -> {});
+    InPlaceMutator<List<Integer>> mutator =
+        mutateSumInPlace(mutotarIndexFromValue, mutator1, mutator2);
+
+    List<Integer> a = new ArrayList<>();
+    List<Integer> b = new ArrayList<>();
+
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      mutator.crossOverInPlace(a, b, prng);
+    }
+    assertThat(a).containsExactly(42);
+  }
+
+  @Test
+  void testCrossOverSumInPlaceIndeterminate() {
+    InPlaceMutator<List<?>> mutator1 = mockCrossOverInPlace((a, b) -> {});
+    InPlaceMutator<List<?>> mutator2 = mockCrossOverInPlace((a, b) -> {});
+    ToIntFunction<List<?>> bothIndeterminate = (r) -> - 1;
+
+    InPlaceMutator<List<?>> mutator = mutateSumInPlace(bothIndeterminate, mutator1, mutator2);
+
+    List<Integer> a = new ArrayList<>();
+    a.add(42);
+    List<Integer> b = new ArrayList<>();
+
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      mutator.crossOverInPlace(a, b, prng);
+      assertThat(a).containsExactly(42);
+    }
+  }
+
+  @Test
+  void testCrossOverSumInPlaceFirstIndeterminate() {
+    List<Integer> reference = new ArrayList<>();
+    List<Integer> otherReference = new ArrayList<>();
+
+    InPlaceMutator<List<Integer>> mutator1 = mockCrossOverInPlace((a, b) -> {});
+    InPlaceMutator<List<Integer>> mutator2 = mockInitInPlace((l) -> { l.add(42); });
+    ToIntFunction<List<Integer>> firstIndeterminate = (r) -> r == reference ? -1 : 1;
+
+    InPlaceMutator<List<Integer>> mutator =
+        mutateSumInPlace(firstIndeterminate, mutator1, mutator2);
+
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      mutator.crossOverInPlace(reference, otherReference, prng);
+      assertThat(reference).containsExactly(42);
+    }
+  }
+
+  static class Foo {
+    private int value;
+    private final List<Integer> list;
+
+    public Foo(int value) {
+      this(value, new ArrayList<>());
+    }
+    public Foo(int value, List<Integer> list) {
+      this.value = value;
+      this.list = new ArrayList<>(list);
+    }
+
+    public List<Integer> getList() {
+      return list;
+    }
+
+    public int getValue() {
+      return value;
+    }
+
+    public void setValue(int value) {
+      this.value = value;
+    }
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/engine/BUILD.bazel b/src/test/java/com/code_intelligence/jazzer/mutation/engine/BUILD.bazel
new file mode 100644
index 0000000..9cb59de
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/engine/BUILD.bazel
@@ -0,0 +1,13 @@
+load("@contrib_rules_jvm//java:defs.bzl", "java_test_suite")
+
+java_test_suite(
+    name = "EngineTests",
+    size = "small",
+    srcs = glob(["*.java"]),
+    runner = "junit5",
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/mutation/engine",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/support",
+        "//src/test/java/com/code_intelligence/jazzer/mutation/support:test_support",
+    ],
+)
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/engine/SeededPseudoRandomTest.java b/src/test/java/com/code_intelligence/jazzer/mutation/engine/SeededPseudoRandomTest.java
new file mode 100644
index 0000000..38ab2eb
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/engine/SeededPseudoRandomTest.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.engine;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.collectingAndThen;
+import static java.util.stream.Collectors.counting;
+import static java.util.stream.Collectors.groupingBy;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+import com.google.common.truth.Correspondence;
+import java.util.Map;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+public class SeededPseudoRandomTest {
+  static Stream<Arguments> doubleClosedRange() {
+    return Stream.of(arguments(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, false),
+        arguments(Double.MAX_VALUE, Double.POSITIVE_INFINITY, false),
+        arguments(Double.NEGATIVE_INFINITY, -Double.MAX_VALUE, false),
+        arguments(-Double.MAX_VALUE, Double.MAX_VALUE, false),
+        arguments(-Double.MAX_VALUE, -Double.MAX_VALUE, false),
+        arguments(-Double.MAX_VALUE * 0.5, Double.MAX_VALUE * 0.5, false),
+        arguments(-Double.MAX_VALUE * 0.5, Math.nextUp(Double.MAX_VALUE * 0.5), false),
+        arguments(Double.MAX_VALUE, Double.MAX_VALUE, false),
+        arguments(-Double.MIN_VALUE, Double.MIN_VALUE, false),
+        arguments(-Double.MIN_VALUE, 0, false), arguments(0, Double.MIN_VALUE, false),
+        arguments(-Double.MAX_VALUE, 0, false), arguments(0, Double.MAX_VALUE, false),
+        arguments(1000.0, Double.MAX_VALUE, false), arguments(0, Double.POSITIVE_INFINITY, false),
+        arguments(1e200, Double.POSITIVE_INFINITY, false),
+        arguments(Double.NEGATIVE_INFINITY, -1e200, false), arguments(0.0, 1.0, false),
+        arguments(-1.0, 1.0, false), arguments(-1e300, 1e300, false),
+        arguments(0.0, 0.0 + Double.MIN_VALUE, false),
+        arguments(-Double.MAX_VALUE, -Double.MAX_VALUE + 1e292, false),
+        arguments(-Double.NaN, 0.0, true), arguments(0.0, Double.NaN, true),
+        arguments(Double.NaN, Double.NaN, true));
+  }
+
+  static Stream<Arguments> floatClosedRange() {
+    return Stream.of(arguments(Float.NEGATIVE_INFINITY, Float.POSITIVE_INFINITY, false),
+        arguments(Float.MAX_VALUE, Float.POSITIVE_INFINITY, false),
+        arguments(Float.NEGATIVE_INFINITY, -Float.MAX_VALUE, false),
+        arguments(-Float.MAX_VALUE, Float.MAX_VALUE, false),
+        arguments(-Float.MAX_VALUE, -Float.MAX_VALUE, false),
+        arguments(Float.MAX_VALUE, Float.MAX_VALUE, false),
+        arguments(-Float.MAX_VALUE / 2f, Float.MAX_VALUE / 2f, false),
+        arguments(-Float.MIN_VALUE, Float.MIN_VALUE, false), arguments(-Float.MIN_VALUE, 0f, false),
+        arguments(0f, Float.MIN_VALUE, false), arguments(-Float.MAX_VALUE, 0f, false),
+        arguments(0f, Float.MAX_VALUE, false), arguments(-Float.MAX_VALUE, -0f, false),
+        arguments(-0f, Float.MAX_VALUE, false), arguments(1000f, Float.MAX_VALUE, false),
+        arguments(0f, Float.POSITIVE_INFINITY, false),
+        arguments(1e38f, Float.POSITIVE_INFINITY, false),
+        arguments(Float.NEGATIVE_INFINITY, -1e38f, false), arguments(0f, 1f, false),
+        arguments(-1f, 1f, false), arguments(-1e38f, 1e38f, false),
+        arguments(0f, 0f + Float.MIN_VALUE, false),
+        arguments(-Float.MAX_VALUE, -Float.MAX_VALUE + 1e32f, false),
+        arguments(-Float.NaN, 0f, true), arguments(0f, Float.NaN, true),
+        arguments(Float.NaN, Float.NaN, true));
+  }
+
+  @ParameterizedTest
+  @MethodSource("doubleClosedRange")
+  void testDoubleForceInRange(double minValue, double maxValue, boolean throwsException) {
+    SeededPseudoRandom seededPseudoRandom = new SeededPseudoRandom(1337);
+    for (int i = 0; i < 1000; i++) {
+      if (throwsException) {
+        assertThrows(IllegalArgumentException.class,
+            ()
+                -> seededPseudoRandom.closedRange(minValue, maxValue),
+            "minValue: " + minValue + ", maxValue: " + maxValue);
+      } else {
+        double inClosedRange = seededPseudoRandom.closedRange(minValue, maxValue);
+        assertThat(inClosedRange).isAtLeast(minValue);
+        assertThat(inClosedRange).isAtMost(maxValue);
+        assertThat(inClosedRange).isFinite();
+      }
+    }
+  }
+
+  @ParameterizedTest
+  @MethodSource("floatClosedRange")
+  void testFloatForceInRange(float minValue, float maxValue, boolean throwsException) {
+    SeededPseudoRandom seededPseudoRandom = new SeededPseudoRandom(1337);
+    for (int i = 0; i < 1000; i++) {
+      if (throwsException) {
+        assertThrows(IllegalArgumentException.class,
+            ()
+                -> seededPseudoRandom.closedRange(minValue, maxValue),
+            "minValue: " + minValue + ", maxValue: " + maxValue);
+      } else {
+        float inClosedRange = seededPseudoRandom.closedRange(minValue, maxValue);
+        assertThat(inClosedRange).isAtLeast(minValue);
+        assertThat(inClosedRange).isAtMost(maxValue);
+        assertThat(inClosedRange).isFinite();
+      }
+    }
+  }
+
+  @Test
+  void testClosedRangeBiasedTowardsSmall() {
+    SeededPseudoRandom prng = new SeededPseudoRandom(1337133371337L);
+
+    assertThrows(IllegalArgumentException.class, () -> prng.closedRangeBiasedTowardsSmall(-1));
+    assertThrows(IllegalArgumentException.class, () -> prng.closedRangeBiasedTowardsSmall(2, 1));
+    assertThat(prng.closedRangeBiasedTowardsSmall(0)).isEqualTo(0);
+    assertThat(prng.closedRangeBiasedTowardsSmall(5, 5)).isEqualTo(5);
+  }
+
+  @Test
+  void testClosedRangeBiasedTowardsSmall_distribution() {
+    int num = 5000000;
+    SeededPseudoRandom prng = new SeededPseudoRandom(1337133371337L);
+    Map<Integer, Double> frequencies =
+        Stream.generate(() -> prng.closedRangeBiasedTowardsSmall(9))
+            .limit(num)
+            .collect(
+                groupingBy(i -> i, collectingAndThen(counting(), count -> ((double) count) / num)));
+    // Reference values obtained from
+    // https://www.wolframalpha.com/input?i=N%5BTable%5BPDF%5BZipfDistribution%5B10%2C+1%5D%2C+i%5D%2C+%7Bi%2C+1%2C+10%7D%5D%5D
+    assertThat(frequencies)
+        .comparingValuesUsing(Correspondence.tolerance(0.0005))
+        .containsExactly(0, 0.645, 1, 0.161, 2, 0.072, 3, 0.040, 4, 0.026, 5, 0.018, 6, 0.013, 7,
+            0.01, 8, 0.008, 9, 0.006);
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/mutator/BUILD.bazel b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/BUILD.bazel
new file mode 100644
index 0000000..2694335
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/BUILD.bazel
@@ -0,0 +1,28 @@
+load("@contrib_rules_jvm//java:defs.bzl", "java_junit5_test")
+
+TEST_PARALLELISM = 4
+
+java_junit5_test(
+    name = "StressTest",
+    size = "large",
+    srcs = ["StressTest.java"],
+    env = {"JAZZER_MOCK_LIBFUZZER_MUTATOR": "true"},
+    jvm_flags = [
+        "-Djunit.jupiter.execution.parallel.enabled=true",
+        "-Djunit.jupiter.execution.parallel.mode.default=concurrent",
+        "-Djunit.jupiter.execution.parallel.config.strategy=fixed",
+        "-Djunit.jupiter.execution.parallel.config.fixed.parallelism=" + str(TEST_PARALLELISM),
+    ],
+    tags = ["cpu:" + str(TEST_PARALLELISM)],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/mutation/annotation",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/annotation/proto",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/api",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/mutator",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/support",
+        "//src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto:proto2_java_proto",
+        "//src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto:proto3_java_proto",
+        "//src/test/java/com/code_intelligence/jazzer/mutation/support:test_support",
+        "@com_google_protobuf_protobuf_java//jar",
+    ],
+)
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/mutator/StressTest.java b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/StressTest.java
new file mode 100644
index 0000000..3bf880a
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/StressTest.java
@@ -0,0 +1,588 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator;
+
+import static com.code_intelligence.jazzer.mutation.mutator.Mutators.validateAnnotationUsage;
+import static com.code_intelligence.jazzer.mutation.support.InputStreamSupport.extendWithZeros;
+import static com.code_intelligence.jazzer.mutation.support.Preconditions.require;
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.anyPseudoRandom;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.asAnnotatedType;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static java.lang.Math.floor;
+import static java.lang.Math.pow;
+import static java.lang.Math.sqrt;
+import static java.util.Collections.emptyList;
+import static java.util.Collections.singletonList;
+import static java.util.stream.IntStream.rangeClosed;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+import com.code_intelligence.jazzer.mutation.annotation.DoubleInRange;
+import com.code_intelligence.jazzer.mutation.annotation.FloatInRange;
+import com.code_intelligence.jazzer.mutation.annotation.InRange;
+import com.code_intelligence.jazzer.mutation.annotation.NotNull;
+import com.code_intelligence.jazzer.mutation.annotation.WithSize;
+import com.code_intelligence.jazzer.mutation.annotation.proto.AnySource;
+import com.code_intelligence.jazzer.mutation.annotation.proto.WithDefaultInstance;
+import com.code_intelligence.jazzer.mutation.api.PseudoRandom;
+import com.code_intelligence.jazzer.mutation.api.Serializer;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.code_intelligence.jazzer.mutation.support.TypeHolder;
+import com.code_intelligence.jazzer.protobuf.Proto2.TestProtobuf;
+import com.code_intelligence.jazzer.protobuf.Proto3.AnyField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.BytesField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.DoubleField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.EnumField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.EnumField3.TestEnum;
+import com.code_intelligence.jazzer.protobuf.Proto3.EnumFieldRepeated3;
+import com.code_intelligence.jazzer.protobuf.Proto3.EnumFieldRepeated3.TestEnumRepeated;
+import com.code_intelligence.jazzer.protobuf.Proto3.FloatField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.IntegralField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.MapField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.MessageField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.MessageMapField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.OptionalPrimitiveField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.PrimitiveField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.RepeatedDoubleField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.RepeatedFloatField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.RepeatedIntegralField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.RepeatedRecursiveMessageField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.SingleOptionOneOfField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.StringField3;
+import com.google.protobuf.Any;
+import com.google.protobuf.Descriptors.Descriptor;
+import com.google.protobuf.Descriptors.FieldDescriptor;
+import com.google.protobuf.Descriptors.FieldDescriptor.JavaType;
+import com.google.protobuf.DynamicMessage;
+import com.google.protobuf.Message;
+import com.google.protobuf.Message.Builder;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.lang.reflect.AnnotatedType;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.stream.Stream;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+public class StressTest {
+  private static final int NUM_INITS = 500;
+  private static final int NUM_MUTATE_PER_INIT = 100;
+  private static final double MANY_DISTINCT_ELEMENTS_RATIO = 0.5;
+
+  private enum TestEnumTwo { A, B }
+
+  private enum TestEnumThree { A, B, C }
+
+  @SuppressWarnings("unused")
+  static Message getTestProtobufDefaultInstance() {
+    return TestProtobuf.getDefaultInstance();
+  }
+
+  public static Stream<Arguments> stressTestCases() {
+    return Stream.of(arguments(asAnnotatedType(boolean.class), "Boolean", exactly(false, true),
+                         exactly(false, true)),
+        arguments(new TypeHolder<@NotNull Boolean>() {}.annotatedType(), "Boolean",
+            exactly(false, true), exactly(false, true)),
+        arguments(new TypeHolder<Boolean>() {}.annotatedType(), "Nullable<Boolean>",
+            exactly(null, false, true), exactly(null, false, true)),
+        arguments(new TypeHolder<@NotNull List<@NotNull Boolean>>() {}.annotatedType(),
+            "List<Boolean>", exactly(emptyList(), singletonList(false), singletonList(true)),
+            manyDistinctElements()),
+        arguments(new TypeHolder<@NotNull List<Boolean>>() {}.annotatedType(),
+            "List<Nullable<Boolean>>",
+            exactly(emptyList(), singletonList(null), singletonList(false), singletonList(true)),
+            manyDistinctElements()),
+        arguments(new TypeHolder<List<@NotNull Boolean>>() {}.annotatedType(),
+            "Nullable<List<Boolean>>",
+            exactly(null, emptyList(), singletonList(false), singletonList(true)),
+            distinctElementsRatio(0.30)),
+        arguments(new TypeHolder<List<Boolean>>() {}.annotatedType(),
+            "Nullable<List<Nullable<Boolean>>>",
+            exactly(
+                null, emptyList(), singletonList(null), singletonList(false), singletonList(true)),
+            distinctElementsRatio(0.30)),
+        arguments(
+            new TypeHolder<@NotNull Map<@NotNull String, @NotNull String>>() {}.annotatedType(),
+            "Map<String,String>", distinctElementsRatio(0.45), distinctElementsRatio(0.45)),
+        arguments(new TypeHolder<Map<@NotNull String, @NotNull String>>() {}.annotatedType(),
+            "Nullable<Map<String,String>>", distinctElementsRatio(0.46),
+            distinctElementsRatio(0.48)),
+        arguments(
+            new TypeHolder<@WithSize(max = 3) @NotNull Map<@NotNull Integer, @NotNull Integer>>() {
+            }.annotatedType(),
+            "Map<Integer,Integer>",
+            // Half of all maps are empty, the other half is heavily biased towards special values.
+            all(mapSizeInClosedRange(0, 3), distinctElementsRatio(0.2)),
+            all(mapSizeInClosedRange(0, 3), manyDistinctElements())),
+        arguments(
+            new TypeHolder<@NotNull Map<@NotNull Boolean, @NotNull Boolean>>() {}.annotatedType(),
+            "Map<Boolean,Boolean>",
+            // 1 0-element map, 4 1-element maps
+            distinctElements(1 + 4),
+            // 1 0-element map, 4 1-element maps, 4 2-element maps
+            distinctElements(1 + 4 + 4)),
+        arguments(asAnnotatedType(byte.class), "Byte",
+            // init is heavily biased towards special values and only returns a uniformly random
+            // value in 1 out of 5 calls.
+            all(expectedNumberOfDistinctElements(1 << Byte.SIZE, boundHits(NUM_INITS, 0.2)),
+                contains((byte) 0, (byte) 1, Byte.MIN_VALUE, Byte.MAX_VALUE)),
+            // With mutations, we expect to reach all possible bytes.
+            exactly(rangeClosed(Byte.MIN_VALUE, Byte.MAX_VALUE).mapToObj(i -> (byte) i).toArray())),
+        arguments(asAnnotatedType(short.class), "Short",
+            // init is heavily biased towards special values and only returns a uniformly random
+            // value in 1 out of 5 calls.
+            all(expectedNumberOfDistinctElements(1 << Short.SIZE, boundHits(NUM_INITS, 0.2)),
+                contains((short) 0, (short) 1, Short.MIN_VALUE, Short.MAX_VALUE)),
+            // The integral type mutator does not always return uniformly random values and the
+            // random walk it uses is more likely to produce non-distinct elements, hence the test
+            // only passes with ~90% of the optimal parameters.
+            expectedNumberOfDistinctElements(
+                1 << Short.SIZE, NUM_INITS * NUM_MUTATE_PER_INIT * 9 / 10)),
+        arguments(asAnnotatedType(int.class), "Integer",
+            // init is heavily biased towards special values and only returns a uniformly random
+            // value in 1 out of 5 calls.
+            all(expectedNumberOfDistinctElements(1L << Integer.SIZE, boundHits(NUM_INITS, 0.2)),
+                contains(0, 1, Integer.MIN_VALUE, Integer.MAX_VALUE)),
+            // See "Short" case.
+            expectedNumberOfDistinctElements(
+                1L << Integer.SIZE, NUM_INITS * NUM_MUTATE_PER_INIT * 9 / 10)),
+        arguments(new TypeHolder<@NotNull @InRange(min = 0) Long>() {}.annotatedType(), "Long",
+            // init is heavily biased towards special values and only returns a uniformly random
+            // value in 1 out of 5 calls.
+            all(expectedNumberOfDistinctElements(1L << Long.SIZE - 1, boundHits(NUM_INITS, 0.2)),
+                contains(0L, 1L, Long.MAX_VALUE)),
+            // See "Short" case.
+            expectedNumberOfDistinctElements(
+                1L << Integer.SIZE - 1, NUM_INITS * NUM_MUTATE_PER_INIT * 9 / 10)),
+        arguments(
+            new TypeHolder<@NotNull @InRange(max = Integer.MIN_VALUE + 5) Integer>() {
+            }.annotatedType(),
+            "Integer",
+            exactly(rangeClosed(Integer.MIN_VALUE, Integer.MIN_VALUE + 5).boxed().toArray()),
+            exactly(rangeClosed(Integer.MIN_VALUE, Integer.MIN_VALUE + 5).boxed().toArray())),
+        arguments(asAnnotatedType(TestEnumTwo.class), "Nullable<Enum<TestEnumTwo>>",
+            exactly(null, TestEnumTwo.A, TestEnumTwo.B),
+            exactly(null, TestEnumTwo.A, TestEnumTwo.B)),
+        arguments(asAnnotatedType(TestEnumThree.class), "Nullable<Enum<TestEnumThree>>",
+            exactly(null, TestEnumThree.A, TestEnumThree.B, TestEnumThree.C),
+            exactly(null, TestEnumThree.A, TestEnumThree.B, TestEnumThree.C)),
+        arguments(new TypeHolder<@NotNull @FloatInRange(min = 0f) Float>() {}.annotatedType(),
+            "Float",
+            all(distinctElementsRatio(0.45),
+                doesNotContain(Float.NEGATIVE_INFINITY, -Float.MAX_VALUE, -Float.MIN_VALUE),
+                contains(Float.NaN, Float.POSITIVE_INFINITY, Float.MAX_VALUE, Float.MIN_VALUE, 0.0f,
+                    -0.0f)),
+            all(distinctElementsRatio(0.75),
+                doesNotContain(Float.NEGATIVE_INFINITY, -Float.MAX_VALUE, -Float.MIN_VALUE))),
+        arguments(new TypeHolder<@NotNull Float>() {}.annotatedType(), "Float",
+            all(distinctElementsRatio(0.45),
+                contains(Float.NaN, Float.NEGATIVE_INFINITY, Float.POSITIVE_INFINITY,
+                    -Float.MAX_VALUE, Float.MAX_VALUE, -Float.MIN_VALUE, Float.MIN_VALUE, 0.0f,
+                    -0.0f)),
+            distinctElementsRatio(0.76)),
+        arguments(
+            new TypeHolder<@NotNull @FloatInRange(
+                min = -1.0f, max = 1.0f, allowNaN = false) Float>() {
+            }.annotatedType(),
+            "Float",
+            all(distinctElementsRatio(0.45),
+                doesNotContain(Float.NaN, -Float.MAX_VALUE, Float.MAX_VALUE,
+                    Float.NEGATIVE_INFINITY, Float.POSITIVE_INFINITY),
+                contains(-Float.MIN_VALUE, Float.MIN_VALUE, 0.0f, -0.0f)),
+            all(distinctElementsRatio(0.525),
+                doesNotContain(Float.NaN, -Float.MAX_VALUE, Float.MAX_VALUE,
+                    Float.NEGATIVE_INFINITY, Float.POSITIVE_INFINITY),
+                contains(-Float.MIN_VALUE, Float.MIN_VALUE, 0.0f, -0.0f))),
+        arguments(new TypeHolder<@NotNull Double>() {}.annotatedType(), "Double",
+            all(distinctElementsRatio(0.45),
+                contains(Double.NaN, Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY)),
+            distinctElementsRatio(0.75)),
+        arguments(
+            new TypeHolder<@NotNull @DoubleInRange(
+                min = -1.0, max = 1.0, allowNaN = false) Double>() {
+            }.annotatedType(),
+            "Double", all(distinctElementsRatio(0.45), doesNotContain(Double.NaN)),
+            all(distinctElementsRatio(0.55), doesNotContain(Double.NaN))));
+  }
+
+  public static Stream<Arguments> protoStressTestCases() {
+    return Stream.of(
+        arguments(new TypeHolder<@NotNull OptionalPrimitiveField3>() {}.annotatedType(),
+            "{Builder.Nullable<Boolean>} -> Message",
+            exactly(OptionalPrimitiveField3.newBuilder().build(),
+                OptionalPrimitiveField3.newBuilder().setSomeField(false).build(),
+                OptionalPrimitiveField3.newBuilder().setSomeField(true).build()),
+            exactly(OptionalPrimitiveField3.newBuilder().build(),
+                OptionalPrimitiveField3.newBuilder().setSomeField(false).build(),
+                OptionalPrimitiveField3.newBuilder().setSomeField(true).build())),
+        arguments(new TypeHolder<@NotNull RepeatedRecursiveMessageField3>() {}.annotatedType(),
+            "{Builder.Boolean, WithoutInit(Builder via List<(cycle) -> Message>)} -> Message",
+            // The message field is recursive and thus not initialized.
+            exactly(RepeatedRecursiveMessageField3.getDefaultInstance(),
+                RepeatedRecursiveMessageField3.newBuilder().setSomeField(true).build()),
+            manyDistinctElements()),
+        arguments(new TypeHolder<@NotNull IntegralField3>() {}.annotatedType(),
+            "{Builder.Integer} -> Message",
+            // init is heavily biased towards special values and only returns a uniformly random
+            // value in 1 out of 5 calls.
+            all(expectedNumberOfDistinctElements(1L << Integer.SIZE, boundHits(NUM_INITS, 0.2)),
+                contains(IntegralField3.newBuilder().build(),
+                    IntegralField3.newBuilder().setSomeField(1).build(),
+                    IntegralField3.newBuilder().setSomeField(Integer.MIN_VALUE).build(),
+                    IntegralField3.newBuilder().setSomeField(Integer.MAX_VALUE).build())),
+            // Our mutations return uniformly random elements in ~3/8 of all cases.
+            expectedNumberOfDistinctElements(
+                1L << Integer.SIZE, NUM_INITS * NUM_MUTATE_PER_INIT * 3 / 8)),
+        arguments(new TypeHolder<@NotNull RepeatedIntegralField3>() {}.annotatedType(),
+            "{Builder via List<Integer>} -> Message",
+            contains(RepeatedIntegralField3.getDefaultInstance(),
+                RepeatedIntegralField3.newBuilder().addSomeField(0).build(),
+                RepeatedIntegralField3.newBuilder().addSomeField(1).build(),
+                RepeatedIntegralField3.newBuilder().addSomeField(Integer.MAX_VALUE).build(),
+                RepeatedIntegralField3.newBuilder().addSomeField(Integer.MIN_VALUE).build()),
+            // TODO: This ratio is on the lower end, most likely because of the strong bias towards
+            //  special values combined with the small initial size of the list. When we improve the
+            //  list mutator, this may be increased.
+            distinctElementsRatio(0.25)),
+        arguments(new TypeHolder<@NotNull BytesField3>() {}.annotatedType(),
+            "{Builder.byte[] -> ByteString} -> Message", manyDistinctElements(),
+            manyDistinctElements()),
+        arguments(new TypeHolder<@NotNull StringField3>() {}.annotatedType(),
+            "{Builder.String} -> Message", manyDistinctElements(), manyDistinctElements()),
+        arguments(new TypeHolder<@NotNull EnumField3>() {}.annotatedType(),
+            "{Builder.Enum<TestEnum>} -> Message",
+            exactly(EnumField3.getDefaultInstance(),
+                EnumField3.newBuilder().setSomeField(TestEnum.VAL2).build()),
+            exactly(EnumField3.getDefaultInstance(),
+                EnumField3.newBuilder().setSomeField(TestEnum.VAL2).build())),
+        arguments(new TypeHolder<@NotNull EnumFieldRepeated3>() {}.annotatedType(),
+            "{Builder via List<Enum<TestEnumRepeated>>} -> Message",
+            exactly(EnumFieldRepeated3.getDefaultInstance(),
+                EnumFieldRepeated3.newBuilder().addSomeField(TestEnumRepeated.UNASSIGNED).build(),
+                EnumFieldRepeated3.newBuilder().addSomeField(TestEnumRepeated.VAL1).build(),
+                EnumFieldRepeated3.newBuilder().addSomeField(TestEnumRepeated.VAL2).build()),
+            manyDistinctElements()),
+        arguments(new TypeHolder<@NotNull MapField3>() {}.annotatedType(),
+            "{Builder.Map<Integer,String>} -> Message", distinctElementsRatio(0.47),
+            manyDistinctElements()),
+        arguments(new TypeHolder<@NotNull MessageMapField3>() {}.annotatedType(),
+            "{Builder.Map<String,{Builder.Map<Integer,String>} -> Message>} -> Message",
+            distinctElementsRatio(0.45), distinctElementsRatio(0.45)),
+        arguments(new TypeHolder<@NotNull DoubleField3>() {}.annotatedType(),
+            "{Builder.Double} -> Message", distinctElementsRatio(0.45), distinctElementsRatio(0.7)),
+        arguments(new TypeHolder<@NotNull RepeatedDoubleField3>() {}.annotatedType(),
+            "{Builder via List<Double>} -> Message", distinctElementsRatio(0.2),
+            distinctElementsRatio(0.9)),
+        arguments(new TypeHolder<@NotNull FloatField3>() {}.annotatedType(),
+            "{Builder.Float} -> Message", distinctElementsRatio(0.45), distinctElementsRatio(0.7)),
+        arguments(new TypeHolder<@NotNull RepeatedFloatField3>() {}.annotatedType(),
+            "{Builder via List<Float>} -> Message", distinctElementsRatio(0.20),
+            distinctElementsRatio(0.9), emptyList()),
+        arguments(new TypeHolder<@NotNull TestProtobuf>() {}.annotatedType(),
+            "{Builder.Nullable<Boolean>, Builder.Nullable<Integer>, Builder.Nullable<Integer>, Builder.Nullable<Long>, Builder.Nullable<Long>, Builder.Nullable<Float>, Builder.Nullable<Double>, Builder.Nullable<String>, Builder.Nullable<Enum<Enum>>, WithoutInit(Builder.Nullable<{Builder.Nullable<Integer>, Builder via List<Integer>, WithoutInit(Builder.Nullable<(cycle) -> Message>)} -> Message>), Builder via List<Boolean>, Builder via List<Integer>, Builder via List<Integer>, Builder via List<Long>, Builder via List<Long>, Builder via List<Float>, Builder via List<Double>, Builder via List<String>, Builder via List<Enum<Enum>>, WithoutInit(Builder via List<(cycle) -> Message>), Builder.Map<Integer,Integer>, Builder.Nullable<FixedValue(OnlyLabel)>, Builder.Nullable<{<empty>} -> Message>, Builder.Nullable<Integer> | Builder.Nullable<Long> | Builder.Nullable<Integer>} -> Message",
+            manyDistinctElements(), manyDistinctElements()),
+        arguments(
+            new TypeHolder<@NotNull @WithDefaultInstance(
+                "com.code_intelligence.jazzer.mutation.mutator.StressTest#getTestProtobufDefaultInstance")
+                Message>() {
+            }.annotatedType(),
+            "{Builder.Nullable<Boolean>, Builder.Nullable<Integer>, Builder.Nullable<Integer>, Builder.Nullable<Long>, Builder.Nullable<Long>, Builder.Nullable<Float>, Builder.Nullable<Double>, Builder.Nullable<String>, Builder.Nullable<Enum<Enum>>, WithoutInit(Builder.Nullable<{Builder.Nullable<Integer>, Builder via List<Integer>, WithoutInit(Builder.Nullable<(cycle) -> Message>)} -> Message>), Builder via List<Boolean>, Builder via List<Integer>, Builder via List<Integer>, Builder via List<Long>, Builder via List<Long>, Builder via List<Float>, Builder via List<Double>, Builder via List<String>, Builder via List<Enum<Enum>>, WithoutInit(Builder via List<(cycle) -> Message>), Builder.Map<Integer,Integer>, Builder.Nullable<FixedValue(OnlyLabel)>, Builder.Nullable<{<empty>} -> Message>, Builder.Nullable<Integer> | Builder.Nullable<Long> | Builder.Nullable<Integer>} -> Message",
+            manyDistinctElements(), manyDistinctElements()),
+        arguments(
+            new TypeHolder<@NotNull @AnySource(
+                {PrimitiveField3.class, MessageField3.class}) AnyField3>() {
+            }.annotatedType(),
+            "{Builder.Nullable<Builder.{Builder.Boolean} -> Message | Builder.{Builder.Nullable<(cycle) -> Message>} -> Message -> Message>} -> Message",
+            exactly(AnyField3.getDefaultInstance(),
+                AnyField3.newBuilder()
+                    .setSomeField(Any.pack(PrimitiveField3.getDefaultInstance()))
+                    .build(),
+                AnyField3.newBuilder()
+                    .setSomeField(Any.pack(PrimitiveField3.newBuilder().setSomeField(true).build()))
+                    .build(),
+                AnyField3.newBuilder()
+                    .setSomeField(Any.pack(MessageField3.getDefaultInstance()))
+                    .build(),
+                AnyField3.newBuilder()
+                    .setSomeField(
+                        Any.pack(MessageField3.newBuilder()
+                                     .setMessageField(PrimitiveField3.getDefaultInstance())
+                                     .build()))
+                    .build(),
+                AnyField3.newBuilder()
+                    .setSomeField(Any.pack(
+                        MessageField3.newBuilder()
+                            .setMessageField(PrimitiveField3.newBuilder().setSomeField(true))
+                            .build()))
+                    .build()),
+            exactly(AnyField3.getDefaultInstance(),
+                AnyField3.newBuilder()
+                    .setSomeField(Any.pack(PrimitiveField3.getDefaultInstance()))
+                    .build(),
+                AnyField3.newBuilder()
+                    .setSomeField(Any.pack(PrimitiveField3.newBuilder().setSomeField(true).build()))
+                    .build(),
+                AnyField3.newBuilder()
+                    .setSomeField(Any.pack(MessageField3.getDefaultInstance()))
+                    .build(),
+                AnyField3.newBuilder()
+                    .setSomeField(
+                        Any.pack(MessageField3.newBuilder()
+                                     .setMessageField(PrimitiveField3.getDefaultInstance())
+                                     .build()))
+                    .build(),
+                AnyField3.newBuilder()
+                    .setSomeField(Any.pack(
+                        MessageField3.newBuilder()
+                            .setMessageField(PrimitiveField3.newBuilder().setSomeField(true))
+                            .build()))
+                    .build())),
+        arguments(new TypeHolder<@NotNull SingleOptionOneOfField3>() {}.annotatedType(),
+            "{Builder.Nullable<Boolean>} -> Message",
+            exactly(SingleOptionOneOfField3.getDefaultInstance(),
+                SingleOptionOneOfField3.newBuilder().setBoolField(false).build(),
+                SingleOptionOneOfField3.newBuilder().setBoolField(true).build()),
+            exactly(SingleOptionOneOfField3.getDefaultInstance(),
+                SingleOptionOneOfField3.newBuilder().setBoolField(false).build(),
+                SingleOptionOneOfField3.newBuilder().setBoolField(true).build())));
+  }
+
+  @SafeVarargs
+  private static Consumer<List<Object>> all(Consumer<List<Object>>... checks) {
+    return list -> {
+      for (Consumer<List<Object>> check : checks) {
+        check.accept(list);
+      }
+    };
+  }
+
+  private static Consumer<List<Object>> distinctElements(int num) {
+    return list -> assertThat(new HashSet<>(list).size()).isAtLeast(num);
+  }
+
+  private static Consumer<List<Object>> manyDistinctElements() {
+    return distinctElementsRatio(MANY_DISTINCT_ELEMENTS_RATIO);
+  }
+
+  /**
+   * Returns a lower bound on the expected number of hits when sampling from a domain of a given
+   * size with the given probability.
+   */
+  private static int boundHits(long domainSize, double probability) {
+    // Binomial distribution.
+    double expectedValue = domainSize * probability;
+    double variance = domainSize * probability * (1 - probability);
+    double standardDeviation = sqrt(variance);
+    // Allow missing the expected value by two standard deviations. For a normal distribution,
+    // this would correspond to 95% of all cases.
+    int almostCertainLowerBound = (int) floor(expectedValue - 2 * standardDeviation);
+    return almostCertainLowerBound;
+  }
+
+  /**
+   * Asserts that a given list contains at least as many distinct elements as can be expected when
+   * picking {@code picks} out of {@code domainSize} elements uniformly at random.
+   */
+  private static Consumer<List<Object>> expectedNumberOfDistinctElements(
+      long domainSize, int picks) {
+    // https://www.randomservices.org/random/urn/Birthday.html#mom2
+    double expectedValue = domainSize * (1 - pow(1 - 1.0 / domainSize, picks));
+    double variance = domainSize * (domainSize - 1) * pow(1 - 2.0 / domainSize, picks)
+        + domainSize * pow(1 - 1.0 / domainSize, picks)
+        - domainSize * domainSize * pow(1 - 1.0 / domainSize, 2 * picks);
+    double standardDeviation = sqrt(variance);
+    // Allow missing the expected value by two standard deviations. For a normal distribution,
+    // this would correspond to 95% of all cases.
+    int almostCertainLowerBound = (int) floor(expectedValue - 2 * standardDeviation);
+    return list
+        -> assertWithMessage("V=distinct elements among %s picked out of %s\nE[V]=%s\nσ[V]=%s",
+            picks, domainSize, expectedValue, standardDeviation)
+               .that(new HashSet<>(list).size())
+               .isAtLeast(almostCertainLowerBound);
+  }
+
+  private static Consumer<List<Object>> distinctElementsRatio(double ratio) {
+    require(ratio > 0);
+    require(ratio <= 1);
+    return list -> assertThat(new HashSet<>(list).size() / (double) list.size()).isAtLeast(ratio);
+  }
+
+  private static Consumer<List<Object>> exactly(Object... expected) {
+    return list -> assertThat(new HashSet<>(list)).containsExactly(expected);
+  }
+
+  private static Consumer<List<Object>> contains(Object... expected) {
+    return list -> assertThat(new HashSet<>(list)).containsAtLeastElementsIn(expected);
+  }
+
+  private static Consumer<List<Object>> doesNotContain(Object... expected) {
+    return list -> assertThat(new HashSet<>(list)).containsNoneIn(expected);
+  }
+
+  private static Consumer<List<Object>> mapSizeInClosedRange(int min, int max) {
+    return list -> {
+      list.forEach(map -> {
+        if (map instanceof Map) {
+          assertThat(((Map) map).size()).isAtLeast(min);
+          assertThat(((Map) map).size()).isAtMost(max);
+        } else {
+          throw new IllegalArgumentException(
+              "Expected a list of maps, got list of" + map.getClass().getName());
+        }
+      });
+    };
+  }
+
+  @ParameterizedTest(name = "{index} {0}, {1}")
+  @MethodSource({"stressTestCases", "protoStressTestCases"})
+  void genericMutatorStressTest(AnnotatedType type, String mutatorTree,
+      Consumer<List<Object>> expectedInitValues, Consumer<List<Object>> expectedMutatedValues)
+      throws IOException {
+    validateAnnotationUsage(type);
+    SerializingMutator mutator = Mutators.newFactory().createOrThrow(type);
+    assertThat(mutator.toString()).isEqualTo(mutatorTree);
+
+    // Even with a fallback to mutating map values when no new key can be constructed, the map
+    // {false: true, true: false} will not change its equality class when the fallback picks both
+    // values to mutate.
+    boolean mayPerformNoopMutations =
+        mutatorTree.contains("FixedValue(") || mutatorTree.contains("Map<Boolean,Boolean>");
+
+    PseudoRandom rng = anyPseudoRandom();
+
+    List<Object> initValues = new ArrayList<>();
+    List<Object> mutatedValues = new ArrayList<>();
+    for (int i = 0; i < NUM_INITS; i++) {
+      Object value = mutator.init(rng);
+
+      // For proto messages, each float field with value -0.0f, and double field with value -0.0
+      // will be converted to 0.0f and 0.0, respectively.
+      Object fixedValue = fixFloatingPointsForProtos(value);
+      testReadWriteRoundtrip(mutator, fixedValue);
+      testReadWriteExclusiveRoundtrip(mutator, fixedValue);
+
+      initValues.add(mutator.detach(value));
+      value = fixFloatingPointsForProtos(value);
+
+      for (int mutation = 0; mutation < NUM_MUTATE_PER_INIT; mutation++) {
+        Object detachedOldValue = mutator.detach(value);
+        value = mutator.mutate(value, rng);
+        if (!mayPerformNoopMutations) {
+          if (value instanceof Double) {
+            assertThat(Double.compare((Double) value, (Double) detachedOldValue)).isNotEqualTo(0);
+          } else if (value instanceof Float) {
+            assertThat(Float.compare((Float) value, (Float) detachedOldValue)).isNotEqualTo(0);
+          } else {
+            assertThat(detachedOldValue).isNotEqualTo(value);
+          }
+        }
+
+        mutatedValues.add(mutator.detach(value));
+
+        // For proto messages, each float field with value -0.0f, and double field with value -0.0
+        // will be converted to 0.0f and 0.0, respectively. This is because the values -0f and 0f
+        // and their double counterparts are serialized as default values (0f, and 0.0), which is
+        // relevant for mutation and the round trip tests. This means that the protos with float or
+        // double fields that equal to negative zero, will start mutation from positive zeros, and
+        // cause the assertion above to fail from time to time. To avoid this, we convert all
+        // negative zeros to positive zeros for float and double proto fields.
+        value = fixFloatingPointsForProtos(value);
+        testReadWriteRoundtrip(mutator, fixedValue);
+        testReadWriteExclusiveRoundtrip(mutator, fixedValue);
+      }
+    }
+
+    expectedInitValues.accept(initValues);
+    expectedMutatedValues.accept(mutatedValues);
+  }
+
+  private static <T> void testReadWriteExclusiveRoundtrip(Serializer<T> serializer, T value)
+      throws IOException {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    serializer.writeExclusive(value, out);
+    T newValue = serializer.readExclusive(new ByteArrayInputStream(out.toByteArray()));
+    assertThat(newValue).isEqualTo(value);
+  }
+
+  private static <T> void testReadWriteRoundtrip(Serializer<T> serializer, T value)
+      throws IOException {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    serializer.write(value, new DataOutputStream(out));
+    T newValue = serializer.read(
+        new DataInputStream(extendWithZeros(new ByteArrayInputStream(out.toByteArray()))));
+    assertThat(newValue).isEqualTo(value);
+  }
+
+  // Filter out floating point values -0.0f and -0.0 and replace them
+  // by 0.0f and 0.0 respectively.
+  // This is a workaround for a bug in the protobuf library that causes
+  // our "...RoundTrip" tests to fail for negative zero in floats and doubles.
+  private static <T> T fixFloatingPointsForProtos(T value) {
+    if (!(value instanceof Message)) {
+      return value;
+    }
+    Message.Builder builder = ((Message) value).toBuilder();
+    walkFields(builder, oldValue -> {
+      if (Objects.equals(oldValue, -0.0)) {
+        return 0.0;
+      } else if (Objects.equals(oldValue, -0.0f)) {
+        return 0.0f;
+      } else {
+        return oldValue;
+      }
+    });
+    return (T) builder.build();
+  }
+
+  private static void walkFields(Builder builder, Function<Object, Object> transform) {
+    for (FieldDescriptor field : builder.getDescriptorForType().getFields()) {
+      if (field.isRepeated()) {
+        int bound = builder.getRepeatedFieldCount(field);
+        for (int i = 0; i < bound; i++) {
+          if (field.getJavaType() == JavaType.MESSAGE) {
+            Builder repeatedFieldBuilder =
+                ((Message) builder.getRepeatedField(field, i)).toBuilder();
+            walkFields(repeatedFieldBuilder, transform);
+            builder.setRepeatedField(field, i, repeatedFieldBuilder.build());
+          } else {
+            builder.setRepeatedField(field, i, transform.apply(builder.getRepeatedField(field, i)));
+          }
+        }
+      } else if (field.getJavaType() == JavaType.MESSAGE) {
+        // Break up unbounded recursion.
+        if (!builder.hasField(field)) {
+          continue;
+        }
+        Builder fieldBuilder = ((Message) builder.getField(field)).toBuilder();
+        walkFields(fieldBuilder, transform);
+        builder.setField(field, fieldBuilder.build());
+      } else {
+        builder.setField(field, transform.apply(builder.getField(field)));
+      }
+    }
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/mutator/collection/BUILD.bazel b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/collection/BUILD.bazel
new file mode 100644
index 0000000..2e60b9d
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/collection/BUILD.bazel
@@ -0,0 +1,17 @@
+load("@contrib_rules_jvm//java:defs.bzl", "java_test_suite")
+
+java_test_suite(
+    name = "CollectionTests",
+    size = "small",
+    srcs = glob(["*.java"]),
+    env = {"JAZZER_MOCK_LIBFUZZER_MUTATOR": "true"},
+    runner = "junit5",
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/mutation/annotation",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/api",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/mutator/collection",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/support",
+        "//src/test/java/com/code_intelligence/jazzer/mutation/support:test_support",
+    ],
+)
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/mutator/collection/ChunkMutationsTest.java b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/collection/ChunkMutationsTest.java
new file mode 100644
index 0000000..2fa0c1c
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/collection/ChunkMutationsTest.java
@@ -0,0 +1,237 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.collection;
+
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.asMap;
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.asMutableList;
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.mockInitializer;
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.mockMutator;
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.mockPseudoRandom;
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toCollection;
+import static java.util.stream.Collectors.toList;
+
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.code_intelligence.jazzer.mutation.support.TestSupport.MockPseudoRandom;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+import java.util.Set;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+
+class ChunkMutationsTest {
+  @Test
+  void testDeleteRandomChunk() {
+    List<Integer> list = Stream.of(1, 2, 3, 4, 5, 6).collect(toList());
+
+    try (MockPseudoRandom prng = mockPseudoRandom(2, 3)) {
+      ChunkMutations.deleteRandomChunk(list, 2, prng);
+    }
+    assertThat(list).containsExactly(1, 2, 3, 6).inOrder();
+  }
+
+  @Test
+  void testInsertRandomChunk() {
+    List<String> list = Stream.of("1", "2", "3", "4", "5", "6").collect(toList());
+
+    try (MockPseudoRandom prng = mockPseudoRandom(2, 3)) {
+      ChunkMutations.insertRandomChunk(list, 10, mockInitializer(() -> "7", String::new), prng);
+    }
+    assertThat(list).containsExactly("1", "2", "3", "7", "7", "4", "5", "6").inOrder();
+    String firstNewValue = list.get(3);
+    String secondNewValue = list.get(4);
+    assertThat(firstNewValue).isEqualTo(secondNewValue);
+    // Verify that the individual new elements were detached.
+    assertThat(firstNewValue).isNotSameInstanceAs(secondNewValue);
+  }
+
+  @Test
+  void testInsertRandomChunkSet() {
+    Set<Integer> set = Stream.of(1, 2, 3, 4, 5, 6).collect(toCollection(LinkedHashSet::new));
+
+    Queue<Integer> initReturnValues =
+        Stream.of(7, 7, 7, 8, 9, 9).collect(toCollection(ArrayDeque::new));
+    boolean result;
+    try (MockPseudoRandom prng = mockPseudoRandom(3)) {
+      result = ChunkMutations.insertRandomChunk(
+          set, set::add, 10, mockInitializer(initReturnValues::remove, v -> v), prng);
+    }
+    assertThat(result).isTrue();
+    assertThat(set).containsExactly(1, 2, 3, 4, 5, 6, 7, 8, 9).inOrder();
+  }
+
+  @Test
+  void testInsertRandomChunkSet_largeChunk() {
+    Set<Integer> set = Stream.of(1, 2, 3, 4, 5, 6).collect(toCollection(LinkedHashSet::new));
+
+    Queue<Integer> initReturnValues =
+        IntStream.rangeClosed(1, 10000).boxed().collect(toCollection(ArrayDeque::new));
+    boolean result;
+    try (MockPseudoRandom prng = mockPseudoRandom(9994)) {
+      result = ChunkMutations.insertRandomChunk(
+          set, set::add, 10000, mockInitializer(initReturnValues::remove, v -> v), prng);
+    }
+    assertThat(result).isTrue();
+    assertThat(set)
+        .containsExactlyElementsIn(IntStream.rangeClosed(1, 10000).boxed().toArray())
+        .inOrder();
+  }
+
+  @Test
+  void testInsertRandomChunkSet_failsToConstructDistinctValues() {
+    Set<Integer> set = Stream.of(1, 2, 3, 4, 5, 6).collect(toCollection(LinkedHashSet::new));
+
+    Queue<Integer> initReturnValues =
+        Stream.concat(Stream.of(7, 7, 7, 8), Stream.generate(() -> 7).limit(1000))
+            .collect(toCollection(ArrayDeque::new));
+    boolean result;
+    try (MockPseudoRandom prng = mockPseudoRandom(3)) {
+      result = ChunkMutations.insertRandomChunk(
+          set, set::add, 10, mockInitializer(initReturnValues::remove, v -> v), prng);
+    }
+    assertThat(result).isFalse();
+    assertThat(set).containsExactly(1, 2, 3, 4, 5, 6, 7, 8).inOrder();
+  }
+
+  @Test
+  void testMutateChunk() {
+    List<Integer> list = Stream.of(1, 2, 3, 4, 5, 6).collect(toList());
+
+    try (MockPseudoRandom prng = mockPseudoRandom(2, 3)) {
+      ChunkMutations.mutateRandomChunk(list, mockMutator(1, i -> 2 * i), prng);
+    }
+    assertThat(list).containsExactly(1, 2, 3, 8, 10, 6).inOrder();
+  }
+
+  @Test
+  void testMutateRandomValuesChunk() {
+    Map<Integer, Integer> map = asMap(1, 10, 2, 20, 3, 30, 4, 40, 5, 50, 6, 60);
+
+    try (MockPseudoRandom prng = mockPseudoRandom(2, 3)) {
+      ChunkMutations.mutateRandomValuesChunk(map, mockMutator(1, i -> 2 * i), prng);
+    }
+    assertThat(map).containsExactly(1, 10, 2, 20, 3, 30, 4, 80, 5, 100, 6, 60).inOrder();
+  }
+
+  @Test
+  void testMutateRandomKeysChunk() {
+    Map<List<Integer>, Integer> map = asMap(asMutableList(1), 10, asMutableList(2), 20,
+        asMutableList(3), 30, asMutableList(4), 40, asMutableList(5), 50, asMutableList(6), 60);
+    SerializingMutator<List<Integer>> keyMutator = mockMutator(null, list -> {
+      List<Integer> newList = list.stream().map(i -> i + 1).collect(toList());
+      list.clear();
+      return newList;
+    }, ArrayList::new);
+
+    try (MockPseudoRandom prng = mockPseudoRandom(2, 3)) {
+      boolean result = ChunkMutations.mutateRandomKeysChunk(map, keyMutator, prng);
+      assertThat(result).isTrue();
+    }
+    assertThat(map)
+        .containsExactly(asMutableList(1), 10, asMutableList(2), 20, asMutableList(3), 30,
+            asMutableList(6), 60, asMutableList(7), 40, asMutableList(8), 50)
+        .inOrder();
+  }
+
+  @Test
+  void testMutateRandomKeysChunk_failsToConstructSomeDistinctKeys() {
+    Map<List<Integer>, Integer> map = asMap(asMutableList(1), 10, asMutableList(2), 20,
+        asMutableList(3), 30, asMutableList(4), 40, asMutableList(5), 50, asMutableList(6), 60);
+    SerializingMutator<List<Integer>> keyMutator = mockMutator(null, list -> {
+      list.clear();
+      List<Integer> newList = new ArrayList<>();
+      newList.add(7);
+      return newList;
+    }, ArrayList::new);
+
+    try (MockPseudoRandom prng = mockPseudoRandom(2, 3)) {
+      boolean result = ChunkMutations.mutateRandomKeysChunk(map, keyMutator, prng);
+      assertThat(result).isTrue();
+    }
+    assertThat(map)
+        .containsExactly(asMutableList(1), 10, asMutableList(2), 20, asMutableList(3), 30,
+            asMutableList(5), 50, asMutableList(6), 60, asMutableList(7), 40)
+        .inOrder();
+  }
+
+  @Test
+  void testMutateRandomKeysChunk_failsToConstructAnyDistinctKeys() {
+    Map<List<Integer>, Integer> map = asMap(asMutableList(1), 10, asMutableList(2), 20,
+        asMutableList(3), 30, asMutableList(4), 40, asMutableList(5), 50, asMutableList(6), 60);
+    SerializingMutator<List<Integer>> keyMutator = mockMutator(null, list -> {
+      list.clear();
+      List<Integer> newList = new ArrayList<>();
+      newList.add(1);
+      return newList;
+    }, ArrayList::new);
+
+    try (MockPseudoRandom prng = mockPseudoRandom(2, 3)) {
+      boolean result = ChunkMutations.mutateRandomKeysChunk(map, keyMutator, prng);
+      assertThat(result).isFalse();
+    }
+    assertThat(map)
+        .containsExactly(asMutableList(1), 10, asMutableList(2), 20, asMutableList(3), 30,
+            asMutableList(4), 40, asMutableList(5), 50, asMutableList(6), 60)
+        .inOrder();
+  }
+
+  @Test
+  void testMutateRandomKeysChunk_nullKeyAndValue() {
+    Map<List<Integer>, Integer> map = asMap(asMutableList(1), 10, asMutableList(2), 20,
+        asMutableList(3), 30, asMutableList(4), null, null, 50, asMutableList(6), 60);
+    SerializingMutator<List<Integer>> keyMutator = mockMutator(null, list -> {
+      if (list != null) {
+        List<Integer> newList = list.stream().map(i -> i + 1).collect(toList());
+        list.clear();
+        return newList;
+      } else {
+        return asMutableList(10);
+      }
+    }, list -> list != null ? new ArrayList<>(list) : null);
+
+    try (MockPseudoRandom prng = mockPseudoRandom(2, 3)) {
+      boolean result = ChunkMutations.mutateRandomKeysChunk(map, keyMutator, prng);
+      assertThat(result).isTrue();
+    }
+    assertThat(map)
+        .containsExactly(asMutableList(1), 10, asMutableList(2), 20, asMutableList(3), 30,
+            asMutableList(6), 60, asMutableList(5), null, asMutableList(10), 50)
+        .inOrder();
+  }
+
+  @Test
+  void testMutateRandomKeysChunk_mutateKeyToNull() {
+    Map<List<Integer>, Integer> map = asMap(asMutableList(1), 10, asMutableList(2), 20,
+        asMutableList(3), 30, asMutableList(4), 40, asMutableList(5), 50, asMutableList(6), 60);
+    SerializingMutator<List<Integer>> keyMutator =
+        mockMutator(null, list -> null, list -> list != null ? new ArrayList<>(list) : null);
+
+    try (MockPseudoRandom prng = mockPseudoRandom(1, 3)) {
+      boolean result = ChunkMutations.mutateRandomKeysChunk(map, keyMutator, prng);
+      assertThat(result).isTrue();
+    }
+    assertThat(map)
+        .containsExactly(asMutableList(1), 10, asMutableList(2), 20, asMutableList(3), 30,
+            asMutableList(5), 50, asMutableList(6), 60, null, 40)
+        .inOrder();
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/mutator/collection/ListMutatorTest.java b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/collection/ListMutatorTest.java
new file mode 100644
index 0000000..24299f4
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/collection/ListMutatorTest.java
@@ -0,0 +1,280 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.collection;
+
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.mockPseudoRandom;
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.Collections.emptyList;
+
+import com.code_intelligence.jazzer.mutation.annotation.NotNull;
+import com.code_intelligence.jazzer.mutation.annotation.WithSize;
+import com.code_intelligence.jazzer.mutation.api.ChainedMutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.MutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.code_intelligence.jazzer.mutation.mutator.lang.LangMutators;
+import com.code_intelligence.jazzer.mutation.support.TestSupport.MockPseudoRandom;
+import com.code_intelligence.jazzer.mutation.support.TypeHolder;
+import java.lang.reflect.AnnotatedType;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+@SuppressWarnings("unchecked")
+public class ListMutatorTest {
+  public static final MutatorFactory FACTORY =
+      new ChainedMutatorFactory(LangMutators.newFactory(), CollectionMutators.newFactory());
+
+  private static SerializingMutator<@NotNull List<@NotNull Integer>> defaultListMutator() {
+    AnnotatedType type = new TypeHolder<@NotNull List<@NotNull Integer>>() {}.annotatedType();
+    return (SerializingMutator<@NotNull List<@NotNull Integer>>) FACTORY.createOrThrow(type);
+  }
+
+  @Test
+  void testInit() {
+    SerializingMutator<@NotNull List<@NotNull Integer>> mutator = defaultListMutator();
+    assertThat(mutator.toString()).isEqualTo("List<Integer>");
+
+    List<Integer> list;
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // targetSize
+             1,
+             // elementMutator.init
+             1)) {
+      list = mutator.init(prng);
+    }
+    assertThat(list).containsExactly(0);
+  }
+
+  @Test
+  void testInitMaxSize() {
+    AnnotatedType type =
+        new TypeHolder<@NotNull @WithSize(min = 2, max = 3) List<@NotNull Integer>>(){}
+            .annotatedType();
+
+    SerializingMutator<@NotNull List<@NotNull Integer>> mutator =
+        (SerializingMutator<@NotNull List<@NotNull Integer>>) FACTORY.createOrThrow(type);
+
+    assertThat(mutator.toString()).isEqualTo("List<Integer>");
+    List<Integer> list;
+    try (MockPseudoRandom prng = mockPseudoRandom(2, 4, 42L, 4, 43L)) {
+      list = mutator.init(prng);
+    }
+
+    assertThat(list).containsExactly(42, 43).inOrder();
+  }
+
+  @Test
+  void testRemoveSingleElement() {
+    SerializingMutator<@NotNull List<@NotNull Integer>> mutator = defaultListMutator();
+
+    List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9));
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // action
+             0,
+             // number of elements to remove
+             1,
+             // index to remove
+             2)) {
+      list = mutator.mutate(list, prng);
+    }
+    assertThat(list).containsExactly(1, 2, 4, 5, 6, 7, 8, 9).inOrder();
+  }
+
+  @Test
+  void testRemoveChunk() {
+    SerializingMutator<@NotNull List<@NotNull Integer>> mutator = defaultListMutator();
+
+    List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9));
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // action
+             0,
+             // chunk size
+             2,
+             // chunk offset
+             3)) {
+      list = mutator.mutate(list, prng);
+    }
+    assertThat(list).containsExactly(1, 2, 3, 6, 7, 8, 9).inOrder();
+  }
+
+  @Test
+  void testAddSingleElement() {
+    SerializingMutator<@NotNull List<@NotNull Integer>> mutator = defaultListMutator();
+
+    List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9));
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // action
+             1,
+             // add single element,
+             1,
+             // offset,
+             9,
+             // Integral initImpl sentinel value
+             4,
+             // value
+             42L)) {
+      list = mutator.mutate(list, prng);
+    }
+    assertThat(list).containsExactly(1, 2, 3, 4, 5, 6, 7, 8, 9, 42).inOrder();
+  }
+
+  @Test
+  void testAddChunk() {
+    SerializingMutator<@NotNull List<@NotNull Integer>> mutator = defaultListMutator();
+
+    List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9));
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // action
+             1,
+             // chunkSize
+             2,
+             // chunkOffset
+             3,
+             // Integral initImpl
+             4,
+             // val
+             42L)) {
+      list = mutator.mutate(list, prng);
+    }
+    assertThat(list).containsExactly(1, 2, 3, 42, 42, 4, 5, 6, 7, 8, 9).inOrder();
+  }
+
+  @Test
+  void testChangeSingleElement() {
+    SerializingMutator<@NotNull List<@NotNull Integer>> mutator = defaultListMutator();
+
+    List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9));
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // action
+             2,
+             // number of elements to mutate
+             1,
+             // first index to mutate at
+             2,
+             // mutation choice based on `IntegralMutatorFactory`
+             // 2 == closedRange
+             2,
+             // value
+             55L)) {
+      list = mutator.mutate(list, prng);
+    }
+    assertThat(list).containsExactly(1, 2, 55, 4, 5, 6, 7, 8, 9).inOrder();
+  }
+
+  @Test
+  void testChangeChunk() {
+    SerializingMutator<@NotNull List<@NotNull Integer>> mutator = defaultListMutator();
+
+    List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11));
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // action
+             2,
+             // number of elements to mutate
+             2,
+             // first index to mutate at
+             5,
+             // mutation: 0 == bitflip
+             0,
+             // shift constant
+             13,
+             // and again
+             0, 12)) {
+      list = mutator.mutate(list, prng);
+    }
+    assertThat(list).containsExactly(1, 2, 3, 4, 5, 8198, 4103, 8, 9, 10, 11).inOrder();
+  }
+
+  @Test
+  void testCrossOverEmptyLists() {
+    SerializingMutator<@NotNull List<@NotNull Integer>> mutator = defaultListMutator();
+
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      List<Integer> list = mutator.crossOver(emptyList(), emptyList(), prng);
+      assertThat(list).isEmpty();
+    }
+  }
+
+  @Test
+  void testCrossOverInsertChunk() {
+    SerializingMutator<@NotNull List<@NotNull Integer>> mutator = defaultListMutator();
+
+    List<Integer> list = new ArrayList<>(Arrays.asList(0, 1, 2, 3, 4, 5, 6, 7, 8, 9));
+    List<Integer> otherList =
+        new ArrayList<>(Arrays.asList(10, 11, 12, 13, 14, 15, 16, 17, 18, 19));
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // insert action
+             0,
+             // chunk size
+             3,
+             // fromPos
+             2,
+             // toPos
+             5)) {
+      list = mutator.crossOver(list, otherList, prng);
+    }
+    assertThat(list).containsExactly(0, 1, 2, 3, 4, 12, 13, 14, 5, 6, 7, 8, 9).inOrder();
+  }
+
+  @Test
+  void testCrossOverOverwriteChunk() {
+    SerializingMutator<@NotNull List<@NotNull Integer>> mutator = defaultListMutator();
+
+    List<Integer> list = new ArrayList<>(Arrays.asList(0, 1, 2, 3, 4, 5, 6, 7, 8, 9));
+    List<Integer> otherList =
+        new ArrayList<>(Arrays.asList(10, 11, 12, 13, 14, 15, 16, 17, 18, 19));
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // overwrite action
+             1,
+             // chunk size
+             3,
+             // fromPos
+             2,
+             // toPos
+             5)) {
+      list = mutator.crossOver(list, otherList, prng);
+    }
+    assertThat(list).containsExactly(0, 1, 2, 3, 4, 12, 13, 14, 8, 9).inOrder();
+  }
+
+  @Test
+  void testCrossOverCrossOverChunk() {
+    SerializingMutator<@NotNull List<@NotNull Integer>> mutator = defaultListMutator();
+
+    List<Integer> list = new ArrayList<>(Arrays.asList(0, 1, 2, 3, 4, 5, 6, 7, 8, 9));
+    List<Integer> otherList =
+        new ArrayList<>(Arrays.asList(10, 11, 12, 13, 14, 15, 16, 17, 18, 19));
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // overwrite action
+             2,
+             // chunk size
+             3,
+             // fromPos
+             2,
+             // toPos
+             2,
+             // mean value in sub cross over
+             0,
+             // mean value in sub cross over
+             0,
+             // mean value in sub cross over
+             0)) {
+      list = mutator.crossOver(list, otherList, prng);
+    }
+    assertThat(list).containsExactly(0, 1, 7, 8, 9, 5, 6, 7, 8, 9).inOrder();
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/mutator/collection/MapMutatorTest.java b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/collection/MapMutatorTest.java
new file mode 100644
index 0000000..4c2c14f
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/collection/MapMutatorTest.java
@@ -0,0 +1,355 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.collection;
+
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.asMap;
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.asMutableList;
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.mockPseudoRandom;
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.Collections.emptyMap;
+
+import com.code_intelligence.jazzer.mutation.annotation.NotNull;
+import com.code_intelligence.jazzer.mutation.annotation.WithSize;
+import com.code_intelligence.jazzer.mutation.api.ChainedMutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.MutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.code_intelligence.jazzer.mutation.mutator.lang.LangMutators;
+import com.code_intelligence.jazzer.mutation.support.TestSupport.MockPseudoRandom;
+import com.code_intelligence.jazzer.mutation.support.TypeHolder;
+import java.lang.reflect.AnnotatedType;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+@SuppressWarnings("unchecked")
+class MapMutatorTest {
+  public static final MutatorFactory FACTORY =
+      new ChainedMutatorFactory(LangMutators.newFactory(), CollectionMutators.newFactory());
+
+  private static SerializingMutator<Map<Integer, Integer>> defaultTestMapMutator() {
+    AnnotatedType type =
+        new TypeHolder<@NotNull Map<@NotNull Integer, @NotNull Integer>>() {}.annotatedType();
+    return (SerializingMutator<Map<Integer, Integer>>) FACTORY.createOrThrow(type);
+  }
+
+  @Test
+  void mapInitInsert() {
+    AnnotatedType type =
+        new TypeHolder<@NotNull @WithSize(max = 3) Map<@NotNull String, @NotNull String>>(){}
+            .annotatedType();
+    SerializingMutator<Map<String, String>> mutator =
+        (SerializingMutator<Map<String, String>>) FACTORY.createOrThrow(type);
+    assertThat(mutator.toString()).isEqualTo("Map<String,String>");
+
+    // Initialize new map
+    Map<String, String> map;
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // Initial map size
+             1,
+             // Key 1 size
+             4,
+             // Key 1 value
+             "Key1".getBytes(),
+             // Value size
+             6,
+             // Value value
+             "Value1".getBytes())) {
+      map = mutator.init(prng);
+    }
+    assertThat(map).containsExactly("Key1", "Value1");
+
+    // Add 2 new entries
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // grow chunk
+             1,
+             // ChunkSize
+             2,
+             // Key 2 size
+             4,
+             // Key 2 value
+             "Key2".getBytes(),
+             // Value size
+             6,
+             // Value value
+             "Value2".getBytes(),
+             // Key 3 size
+             4,
+             // Key 3 value
+             "Key3".getBytes(),
+             // Value size
+             6,
+             // Value value
+             "Value3".getBytes())) {
+      map = mutator.mutate(map, prng);
+    }
+    assertThat(map).containsExactly("Key1", "Value1", "Key2", "Value2", "Key3", "Value3").inOrder();
+  }
+
+  @Test
+  void mapDelete() {
+    AnnotatedType type =
+        new TypeHolder<@NotNull Map<@NotNull Integer, @NotNull Integer>>() {}.annotatedType();
+    SerializingMutator<Map<Integer, Integer>> mutator =
+        (SerializingMutator<Map<Integer, Integer>>) FACTORY.createOrThrow(type);
+    assertThat(mutator.toString()).isEqualTo("Map<Integer,Integer>");
+
+    Map<Integer, Integer> map = asMap(1, 10, 2, 20, 3, 30, 4, 40, 5, 50, 6, 60);
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // delete chunk
+             0,
+             // chunk size
+             2,
+             // chunk position
+             3)) {
+      map = mutator.mutate(map, prng);
+    }
+    assertThat(map).containsExactly(1, 10, 2, 20, 3, 30, 6, 60).inOrder();
+  }
+
+  @Test
+  void mapMutateValues() {
+    AnnotatedType type =
+        new TypeHolder<@NotNull Map<@NotNull Integer, @NotNull Integer>>() {}.annotatedType();
+    SerializingMutator<Map<Integer, Integer>> mutator =
+        (SerializingMutator<Map<Integer, Integer>>) FACTORY.createOrThrow(type);
+    assertThat(mutator.toString()).isEqualTo("Map<Integer,Integer>");
+
+    Map<Integer, Integer> map = asMap(1, 10, 2, 20, 3, 30, 4, 40, 5, 50, 6, 60);
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // change chunk
+             2,
+             // mutate values,
+             true,
+             // chunk size
+             2,
+             // chunk position
+             3,
+             // uniform pick
+             2,
+             // random integer
+             41L,
+             // uniform pick
+             2,
+             // random integer
+             51L)) {
+      map = mutator.mutate(map, prng);
+    }
+    assertThat(map).containsExactly(1, 10, 2, 20, 3, 30, 4, 41, 5, 51, 6, 60).inOrder();
+  }
+
+  @Test
+  void mapMutateKeys() {
+    AnnotatedType type =
+        new TypeHolder<@NotNull Map<@NotNull Integer, @NotNull Integer>>() {}.annotatedType();
+    SerializingMutator<Map<Integer, Integer>> mutator =
+        (SerializingMutator<Map<Integer, Integer>>) FACTORY.createOrThrow(type);
+    assertThat(mutator.toString()).isEqualTo("Map<Integer,Integer>");
+
+    Map<Integer, Integer> map = asMap(1, 10, 2, 20, 3, 30, 4, 40, 5, 50, 6, 60);
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // change chunk
+             2,
+             // mutate keys,
+             false,
+             // chunk size
+             2,
+             // chunk position
+             3,
+             // uniform pick
+             2,
+             // integer
+             7L,
+             // uniform pick
+             2,
+             // random integer
+             8L)) {
+      map = mutator.mutate(map, prng);
+    }
+    assertThat(map).containsExactly(1, 10, 2, 20, 3, 30, 6, 60, 7, 40, 8, 50).inOrder();
+  }
+
+  @Test
+  void mapMutateKeysFallbackToValues() {
+    AnnotatedType type =
+        new TypeHolder<@NotNull Map<@NotNull Boolean, @NotNull Boolean>>() {}.annotatedType();
+    SerializingMutator<Map<Boolean, Boolean>> mutator =
+        (SerializingMutator<Map<Boolean, Boolean>>) FACTORY.createOrThrow(type);
+    assertThat(mutator.toString()).isEqualTo("Map<Boolean,Boolean>");
+
+    // No new keys can be generated for this map.
+    Map<Boolean, Boolean> map = asMap(false, false, true, false);
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // change chunk
+             2,
+             // mutate keys,
+             false,
+             // chunk size
+             1,
+             // chunk position
+             0,
+             // chunk size for fallback to mutate values
+             2,
+             // chunk position for fallback
+             0)) {
+      map = mutator.mutate(map, prng);
+    }
+    assertThat(map).containsExactly(false, true, true, true).inOrder();
+  }
+
+  @Test
+  void testCrossOverEmptyMaps() {
+    SerializingMutator<@NotNull Map<@NotNull Integer, @NotNull Integer>> mutator =
+        defaultTestMapMutator();
+
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      Map<Integer, Integer> map = mutator.crossOver(emptyMap(), emptyMap(), prng);
+      assertThat(map).isEmpty();
+    }
+  }
+
+  @Test
+  void testCrossOverInsertChunk() {
+    SerializingMutator<@NotNull Map<@NotNull Integer, @NotNull Integer>> mutator =
+        defaultTestMapMutator();
+
+    Map<Integer, Integer> map = asMap(1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6);
+    Map<Integer, Integer> otherMap = asMap(1, 1, 2, 2, 3, 3, 40, 40, 50, 50, 60, 60);
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // insert action
+             0,
+             // chunk size
+             3,
+             // from chunk offset, will skip first element of chunk as it is already present in map
+             3,
+             // to chunk offset, unused
+             0)) {
+      map = mutator.crossOver(map, otherMap, prng);
+      assertThat(map)
+          .containsExactly(1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 40, 40, 50, 50, 60, 60)
+          .inOrder();
+    }
+  }
+
+  @Test
+  void testCrossOverOverwriteChunk() {
+    SerializingMutator<@NotNull Map<@NotNull Integer, @NotNull Integer>> mutator =
+        defaultTestMapMutator();
+
+    Map<Integer, Integer> map = asMap(1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6);
+    Map<Integer, Integer> otherMap = asMap(1, 1, 2, 2, 3, 3, 40, 40, 50, 50, 60, 60);
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // overwrite action
+             1,
+             // chunk size
+             3,
+             // from chunk offset
+             2,
+             // to chunk offset, will not change first element as values are equal
+             2)) {
+      map = mutator.crossOver(map, otherMap, prng);
+      assertThat(map).containsExactly(1, 1, 2, 2, 3, 3, 4, 40, 5, 50, 6, 6).inOrder();
+    }
+  }
+
+  @Test
+  void testCrossOverCrossOverChunkKeys() {
+    AnnotatedType type =
+        new TypeHolder<@NotNull Map<@NotNull List<@NotNull Integer>, @NotNull Integer>>() {
+        }.annotatedType();
+    SerializingMutator<@NotNull Map<@NotNull List<@NotNull Integer>, @NotNull Integer>> mutator =
+        (SerializingMutator<@NotNull Map<@NotNull List<@NotNull Integer>, @NotNull Integer>>)
+            FACTORY.createOrThrow(type);
+
+    Map<List<Integer>, Integer> map = asMap(asMutableList(1), 1, asMutableList(2), 2,
+        asMutableList(3), 3, asMutableList(4), 4, asMutableList(5), 5, asMutableList(6), 6);
+    Map<List<Integer>, Integer> otherMap = asMap(asMutableList(1), 1, asMutableList(2), 2,
+        asMutableList(3), 3, asMutableList(40), 4, asMutableList(50), 5, asMutableList(60), 6);
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // cross over action
+             2,
+             // keys
+             true,
+             // chunk size
+             3,
+             // from chunk offset
+             2,
+             // to chunk offset,
+             // first keys ("3") are equal and will be overwritten
+             2,
+             // first key, delegate to list cross over, overwrite 1 entry at offset 0 from offset 0
+             1, 1, 0, 0,
+             // second key, delegate to list cross over, overwrite 1 entry at offset 0 from offset 0
+             1, 1, 0, 0,
+             // third key, delegate to list cross over, overwrite 1 entry at offset 0 from offset 0
+             1, 1, 0, 0)) {
+      map = mutator.crossOver(map, otherMap, prng);
+      assertThat(map)
+          .containsExactly(asMutableList(1), 1, asMutableList(2), 2, asMutableList(6), 6,
+              // Overwritten keys after here
+              asMutableList(3), 3, asMutableList(40), 4, asMutableList(50), 5)
+          .inOrder();
+    }
+  }
+
+  @Test
+  void testCrossOverCrossOverChunkValues() {
+    AnnotatedType type =
+        new TypeHolder<@NotNull Map<@NotNull Integer, @NotNull List<@NotNull Integer>>>() {
+        }.annotatedType();
+    SerializingMutator<@NotNull Map<@NotNull Integer, @NotNull List<@NotNull Integer>>> mutator =
+        (SerializingMutator<@NotNull Map<@NotNull Integer, @NotNull List<@NotNull Integer>>>)
+            FACTORY.createOrThrow(type);
+
+    Map<Integer, List<Integer>> map = asMap(1, asMutableList(1), 2, asMutableList(2), 3,
+        asMutableList(3), 4, asMutableList(4), 5, asMutableList(5), 6, asMutableList(6));
+    Map<Integer, List<Integer>> otherMap = asMap(1, asMutableList(1), 2, asMutableList(2), 3,
+        asMutableList(30), 40, asMutableList(40), 50, asMutableList(50), 60, asMutableList(60));
+
+    try (
+        MockPseudoRandom prng = mockPseudoRandom(
+            // cross over action
+            2,
+            // values
+            false,
+            // chunk size
+            3,
+            // from chunk offset
+            2,
+            // to chunk offset,
+            2,
+            // first value, delegate to list cross over, overwrite 1 entry at offset 0 from offset 0
+            1, 1, 0, 0,
+            // second value, delegate to list cross over, overwrite 1 entry at offset 0 from offset
+            // 0
+            1, 1, 0, 0,
+            // third value, delegate to list cross over, overwrite 1 entry at offset 0 from offset 0
+            1, 1, 0, 0)) {
+      map = mutator.crossOver(map, otherMap, prng);
+      assertThat(map)
+          .containsExactly(1, asMutableList(1), 2, asMutableList(2), 3, asMutableList(30), 4,
+              asMutableList(40), 5, asMutableList(50), 6, asMutableList(6))
+          .inOrder();
+    }
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/BUILD.bazel b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/BUILD.bazel
new file mode 100644
index 0000000..05e1d72
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/BUILD.bazel
@@ -0,0 +1,18 @@
+load("@contrib_rules_jvm//java:defs.bzl", "java_test_suite")
+
+java_test_suite(
+    name = "PrimitiveTests",
+    size = "small",
+    srcs = glob(["*.java"]),
+    env = {"JAZZER_MOCK_LIBFUZZER_MUTATOR": "true"},
+    runner = "junit5",
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/mutation/annotation",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/api",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/mutator/libfuzzer",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/support",
+        "//src/test/java/com/code_intelligence/jazzer/mutation/support:test_support",
+        "@com_google_protobuf//java/core",
+    ],
+)
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/BooleanMutatorTest.java b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/BooleanMutatorTest.java
new file mode 100644
index 0000000..3bf55bc
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/BooleanMutatorTest.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.lang;
+
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.mockPseudoRandom;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.code_intelligence.jazzer.mutation.annotation.NotNull;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.code_intelligence.jazzer.mutation.support.TestSupport.MockPseudoRandom;
+import com.code_intelligence.jazzer.mutation.support.TypeHolder;
+import org.junit.jupiter.api.Test;
+
+@SuppressWarnings("unchecked")
+class BooleanMutatorTest {
+  @Test
+  void testPrimitive() {
+    SerializingMutator<Boolean> mutator = LangMutators.newFactory().createOrThrow(boolean.class);
+    assertThat(mutator.toString()).isEqualTo("Boolean");
+
+    boolean bool;
+    try (MockPseudoRandom prng = mockPseudoRandom(true)) {
+      bool = mutator.init(prng);
+    }
+    assertThat(bool).isTrue();
+
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      bool = mutator.mutate(bool, prng);
+    }
+    assertThat(bool).isFalse();
+  }
+
+  @Test
+  void testBoxed() {
+    SerializingMutator<Boolean> mutator =
+        (SerializingMutator<Boolean>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull Boolean>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("Boolean");
+
+    Boolean bool;
+    try (MockPseudoRandom prng = mockPseudoRandom(false)) {
+      bool = mutator.init(prng);
+    }
+    assertThat(bool).isFalse();
+
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      bool = mutator.mutate(bool, prng);
+    }
+    assertThat(bool).isTrue();
+  }
+
+  @Test
+  void testCrossOver() {
+    SerializingMutator<Boolean> mutator = LangMutators.newFactory().createOrThrow(boolean.class);
+    try (MockPseudoRandom prng = mockPseudoRandom(true, false)) {
+      assertThat(mutator.crossOver(true, false, prng)).isTrue();
+      assertThat(mutator.crossOver(true, false, prng)).isFalse();
+    }
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/ByteArrayMutatorTest.java b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/ByteArrayMutatorTest.java
new file mode 100644
index 0000000..1592b17
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/ByteArrayMutatorTest.java
@@ -0,0 +1,189 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.code_intelligence.jazzer.mutation.mutator.lang;
+
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.mockPseudoRandom;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import com.code_intelligence.jazzer.mutation.annotation.NotNull;
+import com.code_intelligence.jazzer.mutation.annotation.WithLength;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.code_intelligence.jazzer.mutation.mutator.libfuzzer.LibFuzzerMutator;
+import com.code_intelligence.jazzer.mutation.support.TestSupport.MockPseudoRandom;
+import com.code_intelligence.jazzer.mutation.support.TypeHolder;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+@SuppressWarnings({"unchecked", "ResultOfMethodCallIgnored"})
+public class ByteArrayMutatorTest {
+  /**
+   * Some tests may set {@link LibFuzzerMutator#MOCK_SIZE_KEY} which can interfere with other tests
+   * unless cleared.
+   */
+  @AfterEach
+  void cleanMockSize() {
+    System.clearProperty(LibFuzzerMutator.MOCK_SIZE_KEY);
+  }
+
+  @Test
+  void testBasicFunction() {
+    SerializingMutator<byte[]> mutator =
+        (SerializingMutator<byte[]>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<byte[]>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("Nullable<byte[]>");
+
+    byte[] arr;
+    try (MockPseudoRandom prng = mockPseudoRandom(false, 5, new byte[] {1, 2, 3, 4, 5})) {
+      arr = mutator.init(prng);
+    }
+    assertThat(arr).isEqualTo(new byte[] {1, 2, 3, 4, 5});
+
+    System.setProperty(LibFuzzerMutator.MOCK_SIZE_KEY, "10");
+    try (MockPseudoRandom prng = mockPseudoRandom(false)) {
+      arr = mutator.mutate(arr, prng);
+    }
+    assertThat(arr).isEqualTo(new byte[] {2, 4, 6, 8, 10, 6, 7, 8, 9, 10});
+  }
+
+  @Test
+  void testMaxLength() {
+    SerializingMutator<byte[]> mutator =
+        (SerializingMutator<byte[]>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<byte @NotNull @WithLength(max = 10)[]>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("byte[]");
+
+    byte[] arr;
+    try (MockPseudoRandom prng = mockPseudoRandom(8, new byte[] {1, 2, 3, 4, 5, 6, 7, 8})) {
+      arr = mutator.init(prng);
+    }
+    assertThat(arr).isEqualTo(new byte[] {1, 2, 3, 4, 5, 6, 7, 8});
+
+    System.setProperty(LibFuzzerMutator.MOCK_SIZE_KEY, "11");
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      // the ByteArrayMutator will limit the maximum size of the data requested from libfuzzer to
+      // WithLength::max so setting the mock mutator to make it bigger will cause an exception
+      assertThrows(ArrayIndexOutOfBoundsException.class, () -> { mutator.mutate(arr, prng); });
+    }
+  }
+
+  @Test
+  void testMaxLengthInitClamp() {
+    SerializingMutator<byte[]> mutator =
+        (SerializingMutator<byte[]>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<byte @NotNull @WithLength(max = 5)[]>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("byte[]");
+
+    try (MockPseudoRandom prng = mockPseudoRandom(10)) {
+      // init will call closedRange(min, max) and the mock prng will assert that the given value
+      // above is between those values which we want to fail here to show that we're properly
+      // clamping the range
+      assertThrows(AssertionError.class, () -> { mutator.init(prng); });
+    }
+  }
+
+  @Test
+  void testMinLengthInitClamp() {
+    SerializingMutator<byte[]> mutator =
+        (SerializingMutator<byte[]>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<byte @NotNull @WithLength(min = 5)[]>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("byte[]");
+
+    try (MockPseudoRandom prng = mockPseudoRandom(3)) {
+      // init will call closedrange(min, max) and the mock prng will assert that the given value
+      // above is between those values which we want to fail here to show that we're properly
+      // clamping the range
+      assertThrows(AssertionError.class, () -> { mutator.init(prng); });
+    }
+  }
+
+  @Test
+  void testMinLength() {
+    SerializingMutator<byte[]> mutator =
+        (SerializingMutator<byte[]>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<byte @NotNull @WithLength(min = 5)[]>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("byte[]");
+
+    byte[] arr;
+    try (MockPseudoRandom prng = mockPseudoRandom(10, new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10})) {
+      arr = mutator.init(prng);
+    }
+    assertThat(arr).hasLength(10);
+
+    System.setProperty(LibFuzzerMutator.MOCK_SIZE_KEY, "3");
+
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      arr = mutator.mutate(arr, prng);
+    }
+    assertThat(arr).hasLength(5);
+    assertThat(arr).isEqualTo(new byte[] {2, 4, 6, 0, 0});
+  }
+
+  @Test
+  void testCrossOver() {
+    SerializingMutator<byte[]> mutator =
+        (SerializingMutator<byte[]>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<byte @NotNull[]>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("byte[]");
+
+    byte[] value = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
+    byte[] otherValue = {10, 11, 12, 13, 14, 15, 16, 17, 18, 19};
+
+    byte[] crossedOver;
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // intersect arrays
+             0,
+             // out length
+             8,
+             // copy 3 from first
+             3,
+             // copy 1 from second
+             1,
+             // copy 1 from first,
+             1,
+             // copy 3 from second
+             3)) {
+      crossedOver = mutator.crossOver(value, otherValue, prng);
+      assertThat(crossedOver).isEqualTo(new byte[] {0, 1, 2, 10, 3, 11, 12, 13});
+    }
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // insert into action
+             1,
+             // copy size
+             3,
+             // from position
+             5,
+             // to position
+             2)) {
+      crossedOver = mutator.crossOver(value, otherValue, prng);
+      assertThat(crossedOver).isEqualTo(new byte[] {0, 1, 15, 16, 17, 2, 3, 4, 5, 6, 7, 8, 9});
+    }
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // overwrite action
+             2,
+             // to position
+             3,
+             // copy size
+             3,
+             // from position
+             4)) {
+      crossedOver = mutator.crossOver(value, otherValue, prng);
+      assertThat(crossedOver).isEqualTo(new byte[] {0, 1, 2, 14, 15, 16, 6, 7, 8, 9});
+    }
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/EnumMutatorTest.java b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/EnumMutatorTest.java
new file mode 100644
index 0000000..d2c6139
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/EnumMutatorTest.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.lang;
+
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.mockPseudoRandom;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import com.code_intelligence.jazzer.mutation.annotation.NotNull;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.code_intelligence.jazzer.mutation.support.TestSupport.MockPseudoRandom;
+import com.code_intelligence.jazzer.mutation.support.TypeHolder;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import org.junit.jupiter.api.Test;
+
+class EnumMutatorTest {
+  enum TestEnumOne { A }
+
+  enum TestEnum { A, B, C }
+
+  @Test
+  void testBoxed() {
+    SerializingMutator<TestEnum> mutator =
+        (SerializingMutator<TestEnum>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull TestEnum>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("Enum<TestEnum>");
+    TestEnum cl;
+    try (MockPseudoRandom prng = mockPseudoRandom(0)) {
+      cl = mutator.init(prng);
+    }
+    assertThat(cl).isEqualTo(TestEnum.A);
+
+    try (MockPseudoRandom prng = mockPseudoRandom(1)) {
+      cl = mutator.mutate(cl, prng);
+    }
+    assertThat(cl).isEqualTo(TestEnum.B);
+
+    try (MockPseudoRandom prng = mockPseudoRandom(0)) {
+      cl = mutator.mutate(cl, prng);
+    }
+    assertThat(cl).isEqualTo(TestEnum.A);
+
+    try (MockPseudoRandom prng = mockPseudoRandom(2)) {
+      cl = mutator.mutate(cl, prng);
+    }
+    assertThat(cl).isEqualTo(TestEnum.C);
+
+    try (MockPseudoRandom prng = mockPseudoRandom(1)) {
+      cl = mutator.mutate(cl, prng);
+    }
+    assertThat(cl).isEqualTo(TestEnum.B);
+  }
+
+  @Test
+  void testEnumWithOneElementShouldThrow() {
+    assertThrows(IllegalArgumentException.class, () -> {
+      LangMutators.newFactory().createOrThrow(
+          new TypeHolder<@NotNull TestEnumOne>() {}.annotatedType());
+    }, "When trying to build mutators for Enum with one value, an Exception should be thrown.");
+  }
+
+  @Test
+  void testEnumBasedOnInvalidInput() throws IOException {
+    SerializingMutator<TestEnum> mutator =
+        (SerializingMutator<TestEnum>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull TestEnum>() {}.annotatedType());
+    ByteArrayOutputStream bo = new ByteArrayOutputStream();
+    DataOutputStream os = new DataOutputStream(bo);
+    // Valid values
+    os.writeInt(0);
+    os.writeInt(1);
+    os.writeInt(2);
+    // Too high indices wrap around
+    os.writeInt(3);
+    // Abs. value is used to calculate the index
+    os.writeInt(-3);
+
+    DataInputStream is = new DataInputStream(new ByteArrayInputStream(bo.toByteArray()));
+    assertThat(mutator.read(is)).isEqualTo(TestEnum.A);
+    assertThat(mutator.read(is)).isEqualTo(TestEnum.B);
+    assertThat(mutator.read(is)).isEqualTo(TestEnum.C);
+    assertThat(mutator.read(is)).isEqualTo(TestEnum.A);
+    assertThat(mutator.read(is)).isEqualTo(TestEnum.A);
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/FloatingPointMutatorTest.java b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/FloatingPointMutatorTest.java
new file mode 100644
index 0000000..9c03b46
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/FloatingPointMutatorTest.java
@@ -0,0 +1,785 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.lang;
+
+import static com.code_intelligence.jazzer.mutation.mutator.lang.FloatingPointMutatorFactory.DoubleMutator;
+import static com.code_intelligence.jazzer.mutation.mutator.lang.FloatingPointMutatorFactory.FloatMutator;
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.mockPseudoRandom;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+import com.code_intelligence.jazzer.mutation.annotation.DoubleInRange;
+import com.code_intelligence.jazzer.mutation.annotation.FloatInRange;
+import com.code_intelligence.jazzer.mutation.annotation.NotNull;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.code_intelligence.jazzer.mutation.support.TestSupport;
+import com.code_intelligence.jazzer.mutation.support.TypeHolder;
+import java.util.function.Supplier;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+class FloatingPointMutatorTest {
+  static final Float UNUSED_FLOAT = 0.0f;
+  static final Double UNUSED_DOUBLE = 0.0;
+
+  static Stream<Arguments> floatForceInRangeCases() {
+    float NaN1 = Float.intBitsToFloat(0x7f800001);
+    float NaN2 = Float.intBitsToFloat(0x7f800002);
+    float NaN3 = Float.intBitsToFloat(0x7f800003);
+    assertThat(Float.isNaN(NaN1) && Float.isNaN(NaN2) && Float.isNaN(NaN3)).isTrue();
+
+    return Stream.of(
+        // value is already in range: it should stay in range
+        arguments(0.0f, 0.0f, 1.0f, true), arguments(0.0f, 1.0f, 1.0f, true),
+        arguments(Float.NEGATIVE_INFINITY, Float.NEGATIVE_INFINITY, 1.0f, true),
+        arguments(Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY, Float.POSITIVE_INFINITY, true),
+        arguments(Float.NaN, 0.0f, 1.0f, true),
+        arguments(1e30f, -Float.MAX_VALUE, Float.MAX_VALUE, true),
+        arguments(-1e30f, -Float.MAX_VALUE, Float.MAX_VALUE, true),
+        arguments(0.0f, Float.NEGATIVE_INFINITY, Float.MAX_VALUE, true),
+        arguments(0.0f, Float.NEGATIVE_INFINITY, Float.POSITIVE_INFINITY, true),
+        arguments(-Float.MAX_VALUE, -Float.MAX_VALUE, Float.MAX_VALUE, true),
+        arguments(Float.MAX_VALUE, -Float.MAX_VALUE, Float.MAX_VALUE, true),
+        arguments(-Float.MAX_VALUE, Float.MAX_VALUE - 3.4e30f, Float.MAX_VALUE, false),
+        arguments(Float.MAX_VALUE, -100.0f, Float.MAX_VALUE, true),
+        arguments(0.0f, -Float.MIN_VALUE, Float.MIN_VALUE, true),
+        // Special values and diff/ranges outside the range
+        arguments(Float.NEGATIVE_INFINITY, -1.0f, 1.0f, true),
+        arguments(Float.POSITIVE_INFINITY, -1.0f, 1.0f, true),
+        arguments(Float.POSITIVE_INFINITY, -Float.MAX_VALUE, Float.MAX_VALUE, true),
+        arguments(Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY, Float.MAX_VALUE, true),
+        arguments(Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY, -Float.MAX_VALUE, true),
+        arguments(Float.NEGATIVE_INFINITY, -Float.MAX_VALUE, Float.MAX_VALUE, true),
+        arguments(Float.NEGATIVE_INFINITY, -Float.MAX_VALUE, Float.POSITIVE_INFINITY, true),
+        arguments(Float.NEGATIVE_INFINITY, Float.MAX_VALUE, Float.POSITIVE_INFINITY, true),
+        // Values outside the range
+        arguments(-2e30f, -100000.0f, 100000.0f, true),
+        arguments(2e30f, Float.NEGATIVE_INFINITY, -Float.MAX_VALUE, true),
+        arguments(-1.0f, 0.0f, 1.0f, false), arguments(5.0f, 0.0f, 1.0f, false),
+        arguments(-Float.MAX_VALUE, -Float.MAX_VALUE, 100.0f, true),
+        // NaN not allowed
+        arguments(Float.NaN, 0.0f, 1.0f, false),
+        arguments(Float.NaN, -Float.MAX_VALUE, 1.0f, false),
+        arguments(Float.NaN, Float.NEGATIVE_INFINITY, 1.0f, false),
+        arguments(Float.NaN, Float.NEGATIVE_INFINITY, Float.POSITIVE_INFINITY, false),
+        arguments(Float.NaN, 0f, Float.POSITIVE_INFINITY, false),
+        arguments(Float.NaN, 0f, Float.MAX_VALUE, false),
+        arguments(Float.NaN, -Float.MAX_VALUE, Float.MAX_VALUE, false),
+        arguments(Float.NaN, -Float.MIN_VALUE, 0.0f, false),
+        arguments(Float.NaN, -Float.MIN_VALUE, Float.MIN_VALUE, false),
+        arguments(Float.NaN, 0.0f, Float.MIN_VALUE, false),
+        // There are many possible NaN values, test a few of them that are different from Float.NaN
+        // (0x7fc00000)
+        arguments(NaN1, 0.0f, 1.0f, false), arguments(NaN2, 0.0f, 1.0f, false),
+        arguments(NaN3, 0.0f, 1.0f, false));
+  }
+
+  static Stream<Arguments> doubleForceInRangeCases() {
+    double NaN1 = Double.longBitsToDouble(0x7ff0000000000001L);
+    double NaN2 = Double.longBitsToDouble(0x7ff0000000000002L);
+    double NaN3 = Double.longBitsToDouble(0x7ff0000000000003L);
+    double NaNdeadbeef = Double.longBitsToDouble(0x7ff00000deadbeefL);
+    assertThat(
+        Double.isNaN(NaN1) && Double.isNaN(NaN2) && Double.isNaN(NaN3) && Double.isNaN(NaNdeadbeef))
+        .isTrue();
+
+    return Stream.of(
+        // value is already in range: it should stay in range
+        arguments(0.0, 0.0, 1.0, true), arguments(0.0, 1.0, 1.0, true),
+        arguments(Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY, 1.0, true),
+        arguments(
+            Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, true),
+        arguments(Double.NaN, 0.0, 1.0, true),
+        arguments(1e30, -Double.MAX_VALUE, Double.MAX_VALUE, true),
+        arguments(-1e30, -Double.MAX_VALUE, Double.MAX_VALUE, true),
+        arguments(0.0, Double.NEGATIVE_INFINITY, Double.MAX_VALUE, true),
+        arguments(0.0, Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, true),
+        arguments(-Double.MAX_VALUE, -Double.MAX_VALUE, Double.MAX_VALUE, true),
+        arguments(Double.MAX_VALUE, -Double.MAX_VALUE, Double.MAX_VALUE, true),
+        arguments(-Double.MAX_VALUE, Double.MAX_VALUE - 3.4e30, Double.MAX_VALUE, false),
+        arguments(Double.MAX_VALUE, -100.0, Double.MAX_VALUE, true),
+        arguments(0.0, -Double.MIN_VALUE, Double.MIN_VALUE, true),
+        // Special values and diff/ranges outside the range
+        arguments(Double.NEGATIVE_INFINITY, -1.0, 1.0, true),
+        arguments(Double.POSITIVE_INFINITY, -1.0, 1.0, true),
+        arguments(Double.POSITIVE_INFINITY, -Double.MAX_VALUE, Double.MAX_VALUE, true),
+        arguments(Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, Double.MAX_VALUE, true),
+        arguments(Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, -Double.MAX_VALUE, true),
+        arguments(Double.NEGATIVE_INFINITY, -Double.MAX_VALUE, Double.MAX_VALUE, true),
+        arguments(Double.NEGATIVE_INFINITY, -Double.MAX_VALUE, Double.POSITIVE_INFINITY, true),
+        arguments(Double.NEGATIVE_INFINITY, Double.MAX_VALUE, Double.POSITIVE_INFINITY, true),
+        // Values outside the range
+        arguments(-2e30, -100000.0, 100000.0, true),
+        arguments(2e30, Double.NEGATIVE_INFINITY, -Double.MAX_VALUE, true),
+        arguments(-1.0, 0.0, 1.0, false), arguments(5.0, 0.0, 1.0, false),
+        arguments(-Double.MAX_VALUE, -Double.MAX_VALUE, 100.0, true),
+        arguments(
+            Math.nextDown(Double.MAX_VALUE), -Double.MAX_VALUE * 0.5, Double.MAX_VALUE * 0.5, true),
+        arguments(Math.nextDown(Double.MAX_VALUE), -Double.MAX_VALUE * 0.5,
+            Math.nextUp(Double.MAX_VALUE * 0.5), true),
+        // NaN not allowed
+        arguments(Double.NaN, 0.0, 1.0, false),
+        arguments(Double.NaN, -Double.MAX_VALUE, 1.0, false),
+        arguments(Double.NaN, Double.NEGATIVE_INFINITY, 1.0, false),
+        arguments(Double.NaN, Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, false),
+        arguments(Double.NaN, 0, Double.POSITIVE_INFINITY, false),
+        arguments(Double.NaN, 0, Double.MAX_VALUE, false),
+        arguments(Double.NaN, -Double.MAX_VALUE, Double.MAX_VALUE, false),
+        arguments(Double.NaN, -Double.MIN_VALUE, 0.0, false),
+        arguments(Double.NaN, -Double.MIN_VALUE, Double.MIN_VALUE, false),
+        arguments(Double.NaN, 0.0, Double.MIN_VALUE, false),
+        // There are many possible NaN values, test a few of them that are different from Double.NaN
+        // (0x7ff8000000000000L)
+        arguments(NaN1, 0.0, 1.0, false), arguments(NaN2, 0.0, 1.0, false),
+        arguments(NaN3, 0.0, 1.0, false),
+        arguments(NaNdeadbeef, Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, false));
+  }
+
+  @ParameterizedTest
+  @MethodSource("floatForceInRangeCases")
+  void testFloatForceInRange(float value, float minValue, float maxValue, boolean allowNaN) {
+    float inRange = FloatMutator.forceInRange(value, minValue, maxValue, allowNaN);
+
+    // inRange can become NaN only if allowNaN is true and value was NaN already
+    if (Float.isNaN(inRange)) {
+      if (allowNaN) {
+        assertThat(Float.isNaN(value)).isTrue();
+        return; // NaN is not in range of anything
+      } else {
+        throw new AssertionError("NaN is not allowed but was returned");
+      }
+    }
+
+    assertThat(inRange).isAtLeast(minValue);
+    assertThat(inRange).isAtMost(maxValue);
+    if (value >= minValue && value <= maxValue) {
+      assertThat(inRange).isEqualTo(value);
+    }
+  }
+
+  @ParameterizedTest
+  @MethodSource("doubleForceInRangeCases")
+  void testDoubleForceInRange(double value, double minValue, double maxValue, boolean allowNaN) {
+    double inRange = DoubleMutator.forceInRange(value, minValue, maxValue, allowNaN);
+
+    // inRange can become NaN only if allowNaN is true and value was NaN already
+    if (Double.isNaN(inRange)) {
+      if (allowNaN) {
+        assertThat(Double.isNaN(value)).isTrue();
+        return; // NaN is not in range of anything
+      } else {
+        throw new AssertionError("NaN is not allowed but was returned");
+      }
+    }
+
+    assertThat(inRange).isAtLeast(minValue);
+    assertThat(inRange).isAtMost(maxValue);
+    if (value >= minValue && value <= maxValue) {
+      assertThat(inRange).isEqualTo(value);
+    }
+  }
+
+  // Tests of mutators' special values after initialization use mocked PRNG to test one special
+  // value after another. This counter enables adding new special values and testcases for them
+  // without modifying all the other test cases.
+  static Supplier<Integer> makeCounter() {
+    return new Supplier<Integer>() {
+      private int counter = 0;
+
+      @Override
+      public Integer get() {
+        return counter++;
+      }
+    };
+  }
+
+  static Stream<Arguments> floatInitCasesFullRange() {
+    SerializingMutator<Float> mutator =
+        (SerializingMutator<Float>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull Float>() {}.annotatedType());
+    Supplier<Integer> ctr = makeCounter();
+    return Stream.of(arguments(mutator, Stream.of(true, ctr.get()), Float.NEGATIVE_INFINITY, true),
+        arguments(mutator, Stream.of(true, ctr.get()), -Float.MAX_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), -Float.MIN_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), -0.0f, true),
+        arguments(mutator, Stream.of(true, ctr.get()), 0.0f, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Float.MIN_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Float.MAX_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Float.POSITIVE_INFINITY, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Float.NaN, true),
+        arguments(mutator, Stream.of(true, ctr.get()), UNUSED_FLOAT, false));
+  }
+
+  static Stream<Arguments> floatInitCasesMinusOneToOne() {
+    SerializingMutator<Float> mutator =
+        (SerializingMutator<Float>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull @FloatInRange(min = -1.0f, max = 1.0f) Float>() {
+            }.annotatedType());
+    Supplier<Integer> ctr = makeCounter();
+    return Stream.of(arguments(mutator, Stream.of(true, ctr.get()), -1.0f, true),
+        arguments(mutator, Stream.of(true, ctr.get()), -Float.MIN_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), -0.0f, true),
+        arguments(mutator, Stream.of(true, ctr.get()), 0.0f, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Float.MIN_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), 1.0f, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Float.NaN, true),
+        arguments(mutator, Stream.of(true, ctr.get()), UNUSED_FLOAT, false));
+  }
+
+  static Stream<Arguments> floatInitCasesMinusMinToMin() {
+    SerializingMutator<Float> mutator =
+        (SerializingMutator<Float>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull @FloatInRange(
+                min = -Float.MIN_VALUE, max = Float.MIN_VALUE) Float>() {
+            }.annotatedType());
+    Supplier<Integer> ctr = makeCounter();
+    return Stream.of(arguments(mutator, Stream.of(true, ctr.get()), -Float.MIN_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), -0.0f, true),
+        arguments(mutator, Stream.of(true, ctr.get()), 0.0f, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Float.MIN_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Float.NaN, true),
+        arguments(mutator, Stream.of(true, ctr.get()), UNUSED_FLOAT, false));
+  }
+
+  static Stream<Arguments> floatInitCasesMaxToInf() {
+    SerializingMutator<Float> mutator =
+        (SerializingMutator<Float>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull @FloatInRange(
+                min = Float.MAX_VALUE, max = Float.POSITIVE_INFINITY) Float>() {
+            }.annotatedType());
+    Supplier<Integer> ctr = makeCounter();
+    return Stream.of(arguments(mutator, Stream.of(true, ctr.get()), Float.MAX_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Float.POSITIVE_INFINITY, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Float.NaN, true),
+        arguments(mutator, Stream.of(true, ctr.get()), UNUSED_FLOAT, false));
+  }
+
+  static Stream<Arguments> floatInitCasesMinusInfToMinusMax() {
+    SerializingMutator<Float> mutator =
+        (SerializingMutator<Float>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull @FloatInRange(
+                min = Float.NEGATIVE_INFINITY, max = -Float.MAX_VALUE) Float>() {
+            }.annotatedType());
+    Supplier<Integer> ctr = makeCounter();
+    return Stream.of(arguments(mutator, Stream.of(true, ctr.get()), Float.NEGATIVE_INFINITY, true),
+        arguments(mutator, Stream.of(true, ctr.get()), -Float.MAX_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Float.NaN, true),
+        arguments(mutator, Stream.of(true, ctr.get()), UNUSED_FLOAT, false));
+  }
+
+  static Stream<Arguments> floatInitCasesFullRangeWithoutNaN() {
+    SerializingMutator<Float> mutator =
+        (SerializingMutator<Float>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull @FloatInRange(min = Float.NEGATIVE_INFINITY,
+                max = Float.POSITIVE_INFINITY, allowNaN = true) Float>() {
+            }.annotatedType());
+    Supplier<Integer> ctr = makeCounter();
+    return Stream.of(arguments(mutator, Stream.of(true, ctr.get()), Float.NEGATIVE_INFINITY, true),
+        arguments(mutator, Stream.of(true, ctr.get()), -Float.MAX_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), -Float.MIN_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), -0.0f, true),
+        arguments(mutator, Stream.of(true, ctr.get()), 0.0f, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Float.MIN_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Float.MAX_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Float.POSITIVE_INFINITY, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Float.NaN, true),
+        arguments(mutator, Stream.of(true, ctr.get()), UNUSED_FLOAT, false));
+  }
+
+  @ParameterizedTest
+  @MethodSource({"floatInitCasesMinusOneToOne", "floatInitCasesFullRange",
+      "floatInitCasesMinusMinToMin", "floatInitCasesMaxToInf", "floatInitCasesMinusInfToMinusMax",
+      "floatInitCasesFullRangeWithoutNaN"})
+  void
+  testFloatInitCases(SerializingMutator<Float> mutator, Stream<Object> prngValues, float expected,
+      boolean specialValueIndexExists) {
+    assertThat(mutator.toString()).isEqualTo("Float");
+    if (specialValueIndexExists) {
+      Float n = null;
+      try (TestSupport.MockPseudoRandom prng = mockPseudoRandom(prngValues.toArray())) {
+        n = mutator.init(prng);
+      }
+      assertThat(n).isEqualTo(expected);
+    } else { // should throw
+      assertThrows(AssertionError.class, () -> {
+        try (TestSupport.MockPseudoRandom prng = mockPseudoRandom(prngValues.toArray())) {
+          mutator.init(prng);
+        }
+      });
+    }
+  }
+
+  static Stream<Arguments> floatMutateSanityChecksFullRangeCases() {
+    SerializingMutator<Float> mutator =
+        (SerializingMutator<Float>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull @FloatInRange(min = Float.NEGATIVE_INFINITY,
+                max = Float.POSITIVE_INFINITY, allowNaN = true) Float>() {
+            }.annotatedType());
+    // Init value can be set to desired one by giving this to the init method: (false, <desired
+    // value>)
+    return Stream.of(
+        // Bit flips
+        arguments(mutator, Stream.of(false, 0f), Stream.of(false, 0, 0), 1.4e-45f, true),
+        arguments(mutator, Stream.of(false, 0f), Stream.of(false, 0, 30), 2.0f, true),
+        arguments(mutator, Stream.of(false, 2f), Stream.of(false, 0, 31), -2.0f, true),
+        arguments(mutator, Stream.of(false, -2f), Stream.of(false, 0, 22), -3.0f, true),
+        // mutateExponent
+        arguments(mutator, Stream.of(false, 0f), Stream.of(false, 1, 0B01111100), 0.125f, true),
+        arguments(mutator, Stream.of(false, 0f), Stream.of(false, 1, 0B01111110), 0.5f, true),
+        arguments(mutator, Stream.of(false, 0f), Stream.of(false, 1, 0B01111111), 1.0f, true),
+        // mutateMantissa
+        arguments(mutator, Stream.of(false, 0f), Stream.of(false, 2, 0, 100), 1.4e-43f, true),
+        arguments(mutator, Stream.of(false, Float.intBitsToFloat(1)), Stream.of(false, 2, 0, -1), 0,
+            true),
+        // mutateWithMathematicalFn
+        arguments(
+            mutator, Stream.of(false, 10.1f), Stream.of(false, 3, 4), 11f, true), // Math::ceil
+        arguments(
+            mutator, Stream.of(false, 1000f), Stream.of(false, 3, 11), 3f, true), // Math::log10
+        // skip libfuzzer
+        // random in range
+        arguments(mutator, Stream.of(false, 0f), Stream.of(false, 5, 10f), 10f, true),
+        // unknown mutation case exception
+        arguments(mutator, Stream.of(false, 0f), Stream.of(false, 6), UNUSED_FLOAT, false));
+  }
+
+  static Stream<Arguments> floatMutateLimitedRangeCases() {
+    SerializingMutator<Float> mutator =
+        (SerializingMutator<Float>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull @FloatInRange(min = -1f, max = 1f, allowNaN = false) Float>() {
+            }.annotatedType());
+    // Init value can be set to desired one by giving this to the init method: (false, <desired
+    // value>)
+    return Stream.of(
+        // Bit flip; forceInRange(); result equals previous value; adjust value
+        arguments(mutator, Stream.of(false, 0f), Stream.of(false, 0, 30, true),
+            0f - Float.MIN_VALUE, true),
+        arguments(mutator, Stream.of(false, 1f), Stream.of(false, 0, 30), Math.nextDown(1f), true),
+        arguments(mutator, Stream.of(false, -1f), Stream.of(false, 0, 30), Math.nextUp(-1f), true),
+        // NaN after mutateWithMathematicalFn with NaN not allowed; forceInRange will return
+        // (min+max)/2
+        arguments(mutator, Stream.of(false, -1f), Stream.of(false, 3, 16), 0.0f, true));
+  }
+
+  static Stream<Arguments> floatMutateLimitedRangeCasesWithNaN() {
+    SerializingMutator<Float> mutator =
+        (SerializingMutator<Float>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull @FloatInRange(min = -1f, max = 1f, allowNaN = true) Float>() {
+            }.annotatedType());
+    // Init value can be set to desired one by giving this to the init method: (false, <desired
+    // value>)
+    return Stream.of(
+        // NaN after mutation and forceInRange(); all good!
+        arguments(mutator, Stream.of(false, -1f), Stream.of(false, 3, 16), Float.NaN, true),
+        // NaN (with a set bit #8) after init, mutation, and forceInRange(); need to change NaN to
+        // something else
+        arguments(mutator, Stream.of(true, 6), Stream.of(false, 0, 8, 0.3f), 0.3f, true));
+  }
+
+  @ParameterizedTest
+  @MethodSource({"floatMutateSanityChecksFullRangeCases", "floatMutateLimitedRangeCases",
+      "floatMutateLimitedRangeCasesWithNaN"})
+  void
+  testFloatMutateCases(SerializingMutator<Float> mutator, Stream<Object> initValues,
+      Stream<Object> mutationValues, float expected, boolean knownMutatorSwitchCase) {
+    assertThat(mutator.toString()).isEqualTo("Float");
+    Float n;
+
+    // Init
+    try (TestSupport.MockPseudoRandom prng = mockPseudoRandom(initValues.toArray())) {
+      n = mutator.init(prng);
+    }
+
+    // Mutate
+    if (knownMutatorSwitchCase) {
+      try (TestSupport.MockPseudoRandom prng = mockPseudoRandom(mutationValues.toArray())) {
+        n = mutator.mutate(n, prng);
+      }
+      assertThat(n).isEqualTo(expected);
+
+      if (!((FloatMutator) mutator).allowNaN) {
+        assertThat(n).isNotEqualTo(Float.NaN);
+      }
+
+      if (!Float.isNaN(n)) {
+        assertThat(n).isAtLeast(((FloatMutator) mutator).minValue);
+        assertThat(n).isAtMost(((FloatMutator) mutator).maxValue);
+      }
+    } else { // Invalid mutation because a case is not handled
+      assertThrows(AssertionError.class, () -> {
+        try (TestSupport.MockPseudoRandom prng = mockPseudoRandom(mutationValues.toArray())) {
+          mutator.mutate(UNUSED_FLOAT, prng);
+        }
+      });
+    }
+  }
+
+  @Test
+  void testFloatCrossOverMean() {
+    SerializingMutator<Float> mutator =
+        (SerializingMutator<Float>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull Float>() {}.annotatedType());
+    try (TestSupport.MockPseudoRandom prng =
+             mockPseudoRandom(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) {
+      assertThat(mutator.crossOver(0f, 0f, prng)).isWithin(0).of(0f);
+      assertThat(mutator.crossOver(-0f, 0f, prng)).isWithin(0).of(0f);
+      assertThat(mutator.crossOver(0f, 2f, prng)).isWithin(1e-10f).of(1.0f);
+      assertThat(mutator.crossOver(1f, 2f, prng)).isWithin(1e-10f).of(1.5f);
+      assertThat(mutator.crossOver(1f, 3f, prng)).isWithin(1e-10f).of(2f);
+      assertThat(mutator.crossOver(Float.MAX_VALUE, Float.MAX_VALUE, prng))
+          .isWithin(1e-10f)
+          .of(Float.MAX_VALUE);
+
+      assertThat(mutator.crossOver(0f, -2f, prng)).isWithin(1e-10f).of(-1.0f);
+      assertThat(mutator.crossOver(-1f, -2f, prng)).isWithin(1e-10f).of(-1.5f);
+      assertThat(mutator.crossOver(-1f, -3f, prng)).isWithin(1e-10f).of(-2f);
+      assertThat(mutator.crossOver(-Float.MAX_VALUE, -Float.MAX_VALUE, prng))
+          .isWithin(1e-10f)
+          .of(-Float.MAX_VALUE);
+
+      assertThat(mutator.crossOver(-100f, 200f, prng)).isWithin(1e-10f).of(50.0f);
+      assertThat(mutator.crossOver(100f, -200f, prng)).isWithin(1e-10f).of(-50f);
+      assertThat(mutator.crossOver(-Float.MAX_VALUE, Float.MAX_VALUE, prng))
+          .isWithin(1e-10f)
+          .of(0f);
+
+      assertThat(mutator.crossOver(Float.NEGATIVE_INFINITY, Float.POSITIVE_INFINITY, prng)).isNaN();
+      assertThat(mutator.crossOver(Float.POSITIVE_INFINITY, 0f, prng)).isPositiveInfinity();
+      assertThat(mutator.crossOver(0f, Float.POSITIVE_INFINITY, prng)).isPositiveInfinity();
+      assertThat(mutator.crossOver(Float.NEGATIVE_INFINITY, 0f, prng)).isNegativeInfinity();
+      assertThat(mutator.crossOver(0f, Float.NEGATIVE_INFINITY, prng)).isNegativeInfinity();
+      assertThat(mutator.crossOver(Float.NaN, 0f, prng)).isNaN();
+      assertThat(mutator.crossOver(0f, Float.NaN, prng)).isNaN();
+    }
+  }
+
+  @Test
+  void testFloatCrossOverExponent() {
+    SerializingMutator<Float> mutator =
+        (SerializingMutator<Float>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull Float>() {}.annotatedType());
+    try (TestSupport.MockPseudoRandom prng = mockPseudoRandom(1, 1, 1)) {
+      assertThat(mutator.crossOver(2.0f, -1.5f, prng)).isWithin(1e-10f).of(1.0f);
+      assertThat(mutator.crossOver(2.0f, Float.POSITIVE_INFINITY, prng)).isPositiveInfinity();
+      assertThat(mutator.crossOver(-1.5f, Float.NEGATIVE_INFINITY, prng)).isNaN();
+    }
+  }
+
+  @Test
+  void testFloatCrossOverMantissa() {
+    SerializingMutator<Float> mutator =
+        (SerializingMutator<Float>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull Float>() {}.annotatedType());
+    try (TestSupport.MockPseudoRandom prng = mockPseudoRandom(2, 2, 2)) {
+      assertThat(mutator.crossOver(4.0f, 3.5f, prng)).isWithin(1e-10f).of(7.0f);
+      assertThat(mutator.crossOver(Float.POSITIVE_INFINITY, 3.0f, prng)).isNaN();
+      assertThat(mutator.crossOver(Float.MAX_VALUE, 0.0f, prng)).isWithin(1e-10f).of(1.7014118e38f);
+    }
+  }
+
+  static Stream<Arguments> doubleInitCasesFullRange() {
+    SerializingMutator<Double> mutator =
+        (SerializingMutator<Double>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull Double>() {}.annotatedType());
+    Supplier<Integer> ctr = makeCounter();
+    return Stream.of(arguments(mutator, Stream.of(true, ctr.get()), Double.NEGATIVE_INFINITY, true),
+        arguments(mutator, Stream.of(true, ctr.get()), -Double.MAX_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), -Double.MIN_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), -0.0, true),
+        arguments(mutator, Stream.of(true, ctr.get()), 0.0, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Double.MIN_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Double.MAX_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Double.POSITIVE_INFINITY, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Double.NaN, true),
+        arguments(mutator, Stream.of(true, ctr.get()), UNUSED_DOUBLE, false));
+  }
+
+  static Stream<Arguments> doubleInitCasesMinusOneToOne() {
+    SerializingMutator<Double> mutator =
+        (SerializingMutator<Double>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull @DoubleInRange(min = -1.0, max = 1.0) Double>() {
+            }.annotatedType());
+    Supplier<Integer> ctr = makeCounter();
+    return Stream.of(arguments(mutator, Stream.of(true, ctr.get()), -1.0, true),
+        arguments(mutator, Stream.of(true, ctr.get()), -Double.MIN_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), -0.0, true),
+        arguments(mutator, Stream.of(true, ctr.get()), 0.0, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Double.MIN_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), 1.0, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Double.NaN, true),
+        arguments(mutator, Stream.of(true, ctr.get()), UNUSED_DOUBLE, false));
+  }
+
+  static Stream<Arguments> doubleInitCasesMinusMinToMin() {
+    SerializingMutator<Double> mutator =
+        (SerializingMutator<Double>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull @DoubleInRange(
+                min = -Double.MIN_VALUE, max = Double.MIN_VALUE) Double>() {
+            }.annotatedType());
+    Supplier<Integer> ctr = makeCounter();
+    return Stream.of(arguments(mutator, Stream.of(true, ctr.get()), -Double.MIN_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), -0.0, true),
+        arguments(mutator, Stream.of(true, ctr.get()), 0.0, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Double.MIN_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Double.NaN, true),
+        arguments(mutator, Stream.of(true, ctr.get()), UNUSED_DOUBLE, false));
+  }
+
+  static Stream<Arguments> doubleInitCasesMaxToInf() {
+    SerializingMutator<Double> mutator =
+        (SerializingMutator<Double>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull @DoubleInRange(
+                min = Double.MAX_VALUE, max = Double.POSITIVE_INFINITY) Double>() {
+            }.annotatedType());
+    Supplier<Integer> ctr = makeCounter();
+    return Stream.of(arguments(mutator, Stream.of(true, ctr.get()), Double.MAX_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Double.POSITIVE_INFINITY, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Double.NaN, true),
+        arguments(mutator, Stream.of(true, ctr.get()), UNUSED_DOUBLE, false));
+  }
+
+  static Stream<Arguments> doubleInitCasesMinusInfToMinusMax() {
+    SerializingMutator<Double> mutator =
+        (SerializingMutator<Double>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull @DoubleInRange(
+                min = Double.NEGATIVE_INFINITY, max = -Double.MAX_VALUE) Double>() {
+            }.annotatedType());
+    Supplier<Integer> ctr = makeCounter();
+    return Stream.of(arguments(mutator, Stream.of(true, ctr.get()), Double.NEGATIVE_INFINITY, true),
+        arguments(mutator, Stream.of(true, ctr.get()), -Double.MAX_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Double.NaN, true),
+        arguments(mutator, Stream.of(true, ctr.get()), UNUSED_DOUBLE, false));
+  }
+
+  static Stream<Arguments> doubleInitCasesFullRangeWithoutNaN() {
+    SerializingMutator<Double> mutator =
+        (SerializingMutator<Double>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull @DoubleInRange(min = Double.NEGATIVE_INFINITY,
+                max = Double.POSITIVE_INFINITY, allowNaN = true) Double>() {
+            }.annotatedType());
+    Supplier<Integer> ctr = makeCounter();
+    return Stream.of(arguments(mutator, Stream.of(true, ctr.get()), Double.NEGATIVE_INFINITY, true),
+        arguments(mutator, Stream.of(true, ctr.get()), -Double.MAX_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), -Double.MIN_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), -0.0, true),
+        arguments(mutator, Stream.of(true, ctr.get()), 0.0, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Double.MIN_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Double.MAX_VALUE, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Double.POSITIVE_INFINITY, true),
+        arguments(mutator, Stream.of(true, ctr.get()), Double.NaN, true),
+        arguments(mutator, Stream.of(true, ctr.get()), UNUSED_DOUBLE, false));
+  }
+
+  @ParameterizedTest
+  @MethodSource({"doubleInitCasesMinusOneToOne", "doubleInitCasesFullRange",
+      "doubleInitCasesMinusMinToMin", "doubleInitCasesMaxToInf",
+      "doubleInitCasesMinusInfToMinusMax", "doubleInitCasesFullRangeWithoutNaN"})
+  void
+  testDoubleInitCases(SerializingMutator<Double> mutator, Stream<Object> prngValues,
+      double expected, boolean knownSwitchCase) {
+    assertThat(mutator.toString()).isEqualTo("Double");
+    if (knownSwitchCase) {
+      Double n = null;
+      try (TestSupport.MockPseudoRandom prng = mockPseudoRandom(prngValues.toArray())) {
+        n = mutator.init(prng);
+      }
+      assertThat(n).isEqualTo(expected);
+    } else {
+      assertThrows(AssertionError.class, () -> {
+        try (TestSupport.MockPseudoRandom prng = mockPseudoRandom(prngValues.toArray())) {
+          mutator.init(prng);
+        }
+      });
+    }
+  }
+
+  static Stream<Arguments> doubleMutateSanityChecksFullRangeCases() {
+    SerializingMutator<Double> mutator =
+        (SerializingMutator<Double>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull @DoubleInRange(min = Double.NEGATIVE_INFINITY,
+                max = Double.POSITIVE_INFINITY, allowNaN = true) Double>() {
+            }.annotatedType());
+    // Init value can be set to desired one by giving this to the init method: (false, <desired
+    // value>)
+    return Stream.of(
+        // Bit flips
+        arguments(mutator, Stream.of(false, 0.0), Stream.of(false, 0, 0), Double.MIN_VALUE, true),
+        arguments(mutator, Stream.of(false, 0.0), Stream.of(false, 0, 62), 2.0, true),
+        arguments(mutator, Stream.of(false, 2.0), Stream.of(false, 0, 63), -2.0, true),
+        arguments(mutator, Stream.of(false, -2.0), Stream.of(false, 0, 51), -3.0, true),
+        // mutateExponent
+        arguments(mutator, Stream.of(false, 0.0), Stream.of(false, 1, 0B1111111100), 0.125, true),
+        arguments(mutator, Stream.of(false, 0.0), Stream.of(false, 1, 0B1111111110), 0.5, true),
+        arguments(mutator, Stream.of(false, 0.0), Stream.of(false, 1, 0B1111111111), 1.0, true),
+        // mutateMantissa
+        arguments(mutator, Stream.of(false, 0.0), Stream.of(false, 2, 0, 100L), 4.94e-322, true),
+        arguments(mutator, Stream.of(false, Double.longBitsToDouble(1)),
+            Stream.of(false, 2, 0, -1L), 0, true),
+        // mutateWithMathematicalFn
+        arguments(mutator, Stream.of(false, 10.1), Stream.of(false, 3, 4), 11, true), // Math::ceil
+        arguments(
+            mutator, Stream.of(false, 1000.0), Stream.of(false, 3, 11), 3, true), // Math::log10
+        // skip libfuzzer
+        // random in range
+        arguments(mutator, Stream.of(false, 0.0), Stream.of(false, 5, 10.0), 10, true),
+        // unknown mutation case exception
+        arguments(mutator, Stream.of(false, 0.0), Stream.of(false, 6), UNUSED_DOUBLE, false));
+  }
+
+  static Stream<Arguments> doubleMutateLimitedRangeCases() {
+    SerializingMutator<Double> mutator =
+        (SerializingMutator<Double>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull @DoubleInRange(min = -1, max = 1, allowNaN = false) Double>() {
+            }.annotatedType());
+    // Init value can be set to desired one by giving this to the init method: (false, <desired
+    // value>)
+    return Stream.of(
+        // Bit flip; forceInRange(); result equals previous value; adjust value
+        arguments(mutator, Stream.of(false, 0.0), Stream.of(false, 0, 62, true),
+            0.0 - Double.MIN_VALUE, true),
+        arguments(
+            mutator, Stream.of(false, 1.0), Stream.of(false, 0, 62), Math.nextDown(1.0), true),
+        arguments(
+            mutator, Stream.of(false, -1.0), Stream.of(false, 0, 62), Math.nextUp(-1.0), true),
+        // NaN after mutateWithMathematicalFn: sqrt(-1.0); NaN not allowed; forceInRange will return
+        // (min+max)/2
+        arguments(mutator, Stream.of(false, -1.0), Stream.of(false, 3, 16), 0.0, true));
+  }
+
+  static Stream<Arguments> doubleMutateLimitedRangeCasesWithNaN() {
+    SerializingMutator<Double> mutator =
+        (SerializingMutator<Double>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull @DoubleInRange(min = -1, max = 1, allowNaN = true) Double>() {
+            }.annotatedType());
+    // Init value can be set to desired one by giving this to the init method: (false, <desired
+    // value>)
+    return Stream.of(
+        // NaN after mutation and forceInRange(); all good!
+        arguments(mutator, Stream.of(false, -1.0), Stream.of(false, 3, 16), Double.NaN, true),
+        // NaN (with a set bit #8) after init, mutation, and forceInRange(); need to change NaN to
+        // something else
+        arguments(mutator, Stream.of(true, 6), Stream.of(false, 0, 8, 0.3), 0.3, true));
+  }
+
+  @ParameterizedTest
+  @MethodSource({"doubleMutateSanityChecksFullRangeCases", "doubleMutateLimitedRangeCases",
+      "doubleMutateLimitedRangeCasesWithNaN"})
+  void
+  testDoubleMutateCases(SerializingMutator<Double> mutator, Stream<Object> initValues,
+      Stream<Object> mutationValues, double expected, boolean knownSwitchCase) {
+    assertThat(mutator.toString()).isEqualTo("Double");
+    Double n;
+
+    // Init
+    try (TestSupport.MockPseudoRandom prng = mockPseudoRandom(initValues.toArray())) {
+      n = mutator.init(prng);
+    }
+
+    // Mutate
+    if (knownSwitchCase) {
+      try (TestSupport.MockPseudoRandom prng = mockPseudoRandom(mutationValues.toArray())) {
+        n = mutator.mutate(n, prng);
+      }
+      assertThat(n).isEqualTo(expected);
+
+      if (!((DoubleMutator) mutator).allowNaN) {
+        assertThat(n).isNotEqualTo(Double.NaN);
+      }
+
+      if (!Double.isNaN(n)) {
+        assertThat(n).isAtLeast(((DoubleMutator) mutator).minValue);
+        assertThat(n).isAtMost(((DoubleMutator) mutator).maxValue);
+      }
+    } else { // Invalid mutation because a case is not handled
+      assertThrows(AssertionError.class, () -> {
+        try (TestSupport.MockPseudoRandom prng = mockPseudoRandom(mutationValues.toArray())) {
+          mutator.mutate(UNUSED_DOUBLE, prng);
+        }
+      });
+    }
+  }
+
+  @Test
+  void testDoubleCrossOverMean() {
+    SerializingMutator<Double> mutator =
+        (SerializingMutator<Double>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull Double>() {}.annotatedType());
+    try (TestSupport.MockPseudoRandom prng =
+             mockPseudoRandom(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) {
+      assertThat(mutator.crossOver(0.0, 0.0, prng)).isWithin(0).of(0f);
+      assertThat(mutator.crossOver(-0.0, 0.0, prng)).isWithin(0).of(0f);
+      assertThat(mutator.crossOver(0.0, 2.0, prng)).isWithin(1e-10f).of(1.0f);
+      assertThat(mutator.crossOver(1.0, 2.0, prng)).isWithin(1e-10f).of(1.5f);
+      assertThat(mutator.crossOver(1.0, 3.0, prng)).isWithin(1e-10f).of(2f);
+      assertThat(mutator.crossOver(Double.MAX_VALUE, Double.MAX_VALUE, prng))
+          .isWithin(1e-10f)
+          .of(Double.MAX_VALUE);
+
+      assertThat(mutator.crossOver(0.0, -2.0, prng)).isWithin(1e-10f).of(-1.0f);
+      assertThat(mutator.crossOver(-1.0, -2.0, prng)).isWithin(1e-10f).of(-1.5f);
+      assertThat(mutator.crossOver(-1.0, -3.0, prng)).isWithin(1e-10f).of(-2f);
+      assertThat(mutator.crossOver(-Double.MAX_VALUE, -Double.MAX_VALUE, prng))
+          .isWithin(1e-10f)
+          .of(-Double.MAX_VALUE);
+
+      assertThat(mutator.crossOver(-100.0, 200.0, prng)).isWithin(1e-10f).of(50.0f);
+      assertThat(mutator.crossOver(100.0, -200.0, prng)).isWithin(1e-10f).of(-50f);
+      assertThat(mutator.crossOver(-Double.MAX_VALUE, Double.MAX_VALUE, prng))
+          .isWithin(1e-10f)
+          .of(0f);
+
+      assertThat(mutator.crossOver(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, prng))
+          .isNaN();
+      assertThat(mutator.crossOver(Double.POSITIVE_INFINITY, 0.0, prng)).isPositiveInfinity();
+      assertThat(mutator.crossOver(0.0, Double.POSITIVE_INFINITY, prng)).isPositiveInfinity();
+      assertThat(mutator.crossOver(Double.NEGATIVE_INFINITY, 0.0, prng)).isNegativeInfinity();
+      assertThat(mutator.crossOver(0.0, Double.NEGATIVE_INFINITY, prng)).isNegativeInfinity();
+      assertThat(mutator.crossOver(Double.NaN, 0.0, prng)).isNaN();
+      assertThat(mutator.crossOver(0.0, Double.NaN, prng)).isNaN();
+    }
+  }
+
+  @Test
+  void testDoubleCrossOverExponent() {
+    SerializingMutator<Double> mutator =
+        (SerializingMutator<Double>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull Double>() {}.annotatedType());
+    try (TestSupport.MockPseudoRandom prng = mockPseudoRandom(1, 1, 1)) {
+      assertThat(mutator.crossOver(2.0, -1.5, prng)).isWithin(1e-10f).of(1.0f);
+      assertThat(mutator.crossOver(2.0, Double.POSITIVE_INFINITY, prng)).isPositiveInfinity();
+      assertThat(mutator.crossOver(-1.5, Double.NEGATIVE_INFINITY, prng)).isNaN();
+    }
+  }
+
+  @Test
+  void testDoubleCrossOverMantissa() {
+    SerializingMutator<Double> mutator =
+        (SerializingMutator<Double>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull Double>() {}.annotatedType());
+    try (TestSupport.MockPseudoRandom prng = mockPseudoRandom(2, 2, 2)) {
+      assertThat(mutator.crossOver(4.0, 3.5, prng)).isWithin(1e-10f).of(7.0f);
+      assertThat(mutator.crossOver(Double.POSITIVE_INFINITY, 3.0, prng)).isNaN();
+      assertThat(mutator.crossOver(Double.MAX_VALUE, 0.0, prng))
+          .isWithin(1e-10f)
+          .of(8.98846567431158e307);
+    }
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/IntegralMutatorTest.java b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/IntegralMutatorTest.java
new file mode 100644
index 0000000..dda8cfe
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/IntegralMutatorTest.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.lang;
+
+import static com.code_intelligence.jazzer.mutation.mutator.lang.IntegralMutatorFactory.AbstractIntegralMutator.forceInRange;
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.mockPseudoRandom;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+import com.code_intelligence.jazzer.mutation.annotation.NotNull;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.code_intelligence.jazzer.mutation.support.TestSupport.MockPseudoRandom;
+import com.code_intelligence.jazzer.mutation.support.TypeHolder;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+@SuppressWarnings("unchecked")
+class IntegralMutatorTest {
+  static Stream<Arguments> forceInRangeCases() {
+    return Stream.of(arguments(0, 0, 1), arguments(5, 0, 1), arguments(-5, -10, -1),
+        arguments(-200, -10, -1), arguments(10, 0, 3), arguments(-5, 0, 3), arguments(10, -7, 7),
+        arguments(Long.MIN_VALUE, Long.MIN_VALUE, Long.MAX_VALUE),
+        arguments(Long.MIN_VALUE, Long.MIN_VALUE, 100),
+        arguments(Long.MIN_VALUE + 100, Long.MIN_VALUE, 100),
+        arguments(Long.MAX_VALUE, -100, Long.MAX_VALUE),
+        arguments(Long.MAX_VALUE - 100, -100, Long.MAX_VALUE),
+        arguments(Long.MAX_VALUE, Long.MIN_VALUE, Long.MAX_VALUE),
+        arguments(Long.MIN_VALUE, Long.MIN_VALUE + 1, Long.MAX_VALUE),
+        arguments(Long.MAX_VALUE, Long.MIN_VALUE, Long.MAX_VALUE - 1),
+        arguments(Long.MIN_VALUE, Long.MAX_VALUE - 5, Long.MAX_VALUE),
+        arguments(Long.MAX_VALUE, Long.MIN_VALUE, Long.MIN_VALUE + 5));
+  }
+
+  @ParameterizedTest
+  @MethodSource("forceInRangeCases")
+  void testForceInRange(long value, long minValue, long maxValue) {
+    long inRange = forceInRange(value, minValue, maxValue);
+    assertThat(inRange).isAtLeast(minValue);
+    assertThat(inRange).isAtMost(maxValue);
+    if (value >= minValue && value <= maxValue) {
+      assertThat(inRange).isEqualTo(value);
+    }
+  }
+
+  @Test
+  void testCrossOver() {
+    SerializingMutator<Long> mutator =
+        (SerializingMutator<Long>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull Long>() {}.annotatedType());
+    // cross over mean values
+    try (MockPseudoRandom prng = mockPseudoRandom(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) {
+      assertThat(mutator.crossOver(0L, 0L, prng)).isEqualTo(0);
+      assertThat(mutator.crossOver(0L, 2L, prng)).isEqualTo(1);
+      assertThat(mutator.crossOver(1L, 2L, prng)).isEqualTo(1);
+      assertThat(mutator.crossOver(1L, 3L, prng)).isEqualTo(2);
+      assertThat(mutator.crossOver(Long.MAX_VALUE, Long.MAX_VALUE, prng)).isEqualTo(Long.MAX_VALUE);
+
+      assertThat(mutator.crossOver(0L, -2L, prng)).isEqualTo(-1);
+      assertThat(mutator.crossOver(-1L, -2L, prng)).isEqualTo(-1);
+      assertThat(mutator.crossOver(-1L, -3L, prng)).isEqualTo(-2);
+      assertThat(mutator.crossOver(Long.MIN_VALUE, Long.MIN_VALUE, prng)).isEqualTo(Long.MIN_VALUE);
+
+      assertThat(mutator.crossOver(-100L, 200L, prng)).isEqualTo(50);
+      assertThat(mutator.crossOver(100L, -200L, prng)).isEqualTo(-50);
+      assertThat(mutator.crossOver(Long.MIN_VALUE, Long.MAX_VALUE, prng)).isEqualTo(0);
+    }
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/NullableMutatorTest.java b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/NullableMutatorTest.java
new file mode 100644
index 0000000..bc9a65b
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/NullableMutatorTest.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.lang;
+
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.mockPseudoRandom;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.code_intelligence.jazzer.mutation.annotation.NotNull;
+import com.code_intelligence.jazzer.mutation.api.ChainedMutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.code_intelligence.jazzer.mutation.support.TestSupport.MockPseudoRandom;
+import com.code_intelligence.jazzer.mutation.support.TypeHolder;
+import java.lang.reflect.AnnotatedType;
+import org.junit.jupiter.api.Test;
+
+@SuppressWarnings("unchecked")
+class NullableMutatorTest {
+  @Test
+  void testNullable() {
+    SerializingMutator<Boolean> mutator =
+        new ChainedMutatorFactory(new NullableMutatorFactory(), new BooleanMutatorFactory())
+            .createOrThrow(Boolean.class);
+    assertThat(mutator.toString()).isEqualTo("Nullable<Boolean>");
+
+    Boolean bool;
+    try (MockPseudoRandom prng = mockPseudoRandom(/* init to null */ true)) {
+      bool = mutator.init(prng);
+    }
+    assertThat(bool).isNull();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(/* init for non-null Boolean */ false)) {
+      bool = mutator.mutate(bool, prng);
+    }
+    assertThat(bool).isFalse();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(/* mutate to non-null Boolean */ false)) {
+      bool = mutator.mutate(bool, prng);
+    }
+    assertThat(bool).isTrue();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(/* mutate to null */ true)) {
+      bool = mutator.mutate(bool, prng);
+    }
+    assertThat(bool).isNull();
+  }
+
+  @Test
+  void testNotNull() {
+    ChainedMutatorFactory factory =
+        new ChainedMutatorFactory(new NullableMutatorFactory(), new BooleanMutatorFactory());
+    AnnotatedType notNullBoolean = new TypeHolder<@NotNull Boolean>() {}.annotatedType();
+    SerializingMutator<Boolean> mutator =
+        (SerializingMutator<Boolean>) factory.createOrThrow(notNullBoolean);
+    assertThat(mutator.toString()).isEqualTo("Boolean");
+  }
+
+  @Test
+  void testPrimitive() {
+    ChainedMutatorFactory factory =
+        new ChainedMutatorFactory(new NullableMutatorFactory(), new BooleanMutatorFactory());
+    SerializingMutator<Boolean> mutator = factory.createOrThrow(boolean.class);
+    assertThat(mutator.toString()).isEqualTo("Boolean");
+  }
+
+  @Test
+  void testCrossOver() {
+    SerializingMutator<Boolean> mutator =
+        new ChainedMutatorFactory(new NullableMutatorFactory(), new BooleanMutatorFactory())
+            .createOrThrow(Boolean.class);
+    try (MockPseudoRandom prng = mockPseudoRandom(true)) {
+      Boolean valueCrossedOver = mutator.crossOver(Boolean.TRUE, Boolean.TRUE, prng);
+      assertThat(valueCrossedOver).isNotNull();
+    }
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      Boolean bothNull = mutator.crossOver(null, null, prng);
+      assertThat(bothNull).isNull();
+    }
+    try (MockPseudoRandom prng = mockPseudoRandom(false)) {
+      Boolean oneNotNull = mutator.crossOver(null, Boolean.TRUE, prng);
+      assertThat(oneNotNull).isNotNull();
+    }
+    try (MockPseudoRandom prng = mockPseudoRandom(true)) {
+      Boolean nullFrequency = mutator.crossOver(null, Boolean.TRUE, prng);
+      assertThat(nullFrequency).isNull();
+    }
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/StringMutatorTest.java b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/StringMutatorTest.java
new file mode 100644
index 0000000..2306035
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/StringMutatorTest.java
@@ -0,0 +1,224 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.lang;
+
+import static com.code_intelligence.jazzer.mutation.mutator.lang.StringMutatorFactory.fixUpAscii;
+import static com.code_intelligence.jazzer.mutation.mutator.lang.StringMutatorFactory.fixUpUtf8;
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.mockPseudoRandom;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.code_intelligence.jazzer.mutation.annotation.NotNull;
+import com.code_intelligence.jazzer.mutation.annotation.WithUtf8Length;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.code_intelligence.jazzer.mutation.mutator.libfuzzer.LibFuzzerMutator;
+import com.code_intelligence.jazzer.mutation.support.RandomSupport;
+import com.code_intelligence.jazzer.mutation.support.TestSupport.MockPseudoRandom;
+import com.code_intelligence.jazzer.mutation.support.TypeHolder;
+import com.google.protobuf.ByteString;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.SplittableRandom;
+import org.junit.jupiter.api.*;
+
+class StringMutatorTest {
+  /**
+   * Some tests may set {@link LibFuzzerMutator#MOCK_SIZE_KEY} which can interfere with other tests
+   * unless cleared.
+   */
+  @AfterEach
+  void cleanMockSize() {
+    System.clearProperty(LibFuzzerMutator.MOCK_SIZE_KEY);
+  }
+
+  @RepeatedTest(10)
+  void testFixAscii_randomInputFixed(RepetitionInfo info) {
+    SplittableRandom random = new SplittableRandom(
+        (long) "testFixAscii_randomInputFixed".hashCode() * info.getCurrentRepetition());
+
+    for (int length = 0; length < 1000; length++) {
+      byte[] randomBytes = generateRandomBytes(random, length);
+      byte[] copy = Arrays.copyOf(randomBytes, randomBytes.length);
+      fixUpAscii(copy);
+      if (isValidAscii(randomBytes)) {
+        assertThat(copy).isEqualTo(randomBytes);
+      } else {
+        assertThat(isValidAscii(copy)).isTrue();
+      }
+    }
+  }
+
+  @RepeatedTest(10)
+  void testFixAscii_validInputNotChanged(RepetitionInfo info) {
+    SplittableRandom random = new SplittableRandom(
+        (long) "testFixAscii_validInputNotChanged".hashCode() * info.getCurrentRepetition());
+
+    for (int codePoints = 0; codePoints < 1000; codePoints++) {
+      byte[] validAscii = generateValidAsciiBytes(random, codePoints);
+      byte[] copy = Arrays.copyOf(validAscii, validAscii.length);
+      fixUpAscii(copy);
+      assertThat(copy).isEqualTo(validAscii);
+    }
+  }
+
+  @RepeatedTest(20)
+  void testFixUtf8_randomInputFixed(RepetitionInfo info) {
+    SplittableRandom random = new SplittableRandom(
+        (long) "testFixUtf8_randomInputFixed".hashCode() * info.getCurrentRepetition());
+
+    for (int length = 0; length < 1000; length++) {
+      byte[] randomBytes = generateRandomBytes(random, length);
+      byte[] copy = Arrays.copyOf(randomBytes, randomBytes.length);
+      fixUpUtf8(copy);
+      if (isValidUtf8(randomBytes)) {
+        assertThat(copy).isEqualTo(randomBytes);
+      } else {
+        assertThat(isValidUtf8(copy)).isTrue();
+      }
+    }
+  }
+
+  @RepeatedTest(20)
+  void testFixUtf8_validInputNotChanged(RepetitionInfo info) {
+    SplittableRandom random = new SplittableRandom(
+        (long) "testFixUtf8_validInputNotChanged".hashCode() * info.getCurrentRepetition());
+
+    for (int codePoints = 0; codePoints < 1000; codePoints++) {
+      byte[] validUtf8 = generateValidUtf8Bytes(random, codePoints);
+      byte[] copy = Arrays.copyOf(validUtf8, validUtf8.length);
+      fixUpUtf8(copy);
+      assertThat(copy).isEqualTo(validUtf8);
+    }
+  }
+
+  @Test
+  void testMinLengthInit() {
+    SerializingMutator<String> mutator =
+        (SerializingMutator<String>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull @WithUtf8Length(min = 10) String>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("String");
+
+    try (MockPseudoRandom prng = mockPseudoRandom(5)) {
+      // mock prng should throw an assert error when given a lower value than min
+      Assertions.assertThrows(AssertionError.class, () -> { String s = mutator.init(prng); });
+    }
+  }
+
+  @Test
+  void testMaxLengthInit() {
+    SerializingMutator<String> mutator =
+        (SerializingMutator<String>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull @WithUtf8Length(max = 50) String>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("String");
+
+    try (MockPseudoRandom prng = mockPseudoRandom(60)) {
+      // mock prng should throw an assert error when given a value higher than max
+      Assertions.assertThrows(AssertionError.class, () -> { String s = mutator.init(prng); });
+    }
+  }
+
+  @Test
+  void testMinLengthMutate() {
+    SerializingMutator<String> mutator =
+        (SerializingMutator<String>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull @WithUtf8Length(min = 10) String>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("String");
+
+    String s;
+    try (MockPseudoRandom prng = mockPseudoRandom(10, "foobarbazf".getBytes())) {
+      s = mutator.init(prng);
+    }
+    assertThat(s).isEqualTo("foobarbazf");
+
+    System.setProperty(LibFuzzerMutator.MOCK_SIZE_KEY, "5");
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      s = mutator.mutate(s, prng);
+    }
+    assertThat(s).isEqualTo("gqrff\0\0\0\0\0");
+  }
+
+  @Test
+  void testMaxLengthMutate() {
+    SerializingMutator<String> mutator =
+        (SerializingMutator<String>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull @WithUtf8Length(max = 15) String>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("String");
+
+    String s;
+    try (MockPseudoRandom prng = mockPseudoRandom(10, "foobarbazf".getBytes())) {
+      s = mutator.init(prng);
+    }
+    assertThat(s).isEqualTo("foobarbazf");
+
+    System.setProperty(LibFuzzerMutator.MOCK_SIZE_KEY, "20");
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      Assertions.assertThrows(
+          ArrayIndexOutOfBoundsException.class, () -> { String s2 = mutator.mutate(s, prng); });
+    }
+  }
+
+  @Test
+  void testMultibyteCharacters() {
+    SerializingMutator<String> mutator =
+        (SerializingMutator<String>) LangMutators.newFactory().createOrThrow(
+            new TypeHolder<@NotNull @WithUtf8Length(min = 10) String>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("String");
+
+    String s;
+    try (
+        MockPseudoRandom prng = mockPseudoRandom(10, "foobarÖÖ".getBytes(StandardCharsets.UTF_8))) {
+      s = mutator.init(prng);
+    }
+    assertThat(s).hasLength(8);
+    assertThat(s).isEqualTo("foobarÖÖ");
+  }
+
+  private static boolean isValidUtf8(byte[] data) {
+    return ByteString.copyFrom(data).isValidUtf8();
+  }
+
+  private static boolean isValidAscii(byte[] data) {
+    for (byte b : data) {
+      if ((b & 0xFF) > 0x7F) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  private static byte[] generateRandomBytes(SplittableRandom random, int length) {
+    byte[] bytes = new byte[length];
+    RandomSupport.nextBytes(random, bytes);
+    return bytes;
+  }
+
+  private static byte[] generateValidAsciiBytes(SplittableRandom random, int length) {
+    return random.ints(0, 0x7F)
+        .limit(length)
+        .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
+        .toString()
+        .getBytes(StandardCharsets.UTF_8);
+  }
+
+  private static byte[] generateValidUtf8Bytes(SplittableRandom random, long codePoints) {
+    return random.ints(0, Character.MAX_CODE_POINT + 1)
+        .filter(code -> code < Character.MIN_SURROGATE || code > Character.MAX_SURROGATE)
+        .limit(codePoints)
+        .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
+        .toString()
+        .getBytes(StandardCharsets.UTF_8);
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto/BUILD.bazel b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto/BUILD.bazel
new file mode 100644
index 0000000..bf8b551
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto/BUILD.bazel
@@ -0,0 +1,60 @@
+load("@contrib_rules_jvm//java:defs.bzl", "java_test_suite")
+
+proto_library(
+    name = "proto3_proto",
+    srcs = ["proto3.proto"],
+    deps = [
+        "@com_google_protobuf//:any_proto",
+    ],
+)
+
+java_proto_library(
+    name = "proto3_java_proto",
+    testonly = True,
+    visibility = ["//src/test/java/com/code_intelligence/jazzer/mutation/mutator:__pkg__"],
+    deps = [":proto3_proto"],
+)
+
+proto_library(
+    name = "proto2_proto",
+    srcs = ["proto2.proto"],
+)
+
+java_proto_library(
+    name = "proto2_java_proto",
+    testonly = True,
+    visibility = [
+        "//src/test/java/com/code_intelligence/jazzer/mutation/mutator:__pkg__",
+        "//tests:__pkg__",
+    ],
+    deps = [":proto2_proto"],
+)
+
+cc_proto_library(
+    name = "proto2_cc_proto",
+    testonly = True,
+    visibility = [
+        "//tests:__pkg__",
+    ],
+    deps = [":proto2_proto"],
+)
+
+java_test_suite(
+    name = "ProtoTests",
+    size = "small",
+    srcs = glob(["*.java"]),
+    runner = "junit5",
+    deps = [
+        ":proto2_java_proto",
+        ":proto3_java_proto",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/annotation",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/annotation/proto",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/api",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/mutator/collection",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/mutator/proto",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/support",
+        "//src/test/java/com/code_intelligence/jazzer/mutation/support:test_support",
+        "@com_google_protobuf//java/core",
+    ],
+)
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto/BuilderAdaptersTest.java b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto/BuilderAdaptersTest.java
new file mode 100644
index 0000000..7722a6a
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto/BuilderAdaptersTest.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.proto;
+
+import static com.code_intelligence.jazzer.mutation.mutator.proto.BuilderAdapters.makeMutableRepeatedFieldView;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import com.code_intelligence.jazzer.protobuf.Proto3.RepeatedIntegralField3;
+import com.google.protobuf.Descriptors.FieldDescriptor;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class BuilderAdaptersTest {
+  @Test
+  void testMakeMutableRepeatedFieldView() {
+    RepeatedIntegralField3.Builder builder = RepeatedIntegralField3.newBuilder();
+    FieldDescriptor someField = builder.getDescriptorForType().findFieldByNumber(1);
+    assertThat(someField).isNotNull();
+
+    List<Integer> view = makeMutableRepeatedFieldView(builder, someField);
+    assertThat(builder.build().getSomeFieldList()).isEmpty();
+
+    assertThat(view.add(1)).isTrue();
+    assertThat(view.get(0)).isEqualTo(1);
+    assertThat(view).hasSize(1);
+    assertThat(builder.build().getSomeFieldList()).containsExactly(1).inOrder();
+    assertThrows(IndexOutOfBoundsException.class, () -> view.get(1));
+
+    assertThat(view.add(2)).isTrue();
+    assertThat(view.add(3)).isTrue();
+    assertThat(view).hasSize(3);
+    assertThat(builder.build().getSomeFieldList()).containsExactly(1, 2, 3).inOrder();
+    assertThrows(IndexOutOfBoundsException.class, () -> view.get(3));
+
+    assertThat(view.set(1, 4)).isEqualTo(2);
+    assertThat(view).hasSize(3);
+    assertThat(builder.build().getSomeFieldList()).containsExactly(1, 4, 3).inOrder();
+
+    assertThat(view.set(1, 5)).isEqualTo(4);
+    assertThat(view).hasSize(3);
+    assertThat(builder.build().getSomeFieldList()).containsExactly(1, 5, 3).inOrder();
+
+    assertThat(view.remove(1)).isEqualTo(5);
+    assertThat(view).hasSize(2);
+    assertThat(builder.build().getSomeFieldList()).containsExactly(1, 3).inOrder();
+
+    assertThrows(IndexOutOfBoundsException.class, () -> view.remove(-1));
+    assertThrows(IndexOutOfBoundsException.class, () -> view.remove(2));
+
+    assertThat(view.addAll(1, Collections.emptyList())).isFalse();
+    assertThat(view).hasSize(2);
+    assertThat(builder.build().getSomeFieldList()).containsExactly(1, 3).inOrder();
+
+    assertThat(view.addAll(1, Arrays.asList(6, 7, 8))).isTrue();
+    assertThat(view).hasSize(5);
+    assertThat(builder.build().getSomeFieldList()).containsExactly(1, 6, 7, 8, 3).inOrder();
+
+    view.subList(2, 4).clear();
+    assertThat(view).hasSize(3);
+    assertThat(builder.build().getSomeFieldList()).containsExactly(1, 6, 3).inOrder();
+
+    assertThat(view.addAll(3, Arrays.asList(9, 10))).isTrue();
+    assertThat(view).hasSize(5);
+    assertThat(builder.build().getSomeFieldList()).containsExactly(1, 6, 3, 9, 10).inOrder();
+
+    view.clear();
+    assertThat(view).hasSize(0);
+    assertThat(builder.build().getSomeFieldList()).isEmpty();
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto/BuilderMutatorProto2Test.java b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto/BuilderMutatorProto2Test.java
new file mode 100644
index 0000000..9492bce
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto/BuilderMutatorProto2Test.java
@@ -0,0 +1,447 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.proto;
+
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.mockPseudoRandom;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+
+import com.code_intelligence.jazzer.mutation.annotation.NotNull;
+import com.code_intelligence.jazzer.mutation.api.ChainedMutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.InPlaceMutator;
+import com.code_intelligence.jazzer.mutation.api.MutatorFactory;
+import com.code_intelligence.jazzer.mutation.mutator.collection.CollectionMutators;
+import com.code_intelligence.jazzer.mutation.mutator.lang.LangMutators;
+import com.code_intelligence.jazzer.mutation.support.TestSupport.MockPseudoRandom;
+import com.code_intelligence.jazzer.mutation.support.TypeHolder;
+import com.code_intelligence.jazzer.protobuf.Proto2.MessageField2;
+import com.code_intelligence.jazzer.protobuf.Proto2.OneOfField2;
+import com.code_intelligence.jazzer.protobuf.Proto2.PrimitiveField2;
+import com.code_intelligence.jazzer.protobuf.Proto2.RecursiveMessageField2;
+import com.code_intelligence.jazzer.protobuf.Proto2.RepeatedMessageField2;
+import com.code_intelligence.jazzer.protobuf.Proto2.RepeatedOptionalMessageField2;
+import com.code_intelligence.jazzer.protobuf.Proto2.RepeatedPrimitiveField2;
+import com.code_intelligence.jazzer.protobuf.Proto2.RequiredPrimitiveField2;
+import org.junit.jupiter.api.Test;
+
+class BuilderMutatorProto2Test {
+  private static final MutatorFactory FACTORY = new ChainedMutatorFactory(
+      LangMutators.newFactory(), CollectionMutators.newFactory(), ProtoMutators.newFactory());
+
+  @Test
+  void testPrimitiveField() {
+    InPlaceMutator<PrimitiveField2.Builder> mutator =
+        (InPlaceMutator<PrimitiveField2.Builder>) FACTORY.createInPlaceOrThrow(
+            new TypeHolder<PrimitiveField2.@NotNull Builder>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("{Builder.Nullable<Boolean>}");
+
+    PrimitiveField2.Builder builder = PrimitiveField2.newBuilder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // present
+             false,
+             // boolean
+             false)) {
+      mutator.initInPlace(builder, prng);
+    }
+    assertThat(builder.hasSomeField()).isTrue();
+    assertThat(builder.getSomeField()).isFalse();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // present
+             false,
+             // boolean
+             true)) {
+      mutator.initInPlace(builder, prng);
+    }
+    assertThat(builder.hasSomeField()).isTrue();
+    assertThat(builder.getSomeField()).isTrue();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate first field
+             0,
+             // mutate as non-null Boolean
+             false)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.hasSomeField()).isTrue();
+    assertThat(builder.getSomeField()).isFalse();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // not present
+             true)) {
+      mutator.initInPlace(builder, prng);
+    }
+    assertThat(builder.hasSomeField()).isFalse();
+    assertThat(builder.getSomeField()).isFalse();
+  }
+
+  @Test
+  void testRequiredPrimitiveField() {
+    InPlaceMutator<RequiredPrimitiveField2.Builder> mutator =
+        (InPlaceMutator<RequiredPrimitiveField2.Builder>) FACTORY.createInPlaceOrThrow(
+            new TypeHolder<RequiredPrimitiveField2.@NotNull Builder>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("{Builder.Boolean}");
+
+    RequiredPrimitiveField2.Builder builder = RequiredPrimitiveField2.newBuilder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(true)) {
+      mutator.initInPlace(builder, prng);
+    }
+    assertThat(builder.getSomeField()).isTrue();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(/* mutate first field */ 0)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.getSomeField()).isFalse();
+  }
+
+  @Test
+  void testRepeatedPrimitiveField() {
+    InPlaceMutator<RepeatedPrimitiveField2.Builder> mutator =
+        (InPlaceMutator<RepeatedPrimitiveField2.Builder>) FACTORY.createInPlaceOrThrow(
+            new TypeHolder<RepeatedPrimitiveField2.@NotNull Builder>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("{Builder via List<Boolean>}");
+
+    RepeatedPrimitiveField2.Builder builder = RepeatedPrimitiveField2.newBuilder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // list size 1
+             1,
+             // boolean,
+             true)) {
+      mutator.initInPlace(builder, prng);
+    }
+    assertThat(builder.getSomeFieldList()).containsExactly(true).inOrder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate first field
+             0,
+             // mutate the list itself by adding an entry
+             1,
+             // add a single element
+             1,
+             // add the element at the end
+             1,
+             // value to add
+             true)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.getSomeFieldList()).containsExactly(true, true).inOrder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate first field
+             0,
+             // mutate the list itself by changing an entry
+             2,
+             // mutate a single element
+             1,
+             // mutate the second element
+             1)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.getSomeFieldList()).containsExactly(true, false).inOrder();
+  }
+
+  @Test
+  void testMessageField() {
+    InPlaceMutator<MessageField2.Builder> mutator =
+        (InPlaceMutator<MessageField2.Builder>) FACTORY.createInPlaceOrThrow(
+            new TypeHolder<MessageField2.@NotNull Builder>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("{Builder.Nullable<{Builder.Boolean} -> Message>}");
+
+    MessageField2.Builder builder = MessageField2.newBuilder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // init submessage
+             false,
+             // boolean submessage field
+             true)) {
+      mutator.initInPlace(builder, prng);
+    }
+
+    assertThat(builder.getMessageField())
+        .isEqualTo(RequiredPrimitiveField2.newBuilder().setSomeField(true).build());
+    assertThat(builder.hasMessageField()).isTrue();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate first field
+             0,
+             // mutate submessage as non-null
+             false,
+             // mutate first field
+             0)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.getMessageField())
+        .isEqualTo(RequiredPrimitiveField2.newBuilder().setSomeField(false).build());
+    assertThat(builder.hasMessageField()).isTrue();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate first field
+             0,
+             // mutate submessage to null
+             true)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.hasMessageField()).isFalse();
+  }
+
+  @Test
+  void testRepeatedOptionalMessageField() {
+    InPlaceMutator<RepeatedOptionalMessageField2.Builder> mutator =
+        (InPlaceMutator<RepeatedOptionalMessageField2.Builder>) FACTORY.createInPlaceOrThrow(
+            new TypeHolder<RepeatedOptionalMessageField2.@NotNull Builder>() {}.annotatedType());
+    assertThat(mutator.toString())
+        .isEqualTo("{Builder via List<{Builder.Nullable<Boolean>} -> Message>}");
+
+    RepeatedOptionalMessageField2.Builder builder = RepeatedOptionalMessageField2.newBuilder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // list size 1
+             1,
+             // boolean
+             true)) {
+      mutator.initInPlace(builder, prng);
+    }
+    assertThat(builder.getMessageFieldList().toString()).isEqualTo("[]");
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate first field
+             0,
+             // mutate the list itself by adding an entry
+             1,
+             // add a single element
+             1,
+             // add the element at the end
+             1,
+             // Nullable mutator init
+             false,
+             // duplicate entry
+             true)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.getMessageFieldList().size()).isEqualTo(2);
+  }
+
+  @Test
+  void testRepeatedRequiredMessageField() {
+    InPlaceMutator<RepeatedMessageField2.Builder> mutator =
+        (InPlaceMutator<RepeatedMessageField2.Builder>) FACTORY.createInPlaceOrThrow(
+            new TypeHolder<RepeatedMessageField2.@NotNull Builder>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("{Builder via List<{Builder.Boolean} -> Message>}");
+
+    RepeatedMessageField2.Builder builder = RepeatedMessageField2.newBuilder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // list size 1
+             1,
+             // boolean
+             true)) {
+      mutator.initInPlace(builder, prng);
+    }
+    assertThat(builder.getMessageFieldList())
+        .containsExactly(RequiredPrimitiveField2.newBuilder().setSomeField(true).build())
+        .inOrder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate first field
+             0,
+             // mutate the list itself by adding an entry
+             1,
+             // add a single element
+             1,
+             // add the element at the end
+             1,
+             // value to add
+             true)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.getMessageFieldList())
+        .containsExactly(RequiredPrimitiveField2.newBuilder().setSomeField(true).build(),
+            RequiredPrimitiveField2.newBuilder().setSomeField(true).build())
+        .inOrder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate first field
+             0,
+             // change an entry
+             2,
+             // mutate a single element
+             1,
+             // mutate the second element,
+             1,
+             // mutate the first element
+             0)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.getMessageFieldList())
+        .containsExactly(RequiredPrimitiveField2.newBuilder().setSomeField(true).build(),
+            RequiredPrimitiveField2.newBuilder().setSomeField(false).build())
+        .inOrder();
+  }
+
+  @Test
+  void testRecursiveMessageField() {
+    InPlaceMutator<RecursiveMessageField2.Builder> mutator =
+        (InPlaceMutator<RecursiveMessageField2.Builder>) FACTORY.createInPlaceOrThrow(
+            new TypeHolder<RecursiveMessageField2.@NotNull Builder>() {}.annotatedType());
+    assertThat(mutator.toString())
+        .isEqualTo("{Builder.Boolean, WithoutInit(Builder.Nullable<(cycle) -> Message>)}");
+    RecursiveMessageField2.Builder builder = RecursiveMessageField2.newBuilder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // boolean
+             true)) {
+      mutator.initInPlace(builder, prng);
+    }
+
+    assertThat(builder.build())
+        .isEqualTo(RecursiveMessageField2.newBuilder().setSomeField(true).build());
+    assertThat(builder.hasMessageField()).isFalse();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate message field (causes init to non-null)
+             1,
+             // bool field in message field
+             false)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    // Nested message field *is* set explicitly and implicitly equal to the default
+    // instance.
+    assertThat(builder.build())
+        .isEqualTo(RecursiveMessageField2.newBuilder()
+                       .setSomeField(true)
+                       .setMessageField(RecursiveMessageField2.newBuilder().setSomeField(false))
+                       .build());
+    assertThat(builder.hasMessageField()).isTrue();
+    assertThat(builder.getMessageField().hasMessageField()).isFalse();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate message field
+             1,
+             //  message field as not null
+             false,
+             // mutate message field
+             1,
+             // nested boolean,
+             true)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.build())
+        .isEqualTo(RecursiveMessageField2.newBuilder()
+                       .setSomeField(true)
+                       .setMessageField(
+                           RecursiveMessageField2.newBuilder().setSomeField(false).setMessageField(
+                               RecursiveMessageField2.newBuilder().setSomeField(true)))
+                       .build());
+    assertThat(builder.hasMessageField()).isTrue();
+    assertThat(builder.getMessageField().hasMessageField()).isTrue();
+    assertThat(builder.getMessageField().getMessageField().hasMessageField()).isFalse();
+  }
+
+  @Test
+  void testOneOfField2() {
+    InPlaceMutator<OneOfField2.Builder> mutator =
+        (InPlaceMutator<OneOfField2.Builder>) FACTORY.createInPlaceOrThrow(
+            new TypeHolder<OneOfField2.@NotNull Builder>() {}.annotatedType());
+    assertThat(mutator.toString())
+        .isEqualTo(
+            "{Builder.Boolean, Builder.Nullable<Boolean>, Builder.Nullable<Boolean> | Builder.Nullable<{Builder.Boolean} -> Message>}");
+    OneOfField2.Builder builder = OneOfField2.newBuilder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // other_field
+             true,
+             // yet_another_field
+             true,
+             // oneof: first field
+             0,
+             // bool_field present
+             false,
+             // bool_field
+             true)) {
+      mutator.initInPlace(builder, prng);
+    }
+    assertThat(builder.build())
+        .isEqualTo(OneOfField2.newBuilder().setOtherField(true).setBoolField(true).build());
+    assertThat(builder.build().hasBoolField()).isTrue();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate oneof
+             2,
+             // preserve oneof state
+             false,
+             // mutate bool_field as non-null
+             false)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.build())
+        .isEqualTo(OneOfField2.newBuilder().setOtherField(true).setBoolField(false).build());
+    assertThat(builder.build().hasBoolField()).isTrue();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate oneof
+             2,
+             // switch oneof state
+             true,
+             // new oneof state
+             1,
+             // init message_field as non-null
+             false,
+             // init some_field as true
+             true)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.build())
+        .isEqualTo(OneOfField2.newBuilder()
+                       .setOtherField(true)
+                       .setMessageField(RequiredPrimitiveField2.newBuilder().setSomeField(true))
+                       .build());
+    assertThat(builder.build().hasMessageField()).isTrue();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate oneof
+             2,
+             // preserve oneof state
+             false,
+             // mutate message_field as non-null
+             false,
+             // mutate some_field
+             0)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.build())
+        .isEqualTo(OneOfField2.newBuilder()
+                       .setOtherField(true)
+                       .setMessageField(RequiredPrimitiveField2.newBuilder().setSomeField(false))
+                       .build());
+    assertThat(builder.build().hasMessageField()).isTrue();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate oneof
+             2,
+             // preserve oneof state
+             false,
+             // mutate message_field to null (and thus oneof state to indeterminate)
+             true)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.build()).isEqualTo(OneOfField2.newBuilder().setOtherField(true).build());
+    assertThat(builder.build().hasMessageField()).isFalse();
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto/BuilderMutatorProto3Test.java b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto/BuilderMutatorProto3Test.java
new file mode 100644
index 0000000..ff29854
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto/BuilderMutatorProto3Test.java
@@ -0,0 +1,603 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.proto;
+
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.mockPseudoRandom;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+
+import com.code_intelligence.jazzer.mutation.annotation.NotNull;
+import com.code_intelligence.jazzer.mutation.annotation.proto.AnySource;
+import com.code_intelligence.jazzer.mutation.api.ChainedMutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.InPlaceMutator;
+import com.code_intelligence.jazzer.mutation.api.MutatorFactory;
+import com.code_intelligence.jazzer.mutation.mutator.collection.CollectionMutators;
+import com.code_intelligence.jazzer.mutation.mutator.lang.LangMutators;
+import com.code_intelligence.jazzer.mutation.support.TestSupport.MockPseudoRandom;
+import com.code_intelligence.jazzer.mutation.support.TypeHolder;
+import com.code_intelligence.jazzer.protobuf.Proto3.AnyField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.AnyField3.Builder;
+import com.code_intelligence.jazzer.protobuf.Proto3.EmptyMessage3;
+import com.code_intelligence.jazzer.protobuf.Proto3.EnumField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.EnumField3.TestEnum;
+import com.code_intelligence.jazzer.protobuf.Proto3.EnumFieldOne3;
+import com.code_intelligence.jazzer.protobuf.Proto3.EnumFieldOne3.TestEnumOne;
+import com.code_intelligence.jazzer.protobuf.Proto3.EnumFieldOutside3;
+import com.code_intelligence.jazzer.protobuf.Proto3.EnumFieldRepeated3;
+import com.code_intelligence.jazzer.protobuf.Proto3.EnumFieldRepeated3.TestEnumRepeated;
+import com.code_intelligence.jazzer.protobuf.Proto3.MessageField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.OneOfField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.OptionalPrimitiveField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.PrimitiveField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.RecursiveMessageField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.RepeatedMessageField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.RepeatedPrimitiveField3;
+import com.code_intelligence.jazzer.protobuf.Proto3.TestEnumOutside3;
+import com.google.protobuf.InvalidProtocolBufferException;
+import java.util.Arrays;
+import org.junit.jupiter.api.Test;
+
+class BuilderMutatorProto3Test {
+  private static final MutatorFactory FACTORY = new ChainedMutatorFactory(
+      LangMutators.newFactory(), CollectionMutators.newFactory(), ProtoMutators.newFactory());
+
+  @Test
+  void testPrimitiveField() {
+    InPlaceMutator<PrimitiveField3.Builder> mutator =
+        (InPlaceMutator<PrimitiveField3.Builder>) FACTORY.createInPlaceOrThrow(
+            new TypeHolder<PrimitiveField3.@NotNull Builder>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("{Builder.Boolean}");
+
+    PrimitiveField3.Builder builder = PrimitiveField3.newBuilder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(true)) {
+      mutator.initInPlace(builder, prng);
+    }
+    assertThat(builder.getSomeField()).isTrue();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(/* mutate first field */ 0)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.getSomeField()).isFalse();
+  }
+
+  @Test
+  void testEnumField() {
+    InPlaceMutator<EnumField3.Builder> mutator =
+        (InPlaceMutator<EnumField3.Builder>) FACTORY.createInPlaceOrThrow(
+            new TypeHolder<EnumField3.@NotNull Builder>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("{Builder.Enum<TestEnum>}");
+    EnumField3.Builder builder = EnumField3.newBuilder();
+    try (MockPseudoRandom prng = mockPseudoRandom(0)) {
+      mutator.initInPlace(builder, prng);
+    }
+    assertThat(builder.getSomeField()).isEqualTo(TestEnum.VAL1);
+    try (MockPseudoRandom prng = mockPseudoRandom(0, 1)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.getSomeField()).isEqualTo(TestEnum.VAL2);
+  }
+
+  @Test
+  void testEnumFieldOutside() {
+    InPlaceMutator<EnumFieldOutside3.Builder> mutator =
+        (InPlaceMutator<EnumFieldOutside3.Builder>) FACTORY.createInPlaceOrThrow(
+            new TypeHolder<EnumFieldOutside3.@NotNull Builder>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("{Builder.Enum<TestEnumOutside3>}");
+    EnumFieldOutside3.Builder builder = EnumFieldOutside3.newBuilder();
+    try (MockPseudoRandom prng = mockPseudoRandom(0)) {
+      mutator.initInPlace(builder, prng);
+    }
+    assertThat(builder.getSomeField()).isEqualTo(TestEnumOutside3.VAL1);
+    try (MockPseudoRandom prng = mockPseudoRandom(0, 2)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.getSomeField()).isEqualTo(TestEnumOutside3.VAL3);
+  }
+
+  @Test
+  void testEnumFieldWithOneValue() {
+    InPlaceMutator<EnumFieldOne3.Builder> mutator =
+        (InPlaceMutator<EnumFieldOne3.Builder>) FACTORY.createInPlaceOrThrow(
+            new TypeHolder<EnumFieldOne3.@NotNull Builder>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("{Builder.FixedValue(ONE)}");
+    EnumFieldOne3.Builder builder = EnumFieldOne3.newBuilder();
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      mutator.initInPlace(builder, prng);
+    }
+    assertThat(builder.getSomeField()).isEqualTo(TestEnumOne.ONE);
+    try (MockPseudoRandom prng = mockPseudoRandom(0)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.getSomeField()).isEqualTo(TestEnumOne.ONE);
+  }
+
+  @Test
+  void testRepeatedEnumField() {
+    InPlaceMutator<EnumFieldRepeated3.Builder> mutator =
+        (InPlaceMutator<EnumFieldRepeated3.Builder>) FACTORY.createInPlaceOrThrow(
+            new TypeHolder<EnumFieldRepeated3.@NotNull Builder>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("{Builder via List<Enum<TestEnumRepeated>>}");
+    EnumFieldRepeated3.Builder builder = EnumFieldRepeated3.newBuilder();
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // list size
+             1, // Only possible start value
+             // enum values
+             2)) {
+      mutator.initInPlace(builder, prng);
+    }
+    assertThat(builder.getSomeFieldList()).isEqualTo(Arrays.asList(TestEnumRepeated.VAL2));
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate first field
+             0,
+             // change an entry
+             2,
+             // mutate a single element
+             1,
+             // mutate to first enum field
+             0,
+             // mutate to first enum value
+             1)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.getSomeFieldList()).isEqualTo(Arrays.asList(TestEnumRepeated.VAL1));
+  }
+
+  @Test
+  void testOptionalPrimitiveField() {
+    InPlaceMutator<OptionalPrimitiveField3.Builder> mutator =
+        (InPlaceMutator<OptionalPrimitiveField3.Builder>) FACTORY.createInPlaceOrThrow(
+            new TypeHolder<OptionalPrimitiveField3.@NotNull Builder>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("{Builder.Nullable<Boolean>}");
+
+    OptionalPrimitiveField3.Builder builder = OptionalPrimitiveField3.newBuilder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // present
+             false,
+             // boolean
+             false)) {
+      mutator.initInPlace(builder, prng);
+    }
+    assertThat(builder.hasSomeField()).isTrue();
+    assertThat(builder.getSomeField()).isFalse();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // present
+             false,
+             // boolean
+             true)) {
+      mutator.initInPlace(builder, prng);
+    }
+    assertThat(builder.hasSomeField()).isTrue();
+    assertThat(builder.getSomeField()).isTrue();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate first field
+             0,
+             // mutate as non-null Boolean
+             false)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.hasSomeField()).isTrue();
+    assertThat(builder.getSomeField()).isFalse();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // not present
+             true)) {
+      mutator.initInPlace(builder, prng);
+    }
+    assertThat(builder.hasSomeField()).isFalse();
+    assertThat(builder.getSomeField()).isFalse();
+  }
+
+  @Test
+  void testRepeatedPrimitiveField() {
+    InPlaceMutator<RepeatedPrimitiveField3.Builder> mutator =
+        (InPlaceMutator<RepeatedPrimitiveField3.Builder>) FACTORY.createInPlaceOrThrow(
+            new TypeHolder<RepeatedPrimitiveField3.@NotNull Builder>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("{Builder via List<Boolean>}");
+
+    RepeatedPrimitiveField3.Builder builder = RepeatedPrimitiveField3.newBuilder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // list size 1
+             1,
+             // boolean,
+             true)) {
+      mutator.initInPlace(builder, prng);
+    }
+    assertThat(builder.getSomeFieldList()).containsExactly(true).inOrder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate first field
+             0,
+             // mutate the list itself by adding an entry
+             1,
+             // add a single element
+             1,
+             // add the element at the end
+             1,
+             // value to add
+             true)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.getSomeFieldList()).containsExactly(true, true).inOrder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate first field
+             0,
+             // mutate the list itself by changing an entry
+             2,
+             // mutate a single element
+             1,
+             // mutate the second element
+             1)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.getSomeFieldList()).containsExactly(true, false).inOrder();
+  }
+
+  @Test
+  void testMessageField() {
+    InPlaceMutator<MessageField3.Builder> mutator =
+        (InPlaceMutator<MessageField3.Builder>) FACTORY.createInPlaceOrThrow(
+            new TypeHolder<MessageField3.@NotNull Builder>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("{Builder.Nullable<{Builder.Boolean} -> Message>}");
+
+    MessageField3.Builder builder = MessageField3.newBuilder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // init submessage
+             false,
+             // boolean submessage field
+             true)) {
+      mutator.initInPlace(builder, prng);
+    }
+    assertThat(builder.getMessageField())
+        .isEqualTo(PrimitiveField3.newBuilder().setSomeField(true).build());
+    assertThat(builder.hasMessageField()).isTrue();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate first field
+             0,
+             // mutate submessage as non-null
+             false,
+             // mutate first field
+             0)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.getMessageField())
+        .isEqualTo(PrimitiveField3.newBuilder().setSomeField(false).build());
+    assertThat(builder.hasMessageField()).isTrue();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate first field
+             0,
+             // mutate submessage to null
+             true)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.hasMessageField()).isFalse();
+  }
+
+  @Test
+  void testRepeatedMessageField() {
+    InPlaceMutator<RepeatedMessageField3.Builder> mutator =
+        (InPlaceMutator<RepeatedMessageField3.Builder>) FACTORY.createInPlaceOrThrow(
+            new TypeHolder<RepeatedMessageField3.@NotNull Builder>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("{Builder via List<{Builder.Boolean} -> Message>}");
+
+    RepeatedMessageField3.Builder builder = RepeatedMessageField3.newBuilder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // list size 1
+             1,
+             // boolean
+             true)) {
+      mutator.initInPlace(builder, prng);
+    }
+    assertThat(builder.getMessageFieldList())
+        .containsExactly(PrimitiveField3.newBuilder().setSomeField(true).build())
+        .inOrder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate first field
+             0,
+             // mutate the list itself by adding an entry
+             1,
+             // add a single element
+             1,
+             // add the element at the end
+             1,
+             // value to add
+             true)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.getMessageFieldList())
+        .containsExactly(PrimitiveField3.newBuilder().setSomeField(true).build(),
+            PrimitiveField3.newBuilder().setSomeField(true).build())
+        .inOrder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate first field
+             0,
+             // change an entry
+             2,
+             // mutate a single element
+             1,
+             // mutate the second element,
+             1,
+             // mutate the first element
+             0)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.getMessageFieldList())
+        .containsExactly(PrimitiveField3.newBuilder().setSomeField(true).build(),
+            PrimitiveField3.newBuilder().setSomeField(false).build())
+        .inOrder();
+  }
+
+  @Test
+  void testRecursiveMessageField() {
+    InPlaceMutator<RecursiveMessageField3.Builder> mutator =
+        (InPlaceMutator<RecursiveMessageField3.Builder>) FACTORY.createInPlaceOrThrow(
+            new TypeHolder<RecursiveMessageField3.@NotNull Builder>() {}.annotatedType());
+    assertThat(mutator.toString())
+        .isEqualTo("{Builder.Boolean, WithoutInit(Builder.Nullable<(cycle) -> Message>)}");
+    RecursiveMessageField3.Builder builder = RecursiveMessageField3.newBuilder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // boolean
+             true)) {
+      mutator.initInPlace(builder, prng);
+    }
+
+    assertThat(builder.build())
+        .isEqualTo(RecursiveMessageField3.newBuilder().setSomeField(true).build());
+    assertThat(builder.hasMessageField()).isFalse();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate message field (causes init to non-null)
+             1,
+             // bool field in message field
+             false)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    // Nested message field *is* set explicitly and implicitly equal to the default
+    // instance.
+    assertThat(builder.build())
+        .isEqualTo(RecursiveMessageField3.newBuilder()
+                       .setSomeField(true)
+                       .setMessageField(RecursiveMessageField3.newBuilder().setSomeField(false))
+                       .build());
+    assertThat(builder.hasMessageField()).isTrue();
+    assertThat(builder.getMessageField().hasMessageField()).isFalse();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate message field
+             1,
+             //  message field as not null
+             false,
+             // mutate message field
+             1,
+             // nested boolean,
+             true)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.build())
+        .isEqualTo(RecursiveMessageField3.newBuilder()
+                       .setSomeField(true)
+                       .setMessageField(
+                           RecursiveMessageField3.newBuilder().setSomeField(false).setMessageField(
+                               RecursiveMessageField3.newBuilder().setSomeField(true)))
+                       .build());
+    assertThat(builder.hasMessageField()).isTrue();
+    assertThat(builder.getMessageField().hasMessageField()).isTrue();
+    assertThat(builder.getMessageField().getMessageField().hasMessageField()).isFalse();
+  }
+
+  @Test
+  void testOneOfField3() {
+    InPlaceMutator<OneOfField3.Builder> mutator =
+        (InPlaceMutator<OneOfField3.Builder>) FACTORY.createInPlaceOrThrow(
+            new TypeHolder<OneOfField3.@NotNull Builder>() {}.annotatedType());
+    assertThat(mutator.toString())
+        .isEqualTo(
+            "{Builder.Boolean, Builder.Boolean, Builder.Nullable<Boolean> | Builder.Nullable<{Builder.Boolean} -> Message>}");
+    OneOfField3.Builder builder = OneOfField3.newBuilder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // other_field
+             true,
+             // yet_another_field
+             true,
+             // oneof: first field
+             0,
+             // bool_field present
+             false,
+             // bool_field
+             true)) {
+      mutator.initInPlace(builder, prng);
+    }
+    assertThat(builder.build())
+        .isEqualTo(OneOfField3.newBuilder()
+                       .setOtherField(true)
+                       .setBoolField(true)
+                       .setYetAnotherField(true)
+                       .build());
+    assertThat(builder.build().hasBoolField()).isTrue();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate oneof
+             2,
+             // preserve oneof state
+             false,
+             // mutate bool_field as non-null
+             false)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.build())
+        .isEqualTo(OneOfField3.newBuilder()
+                       .setOtherField(true)
+                       .setBoolField(false)
+                       .setYetAnotherField(true)
+                       .build());
+    assertThat(builder.build().hasBoolField()).isTrue();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate oneof
+             2,
+             // switch oneof state
+             true,
+             // new oneof state
+             1,
+             // init message_field as non-null
+             false,
+             // init some_field as true
+             true)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.build())
+        .isEqualTo(OneOfField3.newBuilder()
+                       .setOtherField(true)
+                       .setMessageField(PrimitiveField3.newBuilder().setSomeField(true))
+                       .setYetAnotherField(true)
+                       .build());
+    assertThat(builder.build().hasMessageField()).isTrue();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate oneof
+             2,
+             // preserve oneof state
+             false,
+             // mutate message_field as non-null
+             false,
+             // mutate some_field
+             0)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.build())
+        .isEqualTo(OneOfField3.newBuilder()
+                       .setOtherField(true)
+                       .setMessageField(PrimitiveField3.newBuilder().setSomeField(false))
+                       .setYetAnotherField(true)
+                       .build());
+    assertThat(builder.build().hasMessageField()).isTrue();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate oneof
+             2,
+             // preserve oneof state
+             false,
+             // mutate message_field to null (and thus oneof state to indeterminate)
+             true)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.build())
+        .isEqualTo(OneOfField3.newBuilder().setOtherField(true).setYetAnotherField(true).build());
+    assertThat(builder.build().hasMessageField()).isFalse();
+  }
+
+  @Test
+  void testEmptyMessage3() {
+    InPlaceMutator<EmptyMessage3.Builder> mutator =
+        (InPlaceMutator<EmptyMessage3.Builder>) FACTORY.createInPlaceOrThrow(
+            new TypeHolder<EmptyMessage3.@NotNull Builder>() {}.annotatedType());
+    assertThat(mutator.toString()).isEqualTo("{<empty>}");
+    EmptyMessage3.Builder builder = EmptyMessage3.newBuilder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      mutator.initInPlace(builder, prng);
+    }
+    assertThat(builder.build()).isEqualTo(EmptyMessage3.getDefaultInstance());
+
+    try (MockPseudoRandom prng = mockPseudoRandom()) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.build()).isEqualTo(EmptyMessage3.getDefaultInstance());
+  }
+
+  @Test
+  void testAnyField3() throws InvalidProtocolBufferException {
+    InPlaceMutator<AnyField3.Builder> mutator =
+        (InPlaceMutator<AnyField3.Builder>) FACTORY.createInPlaceOrThrow(
+            new TypeHolder<@NotNull @AnySource(
+                {PrimitiveField3.class, MessageField3.class}) Builder>() {
+            }.annotatedType());
+    assertThat(mutator.toString())
+        .isEqualTo(
+            "{Builder.Nullable<Builder.{Builder.Boolean} -> Message | Builder.{Builder.Nullable<(cycle) -> Message>} -> Message -> Message>}");
+    AnyField3.Builder builder = AnyField3.newBuilder();
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // initialize message field
+             false,
+             // PrimitiveField3
+             0,
+             // boolean field
+             true)) {
+      mutator.initInPlace(builder, prng);
+    }
+    assertThat(builder.build().getSomeField().unpack(PrimitiveField3.class))
+        .isEqualTo(PrimitiveField3.newBuilder().setSomeField(true).build());
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate Any field
+             0,
+             // keep non-null message field
+             false,
+             // keep Any state,
+             false,
+             // mutate boolean field
+             0)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.build().getSomeField().unpack(PrimitiveField3.class))
+        .isEqualTo(PrimitiveField3.newBuilder().setSomeField(false).build());
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // mutate Any field
+             0,
+             // keep non-null message field
+             false,
+             // switch Any state
+             true,
+             // new Any state
+             1,
+             // non-null message
+             false,
+             // boolean field,
+             true)) {
+      mutator.mutateInPlace(builder, prng);
+    }
+    assertThat(builder.build().getSomeField().unpack(MessageField3.class))
+        .isEqualTo(MessageField3.newBuilder()
+                       .setMessageField(PrimitiveField3.newBuilder().setSomeField(true))
+                       .build());
+  }
+
+  @Test
+  void testAnyField3WithoutAnySourceDoesNotCrash() throws InvalidProtocolBufferException {
+    InPlaceMutator<AnyField3.Builder> mutator =
+        (InPlaceMutator<AnyField3.Builder>) FACTORY.createInPlaceOrThrow(
+            new TypeHolder<@NotNull Builder>() {}.annotatedType());
+    assertThat(mutator.toString())
+        .isEqualTo("{Builder.Nullable<{Builder.String, Builder.byte[] -> ByteString} -> Message>}");
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto/MessageMutatorTest.java b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto/MessageMutatorTest.java
new file mode 100644
index 0000000..b804c7f
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto/MessageMutatorTest.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.mutator.proto;
+
+import static com.code_intelligence.jazzer.mutation.support.TestSupport.mockPseudoRandom;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.code_intelligence.jazzer.mutation.annotation.NotNull;
+import com.code_intelligence.jazzer.mutation.api.ChainedMutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.MutatorFactory;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.code_intelligence.jazzer.mutation.mutator.collection.CollectionMutators;
+import com.code_intelligence.jazzer.mutation.mutator.lang.LangMutators;
+import com.code_intelligence.jazzer.mutation.support.TestSupport.MockPseudoRandom;
+import com.code_intelligence.jazzer.mutation.support.TypeHolder;
+import com.code_intelligence.jazzer.protobuf.Proto2.ExtendedMessage2;
+import com.code_intelligence.jazzer.protobuf.Proto2.ExtendedSubmessage2;
+import com.code_intelligence.jazzer.protobuf.Proto2.OriginalMessage2;
+import com.code_intelligence.jazzer.protobuf.Proto2.OriginalSubmessage2;
+import com.code_intelligence.jazzer.protobuf.Proto3.PrimitiveField3;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import org.junit.jupiter.api.Test;
+
+class MessageMutatorTest {
+  private static final MutatorFactory FACTORY = new ChainedMutatorFactory(
+      LangMutators.newFactory(), CollectionMutators.newFactory(), ProtoMutators.newFactory());
+
+  @Test
+  void testSimpleMessage() {
+    SerializingMutator<PrimitiveField3> mutator = FACTORY.createOrThrow(PrimitiveField3.class);
+
+    PrimitiveField3 msg;
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // not null
+             false,
+             // boolean
+             false)) {
+      msg = mutator.init(prng);
+      assertThat(msg).isEqualTo(PrimitiveField3.getDefaultInstance());
+    }
+
+    try (MockPseudoRandom prng = mockPseudoRandom(
+             // not null,
+             false,
+             // mutate first field
+             0)) {
+      msg = mutator.mutate(msg, prng);
+      assertThat(msg).isNotEqualTo(PrimitiveField3.getDefaultInstance());
+    }
+  }
+
+  @Test
+  void testIncompleteMessageWithRequiredFields() throws IOException {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    OriginalMessage2.newBuilder()
+        .setMessageField(OriginalSubmessage2.newBuilder().setNumericField(42).build())
+        .setBoolField(true)
+        .build()
+        .writeTo(out);
+    byte[] bytes = out.toByteArray();
+
+    SerializingMutator<ExtendedMessage2> mutator =
+        (SerializingMutator<ExtendedMessage2>) FACTORY.createOrThrow(
+            new TypeHolder<@NotNull ExtendedMessage2>() {}.annotatedType());
+    ExtendedMessage2 extendedMessage = mutator.readExclusive(new ByteArrayInputStream(bytes));
+    assertThat(extendedMessage)
+        .isEqualTo(ExtendedMessage2.newBuilder()
+                       .setMessageField(
+                           ExtendedSubmessage2.newBuilder().setNumericField(42).setMessageField(
+                               OriginalSubmessage2.newBuilder().setNumericField(0).build()))
+                       .setBoolField(true)
+                       .setFloatField(0)
+                       .build());
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto/proto2.proto b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto/proto2.proto
new file mode 100644
index 0000000..ea7c999
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto/proto2.proto
@@ -0,0 +1,161 @@
+// Copyright 2023 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto2";
+
+package com.code_intelligence.jazzer.protobuf;
+option java_package = "com.code_intelligence.jazzer.protobuf";
+
+message PrimitiveField2 {
+optional bool some_field = 1;
+}
+
+message RequiredPrimitiveField2 {
+required bool some_field = 1;
+}
+
+message RepeatedPrimitiveField2 {
+repeated bool some_field = 1;
+}
+
+message MessageField2 {
+optional RequiredPrimitiveField2 message_field = 1;
+}
+
+message RepeatedMessageField2 {
+repeated RequiredPrimitiveField2 message_field = 1;
+}
+
+message RepeatedOptionalMessageField2 {
+repeated PrimitiveField2 message_field = 1;
+}
+
+message RecursiveMessageField2 {
+required bool some_field = 1;
+optional RecursiveMessageField2 message_field = 2;
+}
+
+message RepeatedRecursiveMessageField2 {
+optional bool some_field = 1;
+repeated RepeatedRecursiveMessageField2 message_field = 2;
+}
+
+message OneOfField2 {
+required bool other_field = 4;
+oneof oneof_field {
+  bool bool_field = 7;
+  RequiredPrimitiveField2 message_field = 2;
+}
+optional bool yet_another_field = 1;
+}
+
+message IntegralField2 {
+optional uint32 some_field = 1;
+}
+
+message RepeatedIntegralField2 {
+repeated uint32 some_field = 1;
+}
+
+message BytesField2 {
+optional bytes some_field = 1;
+}
+
+message StringField2 {
+optional string some_field = 1;
+}
+
+message Parent {
+  optional Child child = 1;
+}
+
+message Child {
+  optional Parent parent = 1;
+}
+
+// Taken from
+// https://github.com/google/fuzztest/blob/c5fde4baee6134c84d4f2b618def9f60c7505151/fuzztest/internal/test_protobuf.proto#L24
+message TestSubProtobuf {
+  optional int32 subproto_i32 = 1;
+  repeated int32 subproto_rep_i32 = 2 [packed = true];
+  optional TestProtobuf parent = 3;
+}
+
+message TestProtobuf {
+  enum Enum {
+    Label1 = 0;
+    Label2 = 1;
+    Label3 = 2;
+    Label4 = 3;
+    Label5 = 4;
+  }
+
+  optional bool b = 1;
+  optional int32 i32 = 2;
+  optional uint32 u32 = 3;
+  optional int64 i64 = 4;
+  optional uint64 u64 = 5;
+  optional float f = 6;
+  optional double d = 7;
+  optional string str = 8;
+  optional Enum e = 9;
+  optional TestSubProtobuf subproto = 10;
+
+  repeated bool rep_b = 11;
+  repeated int32 rep_i32 = 12;
+  repeated uint32 rep_u32 = 13;
+  repeated int64 rep_i64 = 14;
+  repeated uint64 rep_u64 = 15;
+  repeated float rep_f = 16;
+  repeated double rep_d = 17;
+  repeated string rep_str = 18;
+  repeated Enum rep_e = 19;
+  repeated TestSubProtobuf rep_subproto = 20;
+
+  oneof oneof_field {
+    int32 oneof_i32 = 21;
+    int64 oneof_i64 = 22;
+    uint32 oneof_u32 = 24;
+  }
+
+  map<int32, int32> map_field = 25;
+
+  // Special cases
+  enum EnumOneLabel {
+    OnlyLabel = 17;
+  }
+  optional EnumOneLabel enum_one_label = 100;
+  message EmptyMessage {}
+  optional EmptyMessage empty_message = 101;
+}
+
+message OriginalSubmessage2 {
+  required int32 numeric_field = 1;
+}
+
+message OriginalMessage2 {
+  required OriginalSubmessage2 message_field = 1;
+  required bool bool_field = 2;
+}
+
+message ExtendedSubmessage2 {
+  required int32 numeric_field = 1;
+  required OriginalSubmessage2 message_field = 2;
+}
+
+message ExtendedMessage2 {
+  required ExtendedSubmessage2 message_field = 1;
+  required bool bool_field = 2;
+  required float float_field = 3;
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto/proto3.proto b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto/proto3.proto
new file mode 100644
index 0000000..7bd6ffe
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto/proto3.proto
@@ -0,0 +1,144 @@
+// Copyright 2023 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+import "google/protobuf/any.proto";
+
+option java_package = "com.code_intelligence.jazzer.protobuf";
+
+message PrimitiveField3 {
+  bool some_field = 1;
+}
+
+message OptionalPrimitiveField3 {
+  optional bool some_field = 1;
+}
+
+message RepeatedPrimitiveField3 {
+  repeated bool some_field = 1;
+}
+
+message MessageField3 {
+  PrimitiveField3 message_field = 1;
+}
+
+message RepeatedMessageField3 {
+  repeated PrimitiveField3 message_field = 1;
+}
+
+message RecursiveMessageField3 {
+  bool some_field = 1;
+  RecursiveMessageField3 message_field = 2;
+}
+
+message RepeatedRecursiveMessageField3 {
+  bool some_field = 1;
+  repeated RepeatedRecursiveMessageField3 message_field = 2;
+}
+
+message OneOfField3 {
+  bool other_field = 4;
+  oneof oneof_field {
+    bool bool_field = 7;
+    PrimitiveField3 message_field = 2;
+  }
+  bool yet_another_field = 1;
+}
+
+message IntegralField3 {
+  uint32 some_field = 1;
+}
+
+message RepeatedIntegralField3 {
+  repeated uint32 some_field = 1;
+}
+
+message BytesField3 {
+  bytes some_field = 1;
+}
+
+message StringField3 {
+  string some_field = 1;
+}
+
+message EnumField3 {
+  enum TestEnum {
+    VAL1 = 0;
+    VAL2 = 1;
+  }
+  TestEnum some_field = 1;
+}
+
+enum TestEnumOutside3 {
+  VAL1 = 0;
+  VAL2 = 1;
+  VAL3 = 3;
+}
+
+message EnumFieldOutside3 {
+  TestEnumOutside3 some_field = 1;
+}
+
+message EnumFieldOne3 {
+  enum TestEnumOne {
+    ONE = 0;
+  }
+  TestEnumOne some_field = 1;
+}
+
+message EnumFieldRepeated3 {
+  enum TestEnumRepeated {
+    UNASSIGNED = 0;
+    VAL1 = 1;
+    VAL2 = 2;
+  }
+  repeated TestEnumRepeated some_field = 1;
+}
+
+message MapField3 {
+  map<int32, string> some_field = 1;
+}
+
+message MessageMapField3 {
+  map<string, MapField3> some_field = 1;
+}
+
+message FloatField3 {
+  float some_field = 1;
+}
+
+message RepeatedFloatField3 {
+  repeated float some_field = 1;
+}
+
+message DoubleField3 {
+  double some_field = 1;
+}
+
+message RepeatedDoubleField3 {
+  repeated double some_field = 1;
+}
+
+message EmptyMessage3 {}
+
+message AnyField3 {
+  google.protobuf.Any some_field = 1;
+}
+
+message SingleOptionOneOfField3 {
+  oneof oneof_field {
+    bool bool_field = 1;
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/support/BUILD.bazel b/src/test/java/com/code_intelligence/jazzer/mutation/support/BUILD.bazel
new file mode 100644
index 0000000..bcde8ba
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/support/BUILD.bazel
@@ -0,0 +1,35 @@
+load("@contrib_rules_jvm//java:defs.bzl", "JUNIT5_DEPS", "java_test_suite")
+
+java_library(
+    name = "test_support",
+    testonly = True,
+    srcs = ["TestSupport.java"],
+    visibility = ["//src/test/java/com/code_intelligence/jazzer/mutation:__subpackages__"],
+    exports = JUNIT5_DEPS + [
+        # keep sorted
+        "@maven//:com_google_truth_extensions_truth_java8_extension",
+        "@maven//:com_google_truth_extensions_truth_proto_extension",
+        "@maven//:com_google_truth_truth",
+        "@maven//:org_junit_jupiter_junit_jupiter_api",
+        "@maven//:org_junit_jupiter_junit_jupiter_params",
+    ],
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/mutation/api",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/engine",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/support",
+        "@com_google_errorprone_error_prone_annotations//jar",
+        "@maven//:com_google_truth_truth",
+    ],
+)
+
+java_test_suite(
+    name = "SupportTests",
+    size = "small",
+    srcs = glob(["*Test.java"]),
+    runner = "junit5",
+    deps = [
+        ":test_support",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/annotation",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/support",
+    ],
+)
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/support/ExceptionSupportTest.java b/src/test/java/com/code_intelligence/jazzer/mutation/support/ExceptionSupportTest.java
new file mode 100644
index 0000000..630b7cd
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/support/ExceptionSupportTest.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.support;
+
+import static com.code_intelligence.jazzer.mutation.support.ExceptionSupport.asUnchecked;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import org.junit.jupiter.api.Test;
+
+class ExceptionSupportTest {
+  @Test
+  void testAsUnchecked_withUncheckedException() {
+    assertThrows(IllegalStateException.class, () -> {
+      // noinspection TrivialFunctionalExpressionUsage
+      ((Runnable) () -> { throw asUnchecked(new IllegalStateException()); }).run();
+    });
+  }
+
+  @Test
+  void testAsUnchecked_withCheckedException() {
+    assertThrows(IOException.class, () -> {
+      // Verify that asUnchecked can be used to throw a checked exception in a function that doesn't
+      // declare it as being thrown.
+      // noinspection TrivialFunctionalExpressionUsage
+      ((Runnable) () -> { throw asUnchecked(new IOException()); }).run();
+    });
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/support/HolderTest.java b/src/test/java/com/code_intelligence/jazzer/mutation/support/HolderTest.java
new file mode 100644
index 0000000..97450e5
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/support/HolderTest.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.support;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.lang.annotation.Annotation;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.lang.reflect.AnnotatedParameterizedType;
+import java.lang.reflect.AnnotatedType;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class HolderTest {
+  @Test
+  void testTypeHolder_rawType() {
+    Type type = new TypeHolder<List<String>>() {}.type();
+    assertThat(type).isInstanceOf(ParameterizedType.class);
+
+    ParameterizedType parameterizedType = (ParameterizedType) type;
+    assertThat(parameterizedType.getRawType()).isEqualTo(List.class);
+    assertThat(parameterizedType.getActualTypeArguments()).asList().containsExactly(String.class);
+  }
+
+  @Test
+  void testTypeHolder_annotatedType() {
+    AnnotatedType type = new TypeHolder<@Foo List<@Bar String>>() {}.annotatedType();
+    assertThat(type).isInstanceOf(AnnotatedParameterizedType.class);
+
+    AnnotatedParameterizedType listType = (AnnotatedParameterizedType) type;
+    assertThat(listType.getType()).isInstanceOf(ParameterizedType.class);
+    assertThat(((ParameterizedType) listType.getType()).getRawType()).isEqualTo(List.class);
+    assertThat(listType.getAnnotations()).hasLength(1);
+    assertThat(listType.getAnnotations()[0]).isInstanceOf(Foo.class);
+    assertThat(listType.getAnnotatedActualTypeArguments()).hasLength(1);
+
+    AnnotatedType stringType = listType.getAnnotatedActualTypeArguments()[0];
+    assertThat(stringType.getType()).isEqualTo(String.class);
+    assertThat(stringType.getAnnotations()).hasLength(1);
+    assertThat(stringType.getAnnotations()[0]).isInstanceOf(Bar.class);
+  }
+
+  @Test
+  void testParameterHolder_rawType() {
+    Type type = new ParameterHolder() {
+      void foo(List<String> parameter) {}
+    }.type();
+    assertThat(type).isInstanceOf(ParameterizedType.class);
+
+    ParameterizedType parameterizedType = (ParameterizedType) type;
+    assertThat(parameterizedType.getRawType()).isEqualTo(List.class);
+    assertThat(parameterizedType.getActualTypeArguments()).asList().containsExactly(String.class);
+  }
+
+  @Test
+  void testParameterHolder_annotatedType() {
+    AnnotatedType type = new ParameterHolder() {
+      void foo(@ParameterAnnotation @Foo List<@Bar String> parameter) {}
+    }.annotatedType();
+    assertThat(type).isInstanceOf(AnnotatedParameterizedType.class);
+
+    AnnotatedParameterizedType listType = (AnnotatedParameterizedType) type;
+    assertThat(listType.getType()).isInstanceOf(ParameterizedType.class);
+    assertThat(((ParameterizedType) listType.getType()).getRawType()).isEqualTo(List.class);
+    assertThat(listType.getAnnotations()).hasLength(1);
+    assertThat(listType.getAnnotations()[0]).isInstanceOf(Foo.class);
+    assertThat(listType.getAnnotatedActualTypeArguments()).hasLength(1);
+
+    AnnotatedType stringType = listType.getAnnotatedActualTypeArguments()[0];
+    assertThat(stringType.getType()).isEqualTo(String.class);
+    assertThat(stringType.getAnnotations()).hasLength(1);
+    assertThat(stringType.getAnnotations()[0]).isInstanceOf(Bar.class);
+  }
+
+  @Test
+  void testParameterHolder_parameterAnnotations() {
+    Annotation[] annotations = new ParameterHolder() {
+      void foo(@ParameterAnnotation @Foo List<@Bar String> parameter) {}
+    }.parameterAnnotations();
+    assertThat(annotations).hasLength(1);
+    assertThat(annotations[0]).isInstanceOf(ParameterAnnotation.class);
+  }
+
+  @Target(ElementType.TYPE_USE)
+  @Retention(RetentionPolicy.RUNTIME)
+  private @interface Foo {}
+
+  @Target(ElementType.TYPE_USE)
+  @Retention(RetentionPolicy.RUNTIME)
+  private @interface Bar {}
+
+  @Target(ElementType.PARAMETER)
+  @Retention(RetentionPolicy.RUNTIME)
+  private @interface ParameterAnnotation {}
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/support/InputStreamSupportTest.java b/src/test/java/com/code_intelligence/jazzer/mutation/support/InputStreamSupportTest.java
new file mode 100644
index 0000000..29963f4
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/support/InputStreamSupportTest.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.support;
+
+import static com.code_intelligence.jazzer.mutation.support.InputStreamSupport.cap;
+import static com.code_intelligence.jazzer.mutation.support.InputStreamSupport.extendWithReadExactly;
+import static com.code_intelligence.jazzer.mutation.support.InputStreamSupport.extendWithZeros;
+import static com.code_intelligence.jazzer.mutation.support.InputStreamSupport.infiniteZeros;
+import static com.code_intelligence.jazzer.mutation.support.InputStreamSupport.readAllBytes;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.code_intelligence.jazzer.mutation.support.InputStreamSupport.ReadExactlyInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+class InputStreamSupportTest {
+  @Test
+  void testInfiniteZeros() throws IOException {
+    InputStream input = infiniteZeros();
+
+    assertThat(input.available()).isEqualTo(Integer.MAX_VALUE);
+    assertThat(input.read()).isEqualTo(0);
+
+    input.close();
+
+    assertThat(input.available()).isEqualTo(Integer.MAX_VALUE);
+    assertThat(input.read()).isEqualTo(0);
+  }
+
+  @Test
+  void testExtendWithNullInputStream_empty() throws IOException {
+    InputStream input = extendWithZeros(new ByteArrayInputStream(new byte[0]));
+    assertThat(input.skip(5)).isEqualTo(5);
+    assertThat(input.read()).isEqualTo(0);
+    byte[] bytes = new byte[] {9, 9, 9, 9, 9};
+    assertThat(input.read(bytes)).isEqualTo(5);
+    assertThat(bytes).asList().containsExactly((byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 0);
+  }
+
+  @Test
+  void testExtendWithNullInputStream_emptyAfterRead() throws IOException {
+    InputStream input = extendWithZeros(new ByteArrayInputStream(new byte[] {1}));
+    assertThat(input.read()).isEqualTo(1);
+    assertThat(input.read()).isEqualTo(0);
+    assertThat(input.read()).isEqualTo(0);
+    byte[] bytes = new byte[] {9, 9, 9, 9, 9};
+    assertThat(input.read(bytes)).isEqualTo(5);
+    assertThat(bytes).asList().containsExactly((byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 0);
+  }
+
+  @Test
+  void testExtendWithNullInputStream_emptyWithinRead() throws IOException {
+    InputStream input = extendWithZeros(new ByteArrayInputStream(new byte[] {1, 2, 3}));
+    byte[] bytes = new byte[] {9, 9, 9, 9, 9};
+    assertThat(input.read(bytes)).isEqualTo(5);
+    assertThat(bytes).asList().containsExactly((byte) 1, (byte) 2, (byte) 3, (byte) 0, (byte) 0);
+  }
+
+  @Test
+  void testExtendWithNullInputStream_emptyWithinSkip() throws IOException {
+    InputStream input = extendWithZeros(new ByteArrayInputStream(new byte[] {1, 2, 3}));
+    assertThat(input.skip(5)).isEqualTo(5);
+    byte[] bytes = new byte[] {9, 9, 9, 9, 9};
+    assertThat(input.read(bytes)).isEqualTo(5);
+    assertThat(bytes).asList().containsExactly((byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 0);
+  }
+
+  @Test
+  void testCap_reachedAfterRead() throws IOException {
+    InputStream input = cap(new ByteArrayInputStream(new byte[] {1, 2, 3, 4, 5}), 3);
+    assertThat(input.available()).isEqualTo(3);
+    assertThat(input.read()).isEqualTo(1);
+    assertThat(input.available()).isEqualTo(2);
+    assertThat(input.read()).isEqualTo(2);
+    assertThat(input.available()).isEqualTo(1);
+    assertThat(input.read()).isEqualTo(3);
+    assertThat(input.available()).isEqualTo(0);
+    assertThat(input.read()).isEqualTo(-1);
+    assertThat(input.read(new byte[5], 0, 5)).isEqualTo(-1);
+  }
+
+  @Test
+  void testCap_reachedWithinRead() throws IOException {
+    InputStream input = cap(new ByteArrayInputStream(new byte[] {1, 2, 3, 4, 5}), 3);
+    byte[] bytes = new byte[5];
+    assertThat(input.available()).isEqualTo(3);
+    assertThat(input.read(bytes, 0, 5)).isEqualTo(3);
+    assertThat(bytes).asList().containsExactly((byte) 1, (byte) 2, (byte) 3, (byte) 0, (byte) 0);
+  }
+
+  @ParameterizedTest
+  // 8192 is the internal buffer size.
+  @ValueSource(ints = {0, 1, 3, 500, 8192, 8192 + 17, 8192 * 8192 + 17})
+  void testReadAllBytes(int length) throws IOException {
+    byte[] bytes = new byte[length];
+    for (int i = 0; i < bytes.length; i++) {
+      bytes[i] = (byte) i;
+    }
+    InputStream input = new ByteArrayInputStream(bytes);
+
+    assertThat(readAllBytes(input)).isEqualTo(bytes);
+  }
+
+  @Test
+  @SuppressWarnings("ResultOfMethodCallIgnored")
+  void testReadExactly() throws IOException {
+    ReadExactlyInputStream ce = extendWithReadExactly(new ByteArrayInputStream(new byte[] {0, 1}));
+    assertThat(ce.isConsumedExactly()).isFalse();
+    ce.read();
+    assertThat(ce.isConsumedExactly()).isFalse();
+    ce.read();
+    assertThat(ce.isConsumedExactly()).isTrue();
+    ce.read();
+    assertThat(ce.isConsumedExactly()).isFalse();
+  }
+
+  @Test
+  @SuppressWarnings("ResultOfMethodCallIgnored")
+  void testReadExactly_readBytes() throws IOException {
+    ReadExactlyInputStream ce =
+        extendWithReadExactly(new ByteArrayInputStream(new byte[] {0, 1, 2}));
+    assertThat(ce.isConsumedExactly()).isFalse();
+    ce.read(new byte[3]);
+    assertThat(ce.isConsumedExactly()).isTrue();
+    ce.read(new byte[1]);
+    assertThat(ce.isConsumedExactly()).isFalse();
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/support/TestSupport.java b/src/test/java/com/code_intelligence/jazzer/mutation/support/TestSupport.java
new file mode 100644
index 0000000..8035ef8
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/support/TestSupport.java
@@ -0,0 +1,425 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.support;
+
+import static com.code_intelligence.jazzer.mutation.support.Preconditions.requireNonNullElements;
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.Arrays.stream;
+import static java.util.stream.Collectors.toCollection;
+
+import com.code_intelligence.jazzer.mutation.api.Debuggable;
+import com.code_intelligence.jazzer.mutation.api.InPlaceMutator;
+import com.code_intelligence.jazzer.mutation.api.PseudoRandom;
+import com.code_intelligence.jazzer.mutation.api.SerializingMutator;
+import com.code_intelligence.jazzer.mutation.engine.SeededPseudoRandom;
+import com.google.errorprone.annotations.CheckReturnValue;
+import com.google.errorprone.annotations.MustBeClosed;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.OutputStream;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Queue;
+import java.util.function.BiConsumer;
+import java.util.function.BiFunction;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+import java.util.function.UnaryOperator;
+
+public final class TestSupport {
+  private static final DataOutputStream nullDataOutputStream =
+      new DataOutputStream(new OutputStream() {
+        @Override
+        public void write(int i) {}
+      });
+
+  private TestSupport() {}
+
+  public static DataOutputStream nullDataOutputStream() {
+    return nullDataOutputStream;
+  }
+
+  /**
+   * Deterministically creates a new instance of {@link PseudoRandom} whose exact behavior is
+   * intentionally unspecified.
+   */
+  // TODO: Turn usages of this function into fuzz tests.
+  public static PseudoRandom anyPseudoRandom() {
+    // Change this seed from time to time to shake out tests relying on hardcoded behavior.
+    return new SeededPseudoRandom(8853461259049838337L);
+  }
+
+  /**
+   * Creates a {@link PseudoRandom} whose methods return the given values in order.
+   */
+  @MustBeClosed
+  public static MockPseudoRandom mockPseudoRandom(Object... returnValues) {
+    return new MockPseudoRandom(returnValues);
+  }
+
+  @CheckReturnValue
+  public static <T> SerializingMutator<T> mockMutator(T initialValue, UnaryOperator<T> mutate) {
+    return mockMutator(initialValue, mutate, value -> value);
+  }
+
+  @CheckReturnValue
+  public static <T> SerializingMutator<T> mockMutator(
+      T initialValue, UnaryOperator<T> mutate, UnaryOperator<T> detach) {
+    return new AbstractMockMutator<T>() {
+      @Override
+      protected T nextInitialValue() {
+        return initialValue;
+      }
+
+      @Override
+      public T mutate(T value, PseudoRandom prng) {
+        return mutate.apply(value);
+      }
+
+      @Override
+      public T detach(T value) {
+        return detach.apply(value);
+      }
+    };
+  }
+
+  @CheckReturnValue
+  public static <T> SerializingMutator<T> mockInitializer(
+      Supplier<T> getInitialValues, UnaryOperator<T> detach) {
+    return new AbstractMockMutator<T>() {
+      @Override
+      protected T nextInitialValue() {
+        return getInitialValues.get();
+      }
+
+      @Override
+      public T mutate(T value, PseudoRandom prng) {
+        throw new UnsupportedOperationException();
+      }
+
+      @Override
+      public T detach(T value) {
+        return detach.apply(value);
+      }
+    };
+  }
+
+  @CheckReturnValue
+  public static <T> SerializingMutator<T> mockCrossOver(BiFunction<T, T, T> getCrossOverValue) {
+    return new AbstractMockMutator<T>() {
+      @Override
+      protected T nextInitialValue() {
+        throw new UnsupportedOperationException();
+      }
+
+      @Override
+      public T mutate(T value, PseudoRandom prng) {
+        throw new UnsupportedOperationException();
+      }
+
+      @Override
+      public T crossOver(T value, T otherValue, PseudoRandom prng) {
+        return getCrossOverValue.apply(value, otherValue);
+      }
+
+      @Override
+      public T detach(T value) {
+        return value;
+      }
+    };
+  }
+
+  @CheckReturnValue
+  public static <T> InPlaceMutator<T> mockCrossOverInPlace(BiConsumer<T, T> crossOverInPlace) {
+    return new AbstractMockInPlaceMutator<T>() {
+      @Override
+      public void crossOverInPlace(T reference, T otherReference, PseudoRandom prng) {
+        crossOverInPlace.accept(reference, otherReference);
+      }
+
+      @Override
+      public String toDebugString(Predicate<Debuggable> isInCycle) {
+        return "CrossOverInPlaceMockMutator";
+      }
+    };
+  }
+
+  @CheckReturnValue
+  public static <T> InPlaceMutator<T> mockInitInPlace(Consumer<T> setInitialValues) {
+    return new AbstractMockInPlaceMutator<T>() {
+      @Override
+      public void initInPlace(T reference, PseudoRandom prng) {
+        setInitialValues.accept(reference);
+      }
+
+      @Override
+      public String toDebugString(Predicate<Debuggable> isInCycle) {
+        return "InitInPlaceMockMutator";
+      }
+    };
+  }
+
+  private static abstract class AbstractMockInPlaceMutator<T> implements InPlaceMutator<T> {
+    @Override
+    public void initInPlace(T reference, PseudoRandom prng) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void mutateInPlace(T reference, PseudoRandom prng) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void crossOverInPlace(T reference, T otherReference, PseudoRandom prng) {
+      throw new UnsupportedOperationException();
+    }
+  }
+
+  private static abstract class AbstractMockMutator<T> extends SerializingMutator<T> {
+    abstract protected T nextInitialValue();
+
+    @Override
+    public T read(DataInputStream in) {
+      return nextInitialValue();
+    }
+
+    @Override
+    public void write(T value, DataOutputStream out) {
+      throw new UnsupportedOperationException("mockMutator does not support write");
+    }
+
+    @Override
+    public T init(PseudoRandom prng) {
+      return nextInitialValue();
+    }
+
+    @Override
+    public T crossOver(T value, T otherValue, PseudoRandom prng) {
+      return value;
+    }
+
+    @Override
+    public String toDebugString(Predicate<Debuggable> isInCycle) {
+      T initialValue = nextInitialValue();
+      if (initialValue == null) {
+        return "null";
+      }
+      return initialValue.getClass().getSimpleName();
+    }
+
+    @Override
+    public T detach(T value) {
+      return value;
+    }
+  }
+
+  public static final class MockPseudoRandom implements PseudoRandom, AutoCloseable {
+    private final Queue<Object> elements;
+
+    private MockPseudoRandom(Object... objects) {
+      requireNonNullElements(objects);
+      this.elements = stream(objects).collect(toCollection(ArrayDeque::new));
+    }
+
+    @Override
+    public boolean choice() {
+      assertThat(elements).isNotEmpty();
+      return (boolean) elements.poll();
+    }
+
+    @Override
+    public boolean trueInOneOutOf(int inverseFrequencyTrue) {
+      assertThat(inverseFrequencyTrue).isAtLeast(2);
+
+      assertThat(elements).isNotEmpty();
+      return (boolean) elements.poll();
+    }
+
+    @Override
+    public <T> T pickIn(T[] array) {
+      assertThat(array).isNotEmpty();
+
+      assertThat(elements).isNotEmpty();
+      return array[(int) elements.poll()];
+    }
+
+    @Override
+    public <T> T pickIn(List<T> list) {
+      assertThat(list).isNotEmpty();
+
+      assertThat(elements).isNotEmpty();
+      return list.get((int) elements.poll());
+    }
+
+    @Override
+    public <T> int indexIn(T[] array) {
+      assertThat(array).isNotEmpty();
+
+      assertThat(elements).isNotEmpty();
+      return (int) elements.poll();
+    }
+
+    @Override
+    public <T> int indexIn(List<T> list) {
+      assertThat(list).isNotEmpty();
+
+      assertThat(elements).isNotEmpty();
+      return (int) elements.poll();
+    }
+
+    @Override
+    public int indexIn(int range) {
+      assertThat(range).isAtLeast(1);
+
+      assertThat(elements).isNotEmpty();
+      return (int) elements.poll();
+    }
+
+    @Override
+    public <T> int otherIndexIn(T[] array, int currentIndex) {
+      return otherIndexIn(array.length, currentIndex);
+    }
+
+    @Override
+    public int otherIndexIn(int range, int currentValue) {
+      assertThat(range).isAtLeast(2);
+      assertThat(elements).isNotEmpty();
+      int result = (int) elements.poll();
+      assertThat(result).isAtLeast(0);
+      assertThat(result).isAtMost(range - 1);
+      assertThat(result).isNotEqualTo(currentValue);
+      return result;
+    }
+
+    @Override
+    public int closedRange(int lowerInclusive, int upperInclusive) {
+      assertThat(lowerInclusive).isAtMost(upperInclusive);
+
+      assertThat(elements).isNotEmpty();
+      int result = (int) elements.poll();
+      assertThat(result).isAtLeast(lowerInclusive);
+      assertThat(result).isAtMost(upperInclusive);
+      return result;
+    }
+
+    @Override
+    public long closedRange(long lowerInclusive, long upperInclusive) {
+      assertThat(lowerInclusive).isAtMost(upperInclusive);
+
+      assertThat(elements).isNotEmpty();
+      long result = (long) elements.poll();
+      assertThat(result).isAtLeast(lowerInclusive);
+      assertThat(result).isAtMost(upperInclusive);
+      return result;
+    }
+
+    @Override
+    public float closedRange(float lowerInclusive, float upperInclusive) {
+      assertThat(lowerInclusive).isLessThan(upperInclusive);
+      assertThat(elements).isNotEmpty();
+      float result = (float) elements.poll();
+      assertThat(result).isAtLeast(lowerInclusive);
+      assertThat(result).isAtMost(upperInclusive);
+      return result;
+    }
+
+    @Override
+    public double closedRange(double lowerInclusive, double upperInclusive) {
+      assertThat(lowerInclusive).isLessThan(upperInclusive);
+      assertThat(elements).isNotEmpty();
+      double result = (double) elements.poll();
+      assertThat(result).isAtLeast(lowerInclusive);
+      assertThat(result).isAtMost(upperInclusive);
+      return result;
+    }
+
+    @Override
+    public int closedRangeBiasedTowardsSmall(int upperInclusive) {
+      assertThat(upperInclusive).isAtLeast(0);
+
+      assertThat(elements).isNotEmpty();
+      int result = (int) elements.poll();
+      assertThat(result).isAtLeast(0);
+      assertThat(result).isAtMost(upperInclusive);
+      return result;
+    }
+
+    @Override
+    public int closedRangeBiasedTowardsSmall(int lowerInclusive, int upperInclusive) {
+      assertThat(lowerInclusive).isAtMost(upperInclusive);
+
+      assertThat(elements).isNotEmpty();
+      int result = (int) elements.poll();
+      assertThat(result).isAtLeast(lowerInclusive);
+      assertThat(result).isAtMost(upperInclusive);
+      return result;
+    }
+
+    @Override
+    public void bytes(byte[] bytes) {
+      assertThat(elements).isNotEmpty();
+      byte[] result = (byte[]) elements.poll();
+      assertThat(result).hasLength(bytes.length);
+      System.arraycopy(result, 0, bytes, 0, bytes.length);
+    }
+
+    @Override
+    public <T> T pickValue(
+        T value, T otherValue, Supplier<T> supplier, int inverseSupplierFrequency) {
+      assertThat(elements).isNotEmpty();
+      switch ((int) elements.poll()) {
+        case 0:
+          return value;
+        case 1:
+          return otherValue;
+        case 2:
+          return supplier.get();
+        default:
+          throw new AssertionError("Invalid pickValue element");
+      }
+    }
+
+    @Override
+    public long nextLong() {
+      assertThat(elements).isNotEmpty();
+      return (long) elements.poll();
+    }
+
+    @Override
+    public void close() {
+      assertThat(elements).isEmpty();
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  public static <K, V> LinkedHashMap<K, V> asMap(Object... objs) {
+    LinkedHashMap<K, V> map = new LinkedHashMap<>();
+    for (int i = 0; i < objs.length; i += 2) {
+      map.put((K) objs[i], (V) objs[i + 1]);
+    }
+    return map;
+  }
+
+  @SafeVarargs
+  public static <T> ArrayList<T> asMutableList(T... objs) {
+    return stream(objs).collect(toCollection(ArrayList::new));
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/support/TypeSupportTest.java b/src/test/java/com/code_intelligence/jazzer/mutation/support/TypeSupportTest.java
new file mode 100644
index 0000000..bbf4a7e
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/support/TypeSupportTest.java
@@ -0,0 +1,269 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.support;
+
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.asAnnotatedType;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.asSubclassOrEmpty;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.containedInDirectedCycle;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.visitAnnotatedType;
+import static com.code_intelligence.jazzer.mutation.support.TypeSupport.withTypeArguments;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static java.util.Arrays.stream;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import com.code_intelligence.jazzer.mutation.annotation.NotNull;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.lang.reflect.AnnotatedParameterizedType;
+import java.lang.reflect.AnnotatedType;
+import java.lang.reflect.ParameterizedType;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledForJreRange;
+import org.junit.jupiter.api.condition.JRE;
+
+class TypeSupportTest {
+  @Test
+  void testFillTypeVariablesRawType_oneVariable() {
+    AnnotatedParameterizedType actual =
+        withTypeArguments(new TypeHolder<@NotNull List>() {}.annotatedType(),
+            new TypeHolder<@NotNull String>() {}.annotatedType());
+    AnnotatedParameterizedType expected =
+        (AnnotatedParameterizedType) new TypeHolder<@NotNull List<@NotNull String>>() {
+        }.annotatedType();
+
+    // Test both equals implementations as we implement them ourselves.
+    assertThat(actual.getType()).isEqualTo(expected.getType());
+    assertThat(expected.getType()).isEqualTo(actual.getType());
+
+    assertThat(actual.getAnnotations()).isEqualTo(expected.getAnnotations());
+    assertThat(expected.getAnnotations()).isEqualTo(actual.getAnnotations());
+
+    assertThat(((ParameterizedType) actual.getType()).getActualTypeArguments())
+        .isEqualTo(((ParameterizedType) expected.getType()).getActualTypeArguments());
+    assertThat(((ParameterizedType) expected.getType()).getActualTypeArguments())
+        .isEqualTo(((ParameterizedType) actual.getType()).getActualTypeArguments());
+  }
+
+  @Test
+  // Java <= 11 does not implement AnnotatedType#equals.
+  // https://github.com/openjdk/jdk/commit/ab0128ca51de59aaaa674654ca8d4e16b3b79965
+  @EnabledForJreRange(min = JRE.JAVA_12)
+  void testFillTypeVariablesAnnotatedType_oneVariable() {
+    AnnotatedParameterizedType actual =
+        withTypeArguments(new TypeHolder<@NotNull List>() {}.annotatedType(),
+            new TypeHolder<@NotNull String>() {}.annotatedType());
+    AnnotatedParameterizedType expected =
+        (AnnotatedParameterizedType) new TypeHolder<@NotNull List<@NotNull String>>() {
+        }.annotatedType();
+
+    // Test both equals implementations as we implement them ourselves.
+    assertThat(actual).isEqualTo(expected);
+    assertThat(expected).isEqualTo(actual);
+
+    assertThat(actual.getType()).isEqualTo(expected.getType());
+    assertThat(expected.getType()).isEqualTo(actual.getType());
+
+    assertThat(actual.getAnnotations()).isEqualTo(expected.getAnnotations());
+    assertThat(expected.getAnnotations()).isEqualTo(actual.getAnnotations());
+
+    assertThat(actual.getAnnotatedActualTypeArguments())
+        .isEqualTo(expected.getAnnotatedActualTypeArguments());
+    assertThat(expected.getAnnotatedActualTypeArguments())
+        .isEqualTo(actual.getAnnotatedActualTypeArguments());
+  }
+
+  @Test
+  void testFillTypeVariablesRawType_oneVariable_differentType() {
+    AnnotatedParameterizedType actual =
+        withTypeArguments(new TypeHolder<@NotNull List>() {}.annotatedType(),
+            new TypeHolder<@NotNull String>() {}.annotatedType());
+    AnnotatedParameterizedType differentParameterAnnotation =
+        (AnnotatedParameterizedType) new TypeHolder<@NotNull List<@NotNull Boolean>>() {
+        }.annotatedType();
+
+    // Test both equals implementations as we implement them ourselves.
+    assertThat(actual.getType()).isNotEqualTo(differentParameterAnnotation.getType());
+    assertThat(differentParameterAnnotation.getType()).isNotEqualTo(actual.getType());
+
+    assertThat(actual.getAnnotations()).isEqualTo(differentParameterAnnotation.getAnnotations());
+    assertThat(differentParameterAnnotation.getAnnotations()).isEqualTo(actual.getAnnotations());
+
+    assertThat(((ParameterizedType) actual.getType()).getActualTypeArguments())
+        .isNotEqualTo(
+            ((ParameterizedType) differentParameterAnnotation.getType()).getActualTypeArguments());
+    assertThat(
+        ((ParameterizedType) differentParameterAnnotation.getType()).getActualTypeArguments())
+        .isNotEqualTo(((ParameterizedType) actual.getType()).getActualTypeArguments());
+  }
+
+  @Test
+  // Java <= 11 does not implement AnnotatedType#equals.
+  // https://github.com/openjdk/jdk/commit/ab0128ca51de59aaaa674654ca8d4e16b3b79965
+  @EnabledForJreRange(min = JRE.JAVA_12)
+  void testFillTypeVariablesAnnotatedType_oneVariable_differentAnnotations() {
+    AnnotatedParameterizedType actual =
+        withTypeArguments(new TypeHolder<@NotNull List>() {}.annotatedType(),
+            new TypeHolder<@NotNull String>() {}.annotatedType());
+    AnnotatedParameterizedType differentParameterAnnotation =
+        (AnnotatedParameterizedType) new TypeHolder<@NotNull List<String>>() {}.annotatedType();
+
+    // Test both equals implementations as we implement them ourselves.
+    assertThat(actual).isNotEqualTo(differentParameterAnnotation);
+    assertThat(differentParameterAnnotation).isNotEqualTo(actual);
+
+    assertThat(actual.getType()).isEqualTo(differentParameterAnnotation.getType());
+    assertThat(differentParameterAnnotation.getType()).isEqualTo(actual.getType());
+
+    assertThat(actual.getAnnotations()).isEqualTo(differentParameterAnnotation.getAnnotations());
+    assertThat(differentParameterAnnotation.getAnnotations()).isEqualTo(actual.getAnnotations());
+
+    assertThat(actual.getAnnotatedActualTypeArguments())
+        .isNotEqualTo(differentParameterAnnotation.getAnnotatedActualTypeArguments());
+    assertThat(differentParameterAnnotation.getAnnotatedActualTypeArguments())
+        .isNotEqualTo(actual.getAnnotatedActualTypeArguments());
+  }
+
+  @Test
+  void testFillTypeVariablesRawType_twoVariables() {
+    AnnotatedParameterizedType actual =
+        withTypeArguments(new TypeHolder<@NotNull Map>() {}.annotatedType(),
+            new TypeHolder<@NotNull String>() {}.annotatedType(),
+            new TypeHolder<byte[]>() {}.annotatedType());
+    AnnotatedParameterizedType expected =
+        (AnnotatedParameterizedType) new TypeHolder<@NotNull Map<@NotNull String, byte[]>>() {
+        }.annotatedType();
+
+    // Test both equals implementations as we implement them ourselves.
+    assertThat(actual.getType()).isEqualTo(expected.getType());
+    assertThat(expected.getType()).isEqualTo(actual.getType());
+
+    assertThat(actual.getAnnotations()).isEqualTo(expected.getAnnotations());
+    assertThat(expected.getAnnotations()).isEqualTo(actual.getAnnotations());
+
+    assertThat(((ParameterizedType) actual.getType()).getActualTypeArguments())
+        .isEqualTo(((ParameterizedType) expected.getType()).getActualTypeArguments());
+    assertThat(((ParameterizedType) expected.getType()).getActualTypeArguments())
+        .isEqualTo(((ParameterizedType) actual.getType()).getActualTypeArguments());
+  }
+
+  @Test
+  // Java <= 11 does not implement AnnotatedType#equals.
+  // https://github.com/openjdk/jdk/commit/ab0128ca51de59aaaa674654ca8d4e16b3b79965
+  @EnabledForJreRange(min = JRE.JAVA_12)
+  void testFillTypeVariablesAnnotatedType_twoVariables() {
+    AnnotatedParameterizedType actual =
+        withTypeArguments(new TypeHolder<@NotNull Map>() {}.annotatedType(),
+            new TypeHolder<@NotNull String>() {}.annotatedType(),
+            new TypeHolder<byte[]>() {}.annotatedType());
+    AnnotatedParameterizedType expected =
+        (AnnotatedParameterizedType) new TypeHolder<@NotNull Map<@NotNull String, byte[]>>() {
+        }.annotatedType();
+
+    // Test both equals implementations as we implement them ourselves.
+    assertThat(actual).isEqualTo(expected);
+    assertThat(expected).isEqualTo(actual);
+
+    assertThat(actual.getType()).isEqualTo(expected.getType());
+    assertThat(expected.getType()).isEqualTo(actual.getType());
+
+    assertThat(actual.getAnnotations()).isEqualTo(expected.getAnnotations());
+    assertThat(expected.getAnnotations()).isEqualTo(actual.getAnnotations());
+
+    assertThat(actual.getAnnotatedActualTypeArguments())
+        .isEqualTo(expected.getAnnotatedActualTypeArguments());
+    assertThat(expected.getAnnotatedActualTypeArguments())
+        .isEqualTo(actual.getAnnotatedActualTypeArguments());
+  }
+
+  @Test
+  void testFillTypeVariables_failures() {
+    assertThrows(IllegalArgumentException.class,
+        () -> withTypeArguments(new TypeHolder<List>() {}.annotatedType()));
+    assertThrows(IllegalArgumentException.class, () -> withTypeArguments(new TypeHolder<List<?>>() {
+    }.annotatedType(), asAnnotatedType(String.class)));
+  }
+
+  @Test
+  void testAsSubclassOrEmpty() {
+    assertThat(asSubclassOrEmpty(asAnnotatedType(String.class), String.class))
+        .hasValue(String.class);
+    assertThat(asSubclassOrEmpty(asAnnotatedType(String.class), CharSequence.class))
+        .hasValue(String.class);
+    assertThat(asSubclassOrEmpty(asAnnotatedType(CharSequence.class), String.class)).isEmpty();
+    assertThat(asSubclassOrEmpty(new TypeHolder<List<String>>() {
+    }.annotatedType(), List.class)).isEmpty();
+  }
+
+  @Target(ElementType.TYPE_USE)
+  @Retention(RetentionPolicy.RUNTIME)
+  private @interface A {
+    int value();
+  }
+
+  @Test
+  void testVisitAnnotatedType() {
+    Map<Integer, Class<?>> visited = new LinkedHashMap<>();
+    AnnotatedType type = new TypeHolder<@A(
+        1) List<@A(2) Map<@A(3) byte @A(4)[] @A(5)[], @A(6) Byte> @A(7)[] @A(8)[]>>(){}
+                             .annotatedType();
+
+    visitAnnotatedType(type,
+        (clazz, annotations)
+            -> stream(annotations)
+                   .map(annotation -> ((A) annotation).value())
+                   .forEach(value -> visited.put(value, clazz)));
+
+    assertThat(visited)
+        .containsExactly(1, List.class, 7, Map[][].class, 8, Map[].class, 2, Map.class, 4,
+            byte[][].class, 5, byte[].class, 3, byte.class, 6, Byte.class)
+        .inOrder();
+  }
+
+  @Test
+  void testContainedInDirectedCycle() {
+    Function<Integer, Stream<Integer>> successors = integer -> {
+      switch (integer) {
+        case 1:
+          return Stream.of(2);
+        case 2:
+          return Stream.of(3);
+        case 3:
+          return Stream.of(4, 5);
+        case 4:
+          return Stream.of(2);
+        case 5:
+          return Stream.empty();
+        default:
+          throw new IllegalStateException();
+      }
+    };
+
+    assertThat(containedInDirectedCycle(1, successors)).isFalse();
+    assertThat(containedInDirectedCycle(2, successors)).isTrue();
+    assertThat(containedInDirectedCycle(3, successors)).isTrue();
+    assertThat(containedInDirectedCycle(4, successors)).isTrue();
+    assertThat(containedInDirectedCycle(5, successors)).isFalse();
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/support/WeakIdentityHashMapTest.java b/src/test/java/com/code_intelligence/jazzer/mutation/support/WeakIdentityHashMapTest.java
new file mode 100644
index 0000000..5406ef8
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/mutation/support/WeakIdentityHashMapTest.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.code_intelligence.jazzer.mutation.support;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.util.Arrays;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class WeakIdentityHashMapTest {
+  private static void reachabilityFence(Object o) {
+    // Polyfill for JDK 9+ Reference.reachabilityFence:
+    // https://mail.openjdk.org/pipermail/core-libs-dev/2018-February/051312.html
+  }
+
+  @Test
+  void testWeakIdentityHashMap_hasIdentitySemantics() {
+    WeakIdentityHashMap<List<Integer>, String> map = new WeakIdentityHashMap<>();
+
+    List<Integer> list = Arrays.asList(1, 2);
+    map.put(list, "value");
+    assertThat(map.containsKey(list)).isTrue();
+
+    List<Integer> equalList = Arrays.asList(1, 2);
+    assertThat(map.containsKey(equalList)).isFalse();
+
+    reachabilityFence(list);
+  }
+
+  @Test
+  void testWeakIdentityHashMap_hasWeakSemantics() {
+    WeakIdentityHashMap<List<Integer>, String> map = new WeakIdentityHashMap<>();
+
+    List<Integer> list = Arrays.asList(1, 2);
+    map.put(list, "value");
+    assertThat(map.containsKey(list)).isTrue();
+    assertThat(map.size()).isEqualTo(1);
+    assertThat(map.isEmpty()).isFalse();
+
+    reachabilityFence(list);
+    map.collectKeysForTesting();
+
+    assertThat(map.size()).isEqualTo(0);
+    assertThat(map.isEmpty()).isTrue();
+  }
+}
diff --git a/src/test/java/com/code_intelligence/jazzer/runtime/BUILD.bazel b/src/test/java/com/code_intelligence/jazzer/runtime/BUILD.bazel
new file mode 100644
index 0000000..db8e507
--- /dev/null
+++ b/src/test/java/com/code_intelligence/jazzer/runtime/BUILD.bazel
@@ -0,0 +1,14 @@
+load("//bazel:compat.bzl", "SKIP_ON_WINDOWS")
+
+java_test(
+    name = "TraceCmpHooksTest",
+    srcs = [
+        "TraceCmpHooksTest.java",
+    ],
+    target_compatible_with = SKIP_ON_WINDOWS,
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/runtime",
+        "//src/main/native/com/code_intelligence/jazzer/driver:jazzer_driver",
+        "@maven//:junit_junit",
+    ],
+)
diff --git a/agent/src/test/java/com/code_intelligence/jazzer/runtime/TraceCmpHooksTest.java b/src/test/java/com/code_intelligence/jazzer/runtime/TraceCmpHooksTest.java
similarity index 97%
rename from agent/src/test/java/com/code_intelligence/jazzer/runtime/TraceCmpHooksTest.java
rename to src/test/java/com/code_intelligence/jazzer/runtime/TraceCmpHooksTest.java
index 9275ca3..a1ef86f 100644
--- a/agent/src/test/java/com/code_intelligence/jazzer/runtime/TraceCmpHooksTest.java
+++ b/src/test/java/com/code_intelligence/jazzer/runtime/TraceCmpHooksTest.java
@@ -22,7 +22,6 @@
 import java.util.concurrent.Executors;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Function;
-import org.junit.BeforeClass;
 import org.junit.Test;
 
 public class TraceCmpHooksTest {
diff --git a/tests/BUILD.bazel b/tests/BUILD.bazel
index cbc7743..28f9aaf 100644
--- a/tests/BUILD.bazel
+++ b/tests/BUILD.bazel
@@ -1,28 +1,30 @@
 load("@fmeum_rules_jni//jni:defs.bzl", "java_jni_library")
-load("//bazel:compat.bzl", "SKIP_ON_MACOS", "SKIP_ON_WINDOWS")
+load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library")
+load("//bazel:compat.bzl", "LINUX_ONLY", "SKIP_ON_MACOS", "SKIP_ON_WINDOWS")
 load("//bazel:fuzz_target.bzl", "java_fuzz_target_test")
+load("//bazel:kotlin.bzl", "ktlint")
 
 java_fuzz_target_test(
     name = "LongStringFuzzer",
     srcs = [
         "src/test/java/com/example/LongStringFuzzer.java",
     ],
+    allowed_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow"],
     data = ["src/test/java/com/example/LongStringFuzzerInput"],
-    expected_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow"],
+    # Additionally verify that Jazzer-Fuzz-Target-Class is picked up if --target_class isn't set.
+    deploy_manifest_lines = ["Jazzer-Fuzz-Target-Class: com.example.LongStringFuzzer"],
     fuzzer_args = [
-        "$(rootpath src/test/java/com/example/LongStringFuzzerInput)",
+        "$(rlocationpath src/test/java/com/example/LongStringFuzzerInput)",
     ],
-    target_class = "com.example.LongStringFuzzer",
+    launcher_variant = "native",
     verify_crash_input = False,
 )
 
 java_fuzz_target_test(
     name = "JpegImageParserAutofuzz",
-    expected_findings = ["java.lang.NegativeArraySizeException"],
+    allowed_findings = ["java.lang.NegativeArraySizeException"],
     fuzzer_args = [
         "--autofuzz=org.apache.commons.imaging.formats.jpeg.JpegImageParser::getBufferedImage",
-        # Exit after the first finding for testing purposes.
-        "--keep_going=1",
         "--autofuzz_ignore=java.lang.NullPointerException",
     ],
     runtime_deps = [
@@ -30,49 +32,46 @@
     ],
 )
 
+java_binary(
+    name = "HookDependenciesFuzzerHooks",
+    srcs = ["src/test/java/com/example/HookDependenciesFuzzerHooks.java"],
+    create_executable = False,
+    deploy_manifest_lines = ["Jazzer-Hook-Classes: com.example.HookDependenciesFuzzerHooks"],
+    deps = ["//src/main/java/com/code_intelligence/jazzer/api:hooks"],
+)
+
 java_fuzz_target_test(
     name = "HookDependenciesFuzzer",
     srcs = ["src/test/java/com/example/HookDependenciesFuzzer.java"],
+    allowed_findings = [
+        "com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow",
+    ],
     env = {"JAVA_OPTS": "-Xverify:all"},
-    hook_classes = ["com.example.HookDependenciesFuzzer"],
+    hook_jar = "HookDependenciesFuzzerHooks_deploy.jar",
     target_class = "com.example.HookDependenciesFuzzer",
+    verify_crash_reproducer = False,
 )
 
 java_fuzz_target_test(
     name = "AutofuzzWithoutCoverage",
-    expected_findings = ["java.lang.NullPointerException"],
+    allowed_findings = ["java.lang.NullPointerException"],
     fuzzer_args = [
         # Autofuzz a method that triggers no coverage instrumentation (the Java standard library is
         # excluded by default).
         "--autofuzz=java.util.regex.Pattern::compile",
-        "--keep_going=1",
     ],
 )
 
 java_fuzz_target_test(
-    name = "AutofuzzHookDependencies",
-    # The reproducer does not include the hook on OOM and thus throws a regular error.
-    expected_findings = ["java.lang.OutOfMemoryError"],
-    fuzzer_args = [
-        "--instrumentation_includes=java.util.regex.**",
-        "--autofuzz=java.util.regex.Pattern::compile",
-        "--autofuzz_ignore=java.lang.Exception",
-        "--keep_going=1",
-    ],
-    # FIXME(fabian): Regularly times out on Windows with 0 exec/s for minutes.
-    target_compatible_with = SKIP_ON_WINDOWS,
-)
-
-java_fuzz_target_test(
     name = "ForkModeFuzzer",
     size = "enormous",
     srcs = [
         "src/test/java/com/example/ForkModeFuzzer.java",
     ],
+    allowed_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow"],
     env = {
         "JAVA_OPTS": "-Dfoo=not_foo -Djava_opts=1",
     },
-    expected_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow"],
     fuzzer_args = [
         "-fork=2",
         "--additional_jvm_args=-Dbaz=baz",
@@ -82,6 +81,7 @@
         "@platforms//os:windows": ["--jvm_args=-Dfoo=foo;-Dbar=b\\\\;ar"],
         "//conditions:default": ["--jvm_args=-Dfoo=foo:-Dbar=b\\\\:ar"],
     }),
+    launcher_variant = "native",
     # Consumes more resources than can be expressed via the size attribute.
     tags = ["exclusive-if-local"],
     target_class = "com.example.ForkModeFuzzer",
@@ -94,6 +94,7 @@
     srcs = [
         "src/test/java/com/example/CoverageFuzzer.java",
     ],
+    allowed_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow"],
     env = {
         "COVERAGE_REPORT_FILE": "coverage.txt",
         "COVERAGE_DUMP_FILE": "coverage.exec",
@@ -108,7 +109,7 @@
     verify_crash_input = False,
     verify_crash_reproducer = False,
     deps = [
-        "@jazzer_jacoco//:jacoco_internal",
+        "@maven//:org_jacoco_org_jacoco_core",
     ],
 )
 
@@ -116,16 +117,15 @@
     name = "autofuzz_inner_class_target",
     srcs = ["src/test/java/com/example/AutofuzzInnerClassTarget.java"],
     deps = [
-        "//agent:jazzer_api_compile_only",
+        "//deploy:jazzer-api",
     ],
 )
 
 java_fuzz_target_test(
     name = "AutofuzzInnerClassFuzzer",
-    expected_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow"],
+    allowed_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow"],
     fuzzer_args = [
         "--autofuzz=com.example.AutofuzzInnerClassTarget.Middle.Inner::test",
-        "--keep_going=1",
     ],
     runtime_deps = [
         ":autofuzz_inner_class_target",
@@ -135,11 +135,13 @@
 # Regression test for https://github.com/CodeIntelligenceTesting/jazzer/issues/405.
 java_fuzz_target_test(
     name = "MemoryLeakFuzzer",
-    timeout = "short",
+    timeout = "moderate",
     srcs = ["src/test/java/com/example/MemoryLeakFuzzer.java"],
+    allowed_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow"],
     env = {
         "JAVA_OPTS": "-Xmx800m",
     },
+    # --keep_going ignores the only finding.
     expect_crash = False,
     fuzzer_args = [
         # Before the bug was fixed, either the GC overhead limit or the overall heap limit was
@@ -161,7 +163,7 @@
     java_fuzz_target_test(
         name = "JazzerApiFuzzer_" + case,
         srcs = ["src/test/java/com/example/JazzerApiFuzzer.java"],
-        expected_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow"],
+        allowed_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow"],
         fuzzer_args = args,
         target_class = "com.example.JazzerApiFuzzer",
     )
@@ -172,7 +174,6 @@
     name = "DisabledHooksFuzzer",
     timeout = "short",
     srcs = ["src/test/java/com/example/DisabledHooksFuzzer.java"],
-    expect_crash = False,
     fuzzer_args = [
         "-runs=0",
         "--custom_hooks=com.example.DisabledHook",
@@ -185,12 +186,11 @@
 
 java_fuzz_target_test(
     name = "BytesMemoryLeakFuzzer",
-    timeout = "short",
+    timeout = "moderate",
     srcs = ["src/test/java/com/example/BytesMemoryLeakFuzzer.java"],
     env = {
         "JAVA_OPTS": "-Xmx200m",
     },
-    expect_crash = False,
     fuzzer_args = [
         # Before the bug was fixed, either the GC overhead limit or the overall heap limit was
         # reached by this target in this number of runs.
@@ -205,7 +205,6 @@
     name = "NoCoverageFuzzer",
     timeout = "short",
     srcs = ["src/test/java/com/example/NoCoverageFuzzer.java"],
-    expect_crash = False,
     fuzzer_args = [
         "-runs=10",
         "--instrumentation_excludes=**",
@@ -217,7 +216,6 @@
     name = "SeedFuzzer",
     timeout = "short",
     srcs = ["src/test/java/com/example/SeedFuzzer.java"],
-    expect_crash = False,
     fuzzer_args = [
         "-runs=0",
         "-seed=1234567",
@@ -232,7 +230,6 @@
     env = {
         "JAZZER_NO_EXPLICIT_SEED": "1",
     },
-    expect_crash = False,
     fuzzer_args = [
         "-runs=0",
     ],
@@ -244,16 +241,306 @@
     srcs = ["src/test/java/com/example/NativeValueProfileFuzzer.java"],
     native_libs = ["//tests/src/test/native/com/example:native_value_profile_fuzzer"],
     visibility = ["//tests/src/test/native/com/example:__pkg__"],
-    deps = ["//agent:jazzer_api_compile_only"],
+    deps = ["//deploy:jazzer-api"],
 )
 
 java_fuzz_target_test(
     name = "NativeValueProfileFuzzer",
-    expected_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow"],
-    fuzzer_args = ["-use_value_profile=1"],
-    sanitizer = "address",
+    allowed_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow"],
+    fuzzer_args = [
+        "-use_value_profile=1",
+        "--native",
+    ],
     target_class = "com.example.NativeValueProfileFuzzer",
     target_compatible_with = SKIP_ON_WINDOWS,
     verify_crash_reproducer = False,
     runtime_deps = [":native_value_profile_fuzzer"],
 )
+
+java_binary(
+    name = "JUnitAgentConfigurationFuzzTest",
+    srcs = ["src/test/java/com/example/JUnitAgentConfigurationFuzzTest.java"],
+    main_class = "com.code_intelligence.jazzer.Jazzer",
+    runtime_deps = [
+        "//deploy:jazzer",
+        "@maven//:org_junit_jupiter_junit_jupiter_engine",
+    ],
+    deps = [
+        "//deploy:jazzer-api",
+        "//deploy:jazzer-junit",
+        "@maven//:org_junit_jupiter_junit_jupiter_api",
+    ],
+)
+
+sh_test(
+    name = "junit_agent_configuration_test",
+    srcs = ["src/test/shell/junit_agent_configuration_test.sh"],
+    args = ["$(rlocationpath :JUnitAgentConfigurationFuzzTest)"],
+    data = [":JUnitAgentConfigurationFuzzTest"],
+    deps = ["@bazel_tools//tools/bash/runfiles"],
+)
+
+java_fuzz_target_test(
+    name = "JUnitAssertFuzzer",
+    timeout = "short",
+    srcs = ["src/test/java/com/example/JUnitAssertFuzzer.java"],
+    allowed_findings = ["org.opentest4j.AssertionFailedError"],
+    target_class = "com.example.JUnitAssertFuzzer",
+    deps = ["@maven//:org_junit_jupiter_junit_jupiter_api"],
+)
+
+java_library(
+    name = "autofuzz_ignore_target",
+    srcs = ["src/test/java/com/example/AutofuzzIgnoreTarget.java"],
+)
+
+java_fuzz_target_test(
+    name = "AutofuzzIgnoreFuzzer",
+    allowed_findings = ["java.lang.RuntimeException"],
+    fuzzer_args = [
+        "--autofuzz=com.example.AutofuzzIgnoreTarget::doStuff",
+        "--autofuzz_ignore=java.lang.NullPointerException",
+        "--ignore=bdde2af8735993f3,0123456789ABCDEF",
+    ],
+    runtime_deps = [
+        ":autofuzz_ignore_target",
+    ],
+)
+
+java_binary(
+    name = "CrashResistantCoverageTarget",
+    srcs = ["src/test/java/com/example/CrashResistantCoverageTarget.java"],
+)
+
+sh_test(
+    name = "crash_resistant_coverage_test",
+    srcs = ["src/test/shell/crash_resistant_coverage_test.sh"],
+    data = [
+        "src/test/data/crash_resistant_coverage_test/crashing_seeds",
+        "src/test/data/crash_resistant_coverage_test/new_coverage_seeds/new_coverage",
+        ":CrashResistantCoverageTarget_deploy.jar",
+        "//launcher:jazzer",
+        "@bazel_tools//tools/bash/runfiles",
+        "@jacocoagent//file:jacocoagent.jar",
+        "@jacococli//file:jacococli.jar",
+    ],
+    target_compatible_with = LINUX_ONLY,
+)
+
+java_fuzz_target_test(
+    name = "JavaDriver",
+    allowed_findings = ["java.lang.NullPointerException"],
+    fuzzer_args = [
+        "--autofuzz=java.util.regex.Pattern::compile",
+    ],
+)
+
+java_fuzz_target_test(
+    name = "JavaDriverWithFork",
+    allowed_findings = ["java.lang.NullPointerException"],
+    fuzzer_args = [
+        "--autofuzz=java.util.regex.Pattern::compile",
+        "-fork=2",
+    ],
+    # -fork is broken on macOS for unknown reasons.
+    target_compatible_with = SKIP_ON_MACOS,
+)
+
+kt_jvm_library(
+    name = "kotlin_vararg",
+    srcs = ["src/test/java/com/example/KotlinVararg.kt"],
+)
+
+java_fuzz_target_test(
+    name = "KotlinVarargFuzzer",
+    srcs = ["src/test/java/com/example/KotlinVarargFuzzer.java"],
+    allowed_findings = ["java.io.IOException"],
+    target_class = "com.example.KotlinVarargFuzzer",
+    deps = [":kotlin_vararg"],
+)
+
+java_fuzz_target_test(
+    name = "TimeoutFuzzer",
+    timeout = "short",
+    srcs = ["src/test/java/com/example/TimeoutFuzzer.java"],
+    allowed_findings = ["timeout"],
+    fuzzer_args = [
+        "-timeout=1",
+    ],
+    target_class = "com.example.TimeoutFuzzer",
+    verify_crash_reproducer = False,
+)
+
+java_library(
+    name = "autofuzz_crashing_setter_target",
+    srcs = ["src/test/java/com/example/AutofuzzCrashingSetterTarget.java"],
+)
+
+# Regression test for https://github.com/CodeIntelligenceTesting/jazzer/issues/586.
+java_fuzz_target_test(
+    name = "AutofuzzCrashingSetterFuzzer",
+    fuzzer_args = [
+        "--autofuzz=com.example.AutofuzzCrashingSetterTarget::start",
+        "--autofuzz_ignore=java.lang.NullPointerException",
+        "-runs=100000",
+    ],
+    runtime_deps = [
+        ":autofuzz_crashing_setter_target",
+    ],
+)
+
+java_library(
+    name = "autofuzz_assertion_error_target",
+    srcs = ["src/test/java/com/example/AutofuzzAssertionErrorTarget.java"],
+)
+
+# Regression test for https://github.com/CodeIntelligenceTesting/jazzer/issues/589.
+java_fuzz_target_test(
+    name = "AutofuzzAssertionError",
+    allowed_findings = ["java.lang.AssertionError"],
+    fuzzer_args = [
+        "--autofuzz=com.example.AutofuzzAssertionErrorTarget::autofuzz",
+    ],
+    runtime_deps = [
+        ":autofuzz_assertion_error_target",
+    ],
+)
+
+java_fuzz_target_test(
+    name = "SilencedFuzzer",
+    timeout = "short",
+    srcs = ["src/test/java/com/example/SilencedFuzzer.java"],
+    allowed_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueHigh"],
+    target_class = "com.example.SilencedFuzzer",
+)
+
+java_binary(
+    name = "jacococli",
+    main_class = "org.jacoco.cli.internal.Main",
+    runtime_deps = ["@jacococli//file:jacococli.jar"],
+)
+
+java_library(
+    name = "OfflineInstrumentedTarget",
+    srcs = ["src/test/java/com/example/OfflineInstrumentedTarget.java"],
+)
+
+genrule(
+    name = "OfflineInstrumentedTargetInstrumented",
+    srcs = [":OfflineInstrumentedTarget"],
+    outs = ["OfflineInstrumentedTargetInstrumented.jar"],
+    cmd = """
+$(location :jacococli) instrument $< --dest jacoco-instrumented --quiet
+cp jacoco-instrumented/*.jar $@
+""",
+    tags = ["manual"],
+    tools = [":jacococli"],
+)
+
+java_fuzz_target_test(
+    name = "OfflineInstrumentedFuzzer",
+    timeout = "short",
+    srcs = ["src/test/java/com/example/OfflineInstrumentedFuzzer.java"],
+    allowed_findings = ["java.lang.IllegalStateException"],
+    target_class = "com.example.OfflineInstrumentedFuzzer",
+    deps = [
+        ":OfflineInstrumentedTargetInstrumented",
+        "@jacocoagent//file:jacocoagent.jar",  # Offline instrumented classes depend on the jacoco agent
+    ],
+)
+
+# TODO: Move to //examples eventually.
+java_fuzz_target_test(
+    name = "ExperimentalMutatorFuzzer",
+    srcs = ["src/test/java/com/example/ExperimentalMutatorFuzzer.java"],
+    allowed_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium"],
+    fuzzer_args = [
+        "--experimental_mutator",
+        "--instrumentation_includes=com.example.**",
+        "--custom_hook_includes=com.example.**",
+        # TODO: Investigate whether we can automatically exclude protos.
+        "--instrumentation_excludes=com.example.SimpleProto*",
+        "--custom_hook_excludes=com.example.SimpleProto*",
+        # Limit runs to catch regressions in mutator efficiency and speed up test runs.
+        "-runs=40000",
+    ],
+    target_class = "com.example.ExperimentalMutatorFuzzer",
+    verify_crash_reproducer = False,
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/mutation/annotation",
+        "//tests/src/test/proto:simple_java_proto",
+    ],
+)
+
+java_fuzz_target_test(
+    name = "ExperimentalMutatorComplexProtoFuzzer",
+    srcs = ["src/test/java/com/example/ExperimentalMutatorComplexProtoFuzzer.java"],
+    allowed_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium"],
+    fuzzer_args = [
+        "--experimental_mutator",
+        "--instrumentation_includes=com.example.**",
+        "--custom_hook_includes=com.example.**",
+    ] + select({
+        # Limit runs to catch regressions in mutator efficiency and speed up test runs.
+        "@platforms//os:linux": ["-runs=400000"],
+        # TODO: Investigate why this test takes far more runs on macOS, with Windows also being
+        #       significantly worse than Linux.
+        "//conditions:default": ["-runs=1200000"],
+    }),
+    target_class = "com.example.ExperimentalMutatorComplexProtoFuzzer",
+    verify_crash_reproducer = False,
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/mutation/annotation",
+        "//src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto:proto2_java_proto",
+    ],
+)
+
+cc_binary(
+    name = "complex_proto_fuzzer",
+    testonly = True,
+    srcs = ["src/test/cc/complex_proto_fuzzer.cc"],
+    copts = ["-fsanitize=fuzzer"],
+    linkopts = ["-fsanitize=fuzzer"],
+    # libfuzzer not shipped on macOS.
+    target_compatible_with = LINUX_ONLY,
+    deps = [
+        "//src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto:proto2_cc_proto",
+        "@libprotobuf-mutator",
+    ],
+)
+
+java_fuzz_target_test(
+    name = "ExperimentalMutatorDynamicProtoFuzzer",
+    srcs = ["src/test/java/com/example/ExperimentalMutatorDynamicProtoFuzzer.java"],
+    allowed_findings = ["com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium"],
+    fuzzer_args = [
+        "--experimental_mutator",
+        "--instrumentation_includes=com.example.**",
+        "--custom_hook_includes=com.example.**",
+    ] + select({
+        # Limit runs to catch regressions in mutator efficiency and speed up test runs.
+        "@platforms//os:linux": ["-runs=400000"],
+        # TODO: Investigate why this test takes far more runs on macOS, with Windows also being
+        #       significantly worse than Linux.
+        "//conditions:default": ["-runs=1200000"],
+    }),
+    target_class = "com.example.ExperimentalMutatorDynamicProtoFuzzer",
+    verify_crash_reproducer = False,
+    deps = [
+        "//src/main/java/com/code_intelligence/jazzer/mutation/annotation",
+        "//src/main/java/com/code_intelligence/jazzer/mutation/annotation/proto",
+        "@com_google_protobuf//java/core",
+    ],
+)
+
+sh_test(
+    name = "jazzer_from_path_test",
+    srcs = ["src/test/shell/jazzer_from_path_test.sh"],
+    args = ["$(rlocationpath //:jazzer_release)"],
+    data = [
+        "//:jazzer_release",
+        "@bazel_tools//tools/bash/runfiles",
+    ],
+)
+
+ktlint()
diff --git a/tests/src/test/cc/complex_proto_fuzzer.cc b/tests/src/test/cc/complex_proto_fuzzer.cc
new file mode 100644
index 0000000..b9eea8b
--- /dev/null
+++ b/tests/src/test/cc/complex_proto_fuzzer.cc
@@ -0,0 +1,22 @@
+// Copyright 2023 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/libfuzzer/libfuzzer_macro.h"
+#include "src/test/java/com/code_intelligence/jazzer/mutation/mutator/proto/proto2.pb.h"
+
+DEFINE_PROTO_FUZZER(const com::code_intelligence::jazzer::protobuf::TestProtobuf& proto) {
+  if (proto.i32() == 1234 && proto.str() == "abcd") {
+    abort();
+  }
+}
diff --git a/tests/src/test/data/crash_resistant_coverage_test/crashing_seeds/crash b/tests/src/test/data/crash_resistant_coverage_test/crashing_seeds/crash
new file mode 100644
index 0000000..7c4a013
--- /dev/null
+++ b/tests/src/test/data/crash_resistant_coverage_test/crashing_seeds/crash
@@ -0,0 +1 @@
+aaa
\ No newline at end of file
diff --git a/tests/src/test/data/crash_resistant_coverage_test/crashing_seeds/empty_input b/tests/src/test/data/crash_resistant_coverage_test/crashing_seeds/empty_input
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/src/test/data/crash_resistant_coverage_test/crashing_seeds/empty_input
diff --git a/tests/src/test/data/crash_resistant_coverage_test/new_coverage_seeds/new_coverage b/tests/src/test/data/crash_resistant_coverage_test/new_coverage_seeds/new_coverage
new file mode 100644
index 0000000..51497b7
--- /dev/null
+++ b/tests/src/test/data/crash_resistant_coverage_test/new_coverage_seeds/new_coverage
@@ -0,0 +1 @@
+aaaaaaaaaaaaaaaaa
\ No newline at end of file
diff --git a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h b/tests/src/test/java/com/example/AutofuzzAssertionErrorTarget.java
similarity index 68%
copy from driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
copy to tests/src/test/java/com/example/AutofuzzAssertionErrorTarget.java
index 0e8846c..d692371 100644
--- a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
+++ b/tests/src/test/java/com/example/AutofuzzAssertionErrorTarget.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2021 Code Intelligence GmbH
+ * Copyright 2023 Code Intelligence GmbH
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,15 +14,10 @@
  * limitations under the License.
  */
 
-#pragma once
+package com.example;
 
-#include <jni.h>
-
-namespace jazzer {
-/*
- * Print the stack traces of all active JVM threads.
- *
- * This function can be called from any thread.
- */
-void DumpJvmStackTraces();
-}  // namespace jazzer
+public class AutofuzzAssertionErrorTarget {
+  public static void autofuzz(byte[] b) {
+    assert b == null || b.length <= 5 || b[3] != 7;
+  }
+}
diff --git a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h b/tests/src/test/java/com/example/AutofuzzCrashingSetterTarget.java
similarity index 68%
copy from driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
copy to tests/src/test/java/com/example/AutofuzzCrashingSetterTarget.java
index 0e8846c..1af0c7b 100644
--- a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
+++ b/tests/src/test/java/com/example/AutofuzzCrashingSetterTarget.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2021 Code Intelligence GmbH
+ * Copyright 2023 Code Intelligence GmbH
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,15 +14,8 @@
  * limitations under the License.
  */
 
-#pragma once
+package com.example;
 
-#include <jni.h>
-
-namespace jazzer {
-/*
- * Print the stack traces of all active JVM threads.
- *
- * This function can be called from any thread.
- */
-void DumpJvmStackTraces();
-}  // namespace jazzer
+public class AutofuzzCrashingSetterTarget extends Thread {
+  public void start(final byte[] out) {}
+}
diff --git a/agent/src/main/java/jaz/Ter.java b/tests/src/test/java/com/example/AutofuzzIgnoreTarget.java
similarity index 60%
copy from agent/src/main/java/jaz/Ter.java
copy to tests/src/test/java/com/example/AutofuzzIgnoreTarget.java
index 7814396..d71ca4d 100644
--- a/agent/src/main/java/jaz/Ter.java
+++ b/tests/src/test/java/com/example/AutofuzzIgnoreTarget.java
@@ -1,4 +1,4 @@
-// Copyright 2021 Code Intelligence GmbH
+// Copyright 2022 Code Intelligence GmbH
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,13 +12,17 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package jaz;
+package com.example;
 
-/**
- * A safe to use companion of {@link jaz.Zer} that is used to produce serializable instances of it
- * with only light patching.
- */
-@SuppressWarnings("unused")
-public class Ter implements java.io.Serializable {
-  static final long serialVersionUID = 42L;
+public class AutofuzzIgnoreTarget {
+  @SuppressWarnings("unused")
+  public void doStuff(String data) {
+    if (data.isEmpty()) {
+      throw new NullPointerException();
+    }
+    if (data.length() < 10) {
+      throw new IllegalArgumentException();
+    }
+    throw new RuntimeException();
+  }
 }
diff --git a/tests/src/test/java/com/example/CoverageFuzzer.java b/tests/src/test/java/com/example/CoverageFuzzer.java
index 8f63639..1d65d3b 100644
--- a/tests/src/test/java/com/example/CoverageFuzzer.java
+++ b/tests/src/test/java/com/example/CoverageFuzzer.java
@@ -18,10 +18,6 @@
 
 import com.code_intelligence.jazzer.api.FuzzedDataProvider;
 import com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow;
-import com.code_intelligence.jazzer.third_party.org.jacoco.core.data.ExecutionData;
-import com.code_intelligence.jazzer.third_party.org.jacoco.core.data.ExecutionDataReader;
-import com.code_intelligence.jazzer.third_party.org.jacoco.core.data.ExecutionDataStore;
-import com.code_intelligence.jazzer.third_party.org.jacoco.core.data.SessionInfoStore;
 import java.io.FileInputStream;
 import java.io.IOException;
 import java.nio.file.Files;
@@ -30,6 +26,10 @@
 import java.util.List;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
+import org.jacoco.core.data.ExecutionData;
+import org.jacoco.core.data.ExecutionDataReader;
+import org.jacoco.core.data.ExecutionDataStore;
+import org.jacoco.core.data.SessionInfoStore;
 
 /**
  * Test of coverage report and dump.
@@ -171,7 +171,7 @@
     assertEquals(7, countHits(coverageFuzzerCoverage.getProbes()));
 
     assertEquals("com/example/CoverageFuzzer$ClassToCover", classToCoverCoverage.getName());
-    assertEquals(11, countHits(classToCoverCoverage.getProbes()));
+    assertEquals(10, countHits(classToCoverCoverage.getProbes()));
   }
 
   private static int countHits(boolean[] probes) {
diff --git a/tests/src/test/java/com/example/CrashResistantCoverageTarget.java b/tests/src/test/java/com/example/CrashResistantCoverageTarget.java
new file mode 100644
index 0000000..c88d450
--- /dev/null
+++ b/tests/src/test/java/com/example/CrashResistantCoverageTarget.java
@@ -0,0 +1,37 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.example;
+
+import java.time.Instant;
+
+public class CrashResistantCoverageTarget {
+  public static void fuzzerTestOneInput(byte[] data) {
+    if (data.length < 10) {
+      // Crash immediately on the empty and the first seed input so that we can verify that the
+      // crash-resistant merge strategy actually works.
+      throw new IllegalStateException("Crash");
+    }
+    if (data.length < 100) {
+      someFunction();
+    }
+  }
+
+  public static void someFunction() {
+    // A non-trivial condition that always evaluates to true.
+    if (Instant.now().getNano() >= 0) {
+      System.out.println("Hello, world!");
+    }
+  }
+}
diff --git a/tests/src/test/java/com/example/DisabledHooksFuzzer.java b/tests/src/test/java/com/example/DisabledHooksFuzzer.java
index 430bfa4..f9dbdcb 100644
--- a/tests/src/test/java/com/example/DisabledHooksFuzzer.java
+++ b/tests/src/test/java/com/example/DisabledHooksFuzzer.java
@@ -23,6 +23,7 @@
 import java.util.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
 
+@SuppressWarnings("InvalidPatternSyntax")
 public class DisabledHooksFuzzer {
   public static void fuzzerTestOneInput(byte[] data) {
     triggerCustomHook();
diff --git a/tests/src/test/java/com/example/ExperimentalMutatorComplexProtoFuzzer.java b/tests/src/test/java/com/example/ExperimentalMutatorComplexProtoFuzzer.java
new file mode 100644
index 0000000..4c3ed31
--- /dev/null
+++ b/tests/src/test/java/com/example/ExperimentalMutatorComplexProtoFuzzer.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example;
+
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium;
+import com.code_intelligence.jazzer.mutation.annotation.InRange;
+import com.code_intelligence.jazzer.mutation.annotation.NotNull;
+import com.code_intelligence.jazzer.protobuf.Proto2.TestProtobuf;
+
+public class ExperimentalMutatorComplexProtoFuzzer {
+  public static void fuzzerTestOneInput(@NotNull TestProtobuf proto) {
+    if (proto.getI32() == 1234 && proto.getStr().equals("abcd")) {
+      throw new FuzzerSecurityIssueMedium("Secret proto is found!");
+    }
+  }
+}
diff --git a/tests/src/test/java/com/example/ExperimentalMutatorDynamicProtoFuzzer.java b/tests/src/test/java/com/example/ExperimentalMutatorDynamicProtoFuzzer.java
new file mode 100644
index 0000000..bbca1dd
--- /dev/null
+++ b/tests/src/test/java/com/example/ExperimentalMutatorDynamicProtoFuzzer.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example;
+
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium;
+import com.code_intelligence.jazzer.mutation.annotation.NotNull;
+import com.code_intelligence.jazzer.mutation.annotation.proto.WithDefaultInstance;
+import com.google.protobuf.DescriptorProtos.DescriptorProto;
+import com.google.protobuf.DescriptorProtos.FieldDescriptorProto;
+import com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type;
+import com.google.protobuf.DescriptorProtos.FileDescriptorProto;
+import com.google.protobuf.Descriptors.Descriptor;
+import com.google.protobuf.Descriptors.DescriptorValidationException;
+import com.google.protobuf.Descriptors.FieldDescriptor;
+import com.google.protobuf.Descriptors.FileDescriptor;
+import com.google.protobuf.DynamicMessage;
+import com.google.protobuf.Message;
+
+public class ExperimentalMutatorDynamicProtoFuzzer {
+  public static void fuzzerTestOneInput(@NotNull @WithDefaultInstance(
+      "com.example.ExperimentalMutatorDynamicProtoFuzzer#getDefaultInstance") Message proto) {
+    FieldDescriptor I32 = proto.getDescriptorForType().findFieldByName("i32");
+    FieldDescriptor STR = proto.getDescriptorForType().findFieldByName("str");
+    if (proto.getField(I32).equals(1234) && proto.getField(STR).equals("abcd")) {
+      throw new FuzzerSecurityIssueMedium("Secret proto is found!");
+    }
+  }
+
+  @SuppressWarnings("unused")
+  private static DynamicMessage getDefaultInstance() {
+    DescriptorProto myMessage =
+        DescriptorProto.newBuilder()
+            .setName("my_message")
+            .addField(FieldDescriptorProto.newBuilder().setNumber(1).setName("i32").setType(
+                Type.TYPE_INT32))
+            .addField(FieldDescriptorProto.newBuilder().setNumber(2).setName("str").setType(
+                Type.TYPE_STRING))
+            .build();
+    FileDescriptorProto file = FileDescriptorProto.newBuilder()
+                                   .setName("my_protos.proto")
+                                   .addMessageType(myMessage)
+                                   .build();
+    try {
+      return DynamicMessage.getDefaultInstance(FileDescriptor.buildFrom(file, new FileDescriptor[0])
+                                                   .findMessageTypeByName("my_message"));
+    } catch (DescriptorValidationException e) {
+      throw new IllegalStateException(e);
+    }
+  }
+}
diff --git a/tests/src/test/java/com/example/ExperimentalMutatorFuzzer.java b/tests/src/test/java/com/example/ExperimentalMutatorFuzzer.java
new file mode 100644
index 0000000..9645e81
--- /dev/null
+++ b/tests/src/test/java/com/example/ExperimentalMutatorFuzzer.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example;
+
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium;
+import com.code_intelligence.jazzer.mutation.annotation.InRange;
+import com.code_intelligence.jazzer.mutation.annotation.NotNull;
+
+public class ExperimentalMutatorFuzzer {
+  public static void fuzzerTestOneInput(
+      @InRange(max = -42) short num, @NotNull SimpleProto.MyProto proto) {
+    if (num > -42) {
+      throw new IllegalArgumentException();
+    }
+
+    if (proto.getNumber() == 12345678) {
+      if (proto.getMessage().getText().contains("Hello, proto!")) {
+        throw new FuzzerSecurityIssueMedium("Dangerous proto");
+      }
+    }
+  }
+}
diff --git a/tests/src/test/java/com/example/HookDependenciesFuzzer.java b/tests/src/test/java/com/example/HookDependenciesFuzzer.java
index 88627f4..7150ed6 100644
--- a/tests/src/test/java/com/example/HookDependenciesFuzzer.java
+++ b/tests/src/test/java/com/example/HookDependenciesFuzzer.java
@@ -26,29 +26,6 @@
 // 2. hooks that are not shipped in the Jazzer agent JAR can still instrument Java standard library
 //    classes.
 public class HookDependenciesFuzzer {
-  private static final Field PATTERN_ROOT;
-
-  static {
-    Field root;
-    try {
-      root = Pattern.class.getDeclaredField("root");
-    } catch (NoSuchFieldException e) {
-      root = null;
-    }
-    PATTERN_ROOT = root;
-  }
-
-  @MethodHook(type = HookType.AFTER, targetClassName = "java.util.regex.Matcher",
-      targetMethod = "matches", targetMethodDescriptor = "()Z",
-      additionalClassesToHook = {"java.util.regex.Pattern"})
-  public static void
-  matcherMatchesHook(MethodHandle method, Object alwaysNull, Object[] alwaysEmpty, int hookId,
-      Boolean returnValue) {
-    if (PATTERN_ROOT != null) {
-      throw new FuzzerSecurityIssueLow("Hook applied even though it depends on the class to hook");
-    }
-  }
-
   public static void fuzzerTestOneInput(byte[] data) {
     try {
       Pattern.matches("foobar", "foobar");
diff --git a/tests/src/test/java/com/example/HookDependenciesFuzzerHooks.java b/tests/src/test/java/com/example/HookDependenciesFuzzerHooks.java
new file mode 100644
index 0000000..d4f50db
--- /dev/null
+++ b/tests/src/test/java/com/example/HookDependenciesFuzzerHooks.java
@@ -0,0 +1,47 @@
+// Copyright 2022 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.example;
+
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueLow;
+import com.code_intelligence.jazzer.api.HookType;
+import com.code_intelligence.jazzer.api.MethodHook;
+import java.lang.invoke.MethodHandle;
+import java.lang.reflect.Field;
+import java.util.regex.Pattern;
+
+public class HookDependenciesFuzzerHooks {
+  private static final Field PATTERN_ROOT;
+
+  static {
+    Field root;
+    try {
+      root = Pattern.class.getDeclaredField("root");
+    } catch (NoSuchFieldException e) {
+      root = null;
+    }
+    PATTERN_ROOT = root;
+  }
+
+  @MethodHook(type = HookType.AFTER, targetClassName = "java.util.regex.Matcher",
+      targetMethod = "matches", targetMethodDescriptor = "()Z",
+      additionalClassesToHook = {"java.util.regex.Pattern"})
+  public static void
+  matcherMatchesHook(MethodHandle method, Object alwaysNull, Object[] alwaysEmpty, int hookId,
+      Boolean returnValue) {
+    if (PATTERN_ROOT != null) {
+      throw new FuzzerSecurityIssueLow("Hook applied even though it depends on the class to hook");
+    }
+  }
+}
diff --git a/tests/src/test/java/com/example/JUnitAgentConfigurationFuzzTest.java b/tests/src/test/java/com/example/JUnitAgentConfigurationFuzzTest.java
new file mode 100644
index 0000000..4f8c2a1
--- /dev/null
+++ b/tests/src/test/java/com/example/JUnitAgentConfigurationFuzzTest.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example;
+
+import static java.util.Collections.singletonList;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import com.code_intelligence.jazzer.junit.FuzzTest;
+import java.util.function.Supplier;
+
+class JUnitAgentConfigurationFuzzTest {
+  @FuzzTest
+  void testConfiguration(byte[] bytes) {
+    assertEquals(singletonList("com.example.**"), getLazyOptValue("instrumentationIncludes"));
+    assertEquals(singletonList("com.example.**"), getLazyOptValue("customHookIncludes"));
+  }
+
+  private static Object getLazyOptValue(String name) {
+    try {
+      Supplier<Object> supplier =
+          (Supplier<Object>) Class.forName("com.code_intelligence.jazzer.driver.Opt")
+              .getField(name)
+              .get(null);
+      return supplier.get();
+    } catch (NoSuchFieldException | ClassNotFoundException | IllegalAccessException e) {
+      throw new IllegalStateException(e);
+    }
+  }
+}
diff --git a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h b/tests/src/test/java/com/example/JUnitAssertFuzzer.java
similarity index 60%
copy from driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
copy to tests/src/test/java/com/example/JUnitAssertFuzzer.java
index 0e8846c..d264428 100644
--- a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
+++ b/tests/src/test/java/com/example/JUnitAssertFuzzer.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2021 Code Intelligence GmbH
+ * Copyright 2022 Code Intelligence GmbH
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,15 +14,14 @@
  * limitations under the License.
  */
 
-#pragma once
+package com.example;
 
-#include <jni.h>
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
 
-namespace jazzer {
-/*
- * Print the stack traces of all active JVM threads.
- *
- * This function can be called from any thread.
- */
-void DumpJvmStackTraces();
-}  // namespace jazzer
+import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+
+public class JUnitAssertFuzzer {
+  public static void fuzzerTestOneInput(FuzzedDataProvider data) {
+    assertNotEquals("JUnit rocks!", data.consumeRemainingAsString());
+  }
+}
diff --git a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h b/tests/src/test/java/com/example/KotlinVararg.kt
similarity index 68%
copy from driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
copy to tests/src/test/java/com/example/KotlinVararg.kt
index 0e8846c..81974eb 100644
--- a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
+++ b/tests/src/test/java/com/example/KotlinVararg.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright 2021 Code Intelligence GmbH
+ * Copyright 2022 Code Intelligence GmbH
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,15 +14,10 @@
  * limitations under the License.
  */
 
-#pragma once
+package com.example
 
-#include <jni.h>
+class KotlinVararg(vararg opts: String) {
+    private val allOpts = opts.toList().joinToString(", ")
 
-namespace jazzer {
-/*
- * Print the stack traces of all active JVM threads.
- *
- * This function can be called from any thread.
- */
-void DumpJvmStackTraces();
-}  // namespace jazzer
+    fun doStuff() = allOpts
+}
diff --git a/tests/src/test/java/com/example/KotlinVarargFuzzer.java b/tests/src/test/java/com/example/KotlinVarargFuzzer.java
new file mode 100644
index 0000000..3324e2e
--- /dev/null
+++ b/tests/src/test/java/com/example/KotlinVarargFuzzer.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2022 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example;
+
+import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+import java.io.IOException;
+
+public class KotlinVarargFuzzer {
+  public static void fuzzerTestOneInput(FuzzedDataProvider data) throws IOException {
+    String out = new KotlinVararg(data.consumeRemainingAsString().split("; ")).doStuff();
+    if (out.contains("a, a")) {
+      throw new IOException(out);
+    }
+  }
+}
diff --git a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h b/tests/src/test/java/com/example/OfflineInstrumentedFuzzer.java
similarity index 62%
copy from driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
copy to tests/src/test/java/com/example/OfflineInstrumentedFuzzer.java
index 0e8846c..eb7da48 100644
--- a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
+++ b/tests/src/test/java/com/example/OfflineInstrumentedFuzzer.java
@@ -1,11 +1,11 @@
 /*
- * Copyright 2021 Code Intelligence GmbH
+ * Copyright 2023 Code Intelligence GmbH
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
  *
- *      http://www.apache.org/licenses/LICENSE-2.0
+ *     http://www.apache.org/licenses/LICENSE-2.0
  *
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS,
@@ -14,15 +14,10 @@
  * limitations under the License.
  */
 
-#pragma once
+package com.example;
 
-#include <jni.h>
-
-namespace jazzer {
-/*
- * Print the stack traces of all active JVM threads.
- *
- * This function can be called from any thread.
- */
-void DumpJvmStackTraces();
-}  // namespace jazzer
+public class OfflineInstrumentedFuzzer {
+  public static void fuzzerTestOneInput(byte[] data) {
+    OfflineInstrumentedTarget.someFunction(data);
+  }
+}
diff --git a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h b/tests/src/test/java/com/example/OfflineInstrumentedTarget.java
similarity index 61%
copy from driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
copy to tests/src/test/java/com/example/OfflineInstrumentedTarget.java
index 0e8846c..5234727 100644
--- a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
+++ b/tests/src/test/java/com/example/OfflineInstrumentedTarget.java
@@ -1,11 +1,11 @@
 /*
- * Copyright 2021 Code Intelligence GmbH
+ * Copyright 2023 Code Intelligence GmbH
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
  *
- *      http://www.apache.org/licenses/LICENSE-2.0
+ *     http://www.apache.org/licenses/LICENSE-2.0
  *
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS,
@@ -14,15 +14,12 @@
  * limitations under the License.
  */
 
-#pragma once
+package com.example;
 
-#include <jni.h>
-
-namespace jazzer {
-/*
- * Print the stack traces of all active JVM threads.
- *
- * This function can be called from any thread.
- */
-void DumpJvmStackTraces();
-}  // namespace jazzer
+public class OfflineInstrumentedTarget {
+  public static void someFunction(byte[] data) {
+    if (new String(data).equals("found it")) {
+      throw new IllegalStateException("Expected exception");
+    }
+  }
+}
diff --git a/tests/src/test/java/com/example/SilencedFuzzer.java b/tests/src/test/java/com/example/SilencedFuzzer.java
new file mode 100644
index 0000000..d1d8777
--- /dev/null
+++ b/tests/src/test/java/com/example/SilencedFuzzer.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2023 Code Intelligence GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example;
+
+import com.code_intelligence.jazzer.api.FuzzerSecurityIssueHigh;
+import java.io.OutputStream;
+import java.io.PrintStream;
+
+public class SilencedFuzzer {
+  private static final PrintStream noopStream = new PrintStream(new OutputStream() {
+    @Override
+    public void write(int b) {}
+  });
+
+  public static void fuzzerInitialize() {
+    System.setErr(noopStream);
+    System.setOut(noopStream);
+  }
+
+  public static void fuzzerTestOneInput(byte[] input) {
+    // If the FuzzTargetTestWrapper successfully parses the stack trace emitted by this finding, we
+    // know that the fuzzer still emitted output despite the fact that System.err and System.out
+    // have been redirected above.
+    throw new FuzzerSecurityIssueHigh();
+  }
+}
diff --git a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h b/tests/src/test/java/com/example/TimeoutFuzzer.java
similarity index 68%
copy from driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
copy to tests/src/test/java/com/example/TimeoutFuzzer.java
index 0e8846c..952113b 100644
--- a/driver/src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.h
+++ b/tests/src/test/java/com/example/TimeoutFuzzer.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2021 Code Intelligence GmbH
+ * Copyright 2022 Code Intelligence GmbH
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,15 +14,11 @@
  * limitations under the License.
  */
 
-#pragma once
+package com.example;
 
-#include <jni.h>
-
-namespace jazzer {
-/*
- * Print the stack traces of all active JVM threads.
- *
- * This function can be called from any thread.
- */
-void DumpJvmStackTraces();
-}  // namespace jazzer
+public class TimeoutFuzzer {
+  public static void fuzzerTestOneInput(byte[] b) {
+    while (true) {
+    }
+  }
+}
diff --git a/tests/src/test/native/com/example/BUILD.bazel b/tests/src/test/native/com/example/BUILD.bazel
index 93b886a..8065736 100644
--- a/tests/src/test/native/com/example/BUILD.bazel
+++ b/tests/src/test/native/com/example/BUILD.bazel
@@ -1,28 +1,16 @@
 load("@fmeum_rules_jni//jni:defs.bzl", "cc_jni_library")
+load("//bazel:compat.bzl", "SKIP_ON_WINDOWS")
 
 cc_jni_library(
     name = "native_value_profile_fuzzer",
     srcs = ["native_value_profile_fuzzer.cpp"],
     copts = [
-        "-fsanitize=fuzzer-no-link,address",
-        "-fno-sanitize-blacklist",
+        "-fsanitize=fuzzer-no-link",
     ],
-    defines = [
-        # Workaround for Windows build failures with VS 2022:
-        # "lld-link: error: /INFERASANLIBS is not allowed in .drectve"
-        # https://github.com/llvm/llvm-project/issues/56300#issuecomment-1214313292
-        "_DISABLE_STRING_ANNOTATION=1",
-        "_DISABLE_VECTOR_ANNOTATION=1",
+    linkopts = [
+        "-fsanitize=fuzzer-no-link",
     ],
-    linkopts = select({
-        "//:clang_on_linux": ["-fuse-ld=lld"],
-        "@platforms//os:windows": [
-            # Windows requires all symbols that should be imported from the main
-            # executable to be defined by an import lib.
-            "/wholearchive:clang_rt.asan_dll_thunk-x86_64.lib",
-        ],
-        "//conditions:default": [],
-    }),
+    target_compatible_with = SKIP_ON_WINDOWS,
     visibility = ["//tests:__pkg__"],
     deps = ["//tests:native_value_profile_fuzzer.hdrs"],
 )
diff --git a/tests/src/test/proto/BUILD.bazel b/tests/src/test/proto/BUILD.bazel
new file mode 100644
index 0000000..7f34e6a
--- /dev/null
+++ b/tests/src/test/proto/BUILD.bazel
@@ -0,0 +1,10 @@
+proto_library(
+    name = "simple_proto",
+    srcs = ["simple_proto.proto"],
+)
+
+java_proto_library(
+    name = "simple_java_proto",
+    visibility = ["//tests:__pkg__"],
+    deps = [":simple_proto"],
+)
diff --git a/driver/test_main.cpp b/tests/src/test/proto/simple_proto.proto
similarity index 64%
copy from driver/test_main.cpp
copy to tests/src/test/proto/simple_proto.proto
index 14340b8..b8c10e3 100644
--- a/driver/test_main.cpp
+++ b/tests/src/test/proto/simple_proto.proto
@@ -1,4 +1,4 @@
-// Copyright 2021 Code Intelligence GmbH
+// Copyright 2023 Code Intelligence GmbH
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,14 +12,18 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-#include <rules_jni.h>
+syntax = "proto3";
 
-#include "gflags/gflags.h"
-#include "gtest/gtest.h"
+package com.example;
 
-int main(int argc, char **argv) {
-  rules_jni_init(argv[0]);
-  ::testing::InitGoogleTest(&argc, argv);
-  gflags::ParseCommandLineFlags(&argc, &argv, true);
-  return RUN_ALL_TESTS();
+option java_package = "com.example";
+
+message MyProto {
+  uint64 number = 1;
+  MySubProto message = 2;
 }
+
+message MySubProto {
+  string text = 1;
+}
+
diff --git a/tests/src/test/shell/crash_resistant_coverage_test.sh b/tests/src/test/shell/crash_resistant_coverage_test.sh
new file mode 100755
index 0000000..f7fe281
--- /dev/null
+++ b/tests/src/test/shell/crash_resistant_coverage_test.sh
@@ -0,0 +1,75 @@
+#!/bin/bash
+# Copyright 2022 Code Intelligence GmbH
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This test verifies that Jazzer's --nohook mode can be used to measure code coverage using the
+# JaCoCo agent.
+# It loosely follows the OSS-Fuzz merge logic, which is the most important user of this feature:
+# https://github.com/google/oss-fuzz/blob/b8ef6a216dc592f4f491daa35c815b14260315c0/infra/base-images/base-runner/coverage#L181
+# The use of libFuzzer's -merge feature should allow coverage collection to proceed through crashing
+# inputs, which is also verified by this test.
+
+# --- begin runfiles.bash initialization v2 ---
+# Copy-pasted from the Bazel Bash runfiles library v2.
+set -uo pipefail; f=bazel_tools/tools/bash/runfiles/runfiles.bash
+source "${RUNFILES_DIR:-/dev/null}/$f" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "${RUNFILES_MANIFEST_FILE:-/dev/null}" | cut -f2- -d' ')" 2>/dev/null || \
+  source "$0.runfiles/$f" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "$0.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "$0.exe.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \
+  { echo>&2 "ERROR: cannot find $f"; exit 1; }; f=; set -e
+# --- end runfiles.bash initialization v2 ---
+
+function fail() {
+  echo "FAILED: $1"
+  exit 1
+}
+
+
+class_dump_dir=$TEST_TMPDIR/classes
+mkdir -p "$class_dump_dir"
+exec_file=$TEST_TMPDIR/jacoco.exec
+excludes='com.code_intelligence.jazzer.**\:com.sun.tools.attach.**\:sun.tools.attach.**\:sun.jvmstat.**'
+jacoco_args="destfile=$exec_file,classdumpdir=$class_dump_dir,excludes=$excludes"
+
+corpus_dummy=$TEST_TMPDIR/corpus
+mkdir -p "$corpus_dummy"
+"$(rlocation jazzer/launcher/jazzer)" \
+  --cp="$(rlocation jazzer/tests/CrashResistantCoverageTarget_deploy.jar)" \
+  --target_class=com.example.CrashResistantCoverageTarget \
+  -merge=1 -timeout=100 --nohooks \
+  "--additional_jvm_args=-javaagent\\:$(rlocation jacocoagent/file/jacocoagent.jar)=${jacoco_args}" \
+  "$corpus_dummy" \
+  "$(rlocation jazzer/tests/src/test/data/crash_resistant_coverage_test/crashing_seeds)" \
+  "$(rlocation jazzer/tests/src/test/data/crash_resistant_coverage_test/new_coverage_seeds)"
+
+[[ -e $exec_file ]] || fail "JaCoCo .exec file does not exist"
+[[ -s $exec_file ]] || fail "JaCoCo .exec file is empty"
+
+# Available under bazel-testlogs/tests/crash_resistant_coverage_test/test.outputs after the test.
+xml_report=$TEST_UNDECLARED_OUTPUTS_DIR/report.xml
+java -jar "$(rlocation jacococli/file/jacococli.jar)" report "$exec_file" \
+    --xml "$xml_report" \
+    --classfiles "$class_dump_dir"
+
+# Verify that no unexpected class is contained in the report.
+grep -o -P '<class name="(?!com\/example\/CrashResistantCoverageTarget)[^"]*"' "$xml_report" && fail "Unexpected class contained in coverage report"
+
+# Verify that fuzzerTestOneInput and someFunction are fully covered by matching the opening <method>
+# tag and a child <counter> tag - (?:[^<]|<[^\/]).* matches everything but </, so there can't be a
+# </method> between the two.
+# Similarly, verify that <init> isn't covered as the default constructor is never invoked.
+grep -q -P '\Q<method name="<init>" desc="()V" line="19">\E(?:[^<]|<[^\/])*\Q<counter type="LINE" missed="1" covered="0"/>\E' "$xml_report" && fail "<init> has been covered"
+grep -q -P '\Q<method name="fuzzerTestOneInput" desc="([B)V" line="21">\E(?:[^<]|<[^\/])*\Q<counter type="LINE" missed="0" covered="5"/>\E' "$xml_report" || fail "fuzzerTestOneInput hasn't been covered"
+grep -q -P '\Q<method name="someFunction" desc="()V" line="33">\E(?:[^<]|<[^\/])*\Q<counter type="LINE" missed="0" covered="3"/>\E' "$xml_report" || fail "someFunction hasn't been covered"
diff --git a/tests/src/test/shell/jazzer_from_path_test.sh b/tests/src/test/shell/jazzer_from_path_test.sh
new file mode 100755
index 0000000..357fde6
--- /dev/null
+++ b/tests/src/test/shell/jazzer_from_path_test.sh
@@ -0,0 +1,43 @@
+#!/bin/bash
+# Copyright 2022 Code Intelligence GmbH
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Verify that the Jazzer launcher finds the jar when executed from PATH.
+
+
+# --- begin runfiles.bash initialization v3 ---
+# Copy-pasted from the Bazel Bash runfiles library v3.
+set -uo pipefail; set +e; f=bazel_tools/tools/bash/runfiles/runfiles.bash
+source "${RUNFILES_DIR:-/dev/null}/$f" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "${RUNFILES_MANIFEST_FILE:-/dev/null}" | cut -f2- -d' ')" 2>/dev/null || \
+  source "$0.runfiles/$f" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "$0.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "$0.exe.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \
+  { echo>&2 "ERROR: cannot find $f"; exit 1; }; f=; set -e
+# --- end runfiles.bash initialization v3 ---
+
+# Unpack the release archive to a temporary directory.
+jazzer_release="$(rlocation "$1")"
+tmp="$(mktemp -d)"
+trap 'rm -r "$tmp"' EXIT
+# GNU tar on Windows requires --force-local to support colons in archives names,
+# macOS tar does not support it.
+tar -xzf "$jazzer_release" -C "$tmp" --force-local || tar -xzf "$jazzer_release" -C "$tmp"
+
+# Add the Jazzer launcher to PATH first so that it is picked over host Jazzer
+# installations.
+PATH="$tmp:$PATH"
+export PATH
+
+jazzer --version
diff --git a/tests/src/test/shell/junit_agent_configuration_test.sh b/tests/src/test/shell/junit_agent_configuration_test.sh
new file mode 100755
index 0000000..dd02982
--- /dev/null
+++ b/tests/src/test/shell/junit_agent_configuration_test.sh
@@ -0,0 +1,39 @@
+#!/bin/bash
+# Copyright 2022 Code Intelligence GmbH
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Verify that instrumentation filter defaults set by @FuzzTest work.
+
+# --- begin runfiles.bash initialization v2 ---
+# Copy-pasted from the Bazel Bash runfiles library v2.
+set -uo pipefail; f=bazel_tools/tools/bash/runfiles/runfiles.bash
+source "${RUNFILES_DIR:-/dev/null}/$f" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "${RUNFILES_MANIFEST_FILE:-/dev/null}" | cut -f2- -d' ')" 2>/dev/null || \
+  source "$0.runfiles/$f" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "$0.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "$0.exe.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \
+  { echo>&2 "ERROR: cannot find $f"; exit 1; }; f=; set -e
+# --- end runfiles.bash initialization v2 ---
+
+function fail() {
+  echo "FAILED: $1"
+  exit 1
+}
+
+stderr="$TEST_TMPDIR/stderr"
+
+"$(rlocation "$1")" --target_class=com.example.JUnitAgentConfigurationFuzzTest 2>&1 -runs=1 | tee "$stderr" || fail "Jazzer did not exit with exit code 0"
+
+[[ $(grep -c "INFO: Instrumented " "$stderr") == 1 ]] || fail "Expected exactly one instrumented class"
+[[ $(grep "INFO: Instrumented " "$stderr" | grep -c -v "INFO: Instrumented com.example.") == 0 ]] || fail "Expected all instrumented classes to be in com.example"
diff --git a/third_party/BUILD.bazel b/third_party/BUILD.bazel
index a234e83..b70d5dd 100644
--- a/third_party/BUILD.bazel
+++ b/third_party/BUILD.bazel
@@ -1,16 +1 @@
-load("@bazel_skylib//rules:common_settings.bzl", "bool_flag")
-
-bool_flag(
-    name = "toolchain",
-    build_setting_default = False,
-)
-
-config_setting(
-    name = "uses_toolchain",
-    flag_values = {
-        ":toolchain": "true",
-    },
-    visibility = ["//visibility:public"],
-)
-
 exports_files(["jacoco_internal.jarjar"])
diff --git a/third_party/android/BUILD b/third_party/android/BUILD
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/third_party/android/BUILD
diff --git a/third_party/android/android_configure.bzl b/third_party/android/android_configure.bzl
new file mode 100644
index 0000000..3311890
--- /dev/null
+++ b/third_party/android/android_configure.bzl
@@ -0,0 +1,57 @@
+"""Repository rule for Android SKD and NDK autoconfigure"""
+
+load("@build_bazel_rules_android//android:rules.bzl", "android_sdk_repository")
+load("@rules_android_ndk//:rules.bzl", "android_ndk_repository")
+
+_ANDROID_SDK_HOME = "ANDROID_HOME"
+_ANDROID_NDK_HOME = "ANDROID_NDK_HOME"
+
+_ANDROID_REPOS_TEMPLATE = """android_sdk_repository(
+        name="androidsdk",
+        path={sdk_home},
+    )
+    android_ndk_repository(
+        name="androidndk",
+        path={ndk_home},
+    )
+"""
+
+def _is_windows(repository_ctx):
+    """Returns true if the current platform is Windows"""
+    return repository_ctx.os.name.lower().startswith("windows")
+
+def _supports_android(repository_ctx):
+    sdk_home = repository_ctx.os.environ.get(_ANDROID_SDK_HOME)
+    ndk_home = repository_ctx.os.environ.get(_ANDROID_NDK_HOME)
+    return sdk_home and ndk_home and not _is_windows(repository_ctx)
+
+def _android_autoconf_impl(repository_ctx):
+    """Implementation of the android_autoconf repo rule"""
+    sdk_home = repository_ctx.os.environ.get(_ANDROID_SDK_HOME)
+    ndk_home = repository_ctx.os.environ.get(_ANDROID_NDK_HOME)
+
+    # rules_android_ndk does not support Windows yet.
+    if _supports_android(repository_ctx):
+        repos = _ANDROID_REPOS_TEMPLATE.format(
+            sdk_home = repr(sdk_home),
+            ndk_home = repr(ndk_home),
+        )
+    else:
+        repos = "pass"
+
+    repository_ctx.file("BUILD.bazel", "")
+    repository_ctx.file("android_configure.bzl", """
+load("@build_bazel_rules_android//android:rules.bzl", "android_sdk_repository")
+load("@rules_android_ndk//:rules.bzl", "android_ndk_repository")
+
+def android_workspace():
+    {repos}
+    """.format(repos = repos))
+
+android_configure = repository_rule(
+    implementation = _android_autoconf_impl,
+    environ = [
+        _ANDROID_SDK_HOME,
+        _ANDROID_NDK_HOME,
+    ],
+)
diff --git a/third_party/bazel-toolchain-export-dynamic-macos-asan.patch b/third_party/bazel-toolchain-export-dynamic-macos-asan.patch
deleted file mode 100644
index 05020e2..0000000
--- a/third_party/bazel-toolchain-export-dynamic-macos-asan.patch
+++ /dev/null
@@ -1,12 +0,0 @@
-diff --git toolchain/BUILD.llvm_repo toolchain/BUILD.llvm_repo
---- toolchain/BUILD.llvm_repo
-+++ toolchain/BUILD.llvm_repo
-@@ -124,3 +124,8 @@ filegroup(
-     name = "strip",
-     srcs = ["bin/llvm-strip"],
- )
-+
-+cc_import(
-+    name = "macos_asan_dynamic",
-+    shared_library = "lib/clang/13.0.0/lib/darwin/libclang_rt.asan_osx_dynamic.dylib",
-+)
diff --git a/third_party/gflags-use-double-dash-args.patch b/third_party/gflags-use-double-dash-args.patch
deleted file mode 100644
index 554b41b..0000000
--- a/third_party/gflags-use-double-dash-args.patch
+++ /dev/null
@@ -1,11 +0,0 @@
---- src/gflags_reporting.cc
-+++ src/gflags_reporting.cc
-@@ -118,7 +118,7 @@
- // Goes to some trouble to make pretty line breaks.
- string DescribeOneFlag(const CommandLineFlagInfo& flag) {
-   string main_part;
--  SStringPrintf(&main_part, "    -%s (%s)",
-+  SStringPrintf(&main_part, "    --%s (%s)",
-                 flag.name.c_str(),
-                 flag.description.c_str());
-   const char* c_string = main_part.c_str();
diff --git a/third_party/jacoco-ignore-offline-instrumentation.patch b/third_party/jacoco-ignore-offline-instrumentation.patch
new file mode 100644
index 0000000..53d68ea
--- /dev/null
+++ b/third_party/jacoco-ignore-offline-instrumentation.patch
@@ -0,0 +1,16 @@
+diff --git org.jacoco.core/src/org/jacoco/core/internal/instr/InstrSupport.java org.jacoco.core/src/org/jacoco/core/internal/instr/InstrSupport.java
+index b8333a2f..1c728638 100644
+--- org.jacoco.core/src/org/jacoco/core/internal/instr/InstrSupport.java
++++ org.jacoco.core/src/org/jacoco/core/internal/instr/InstrSupport.java
+@@ -234,11 +234,6 @@ public final class InstrSupport {
+ 	 */
+ 	public static void assertNotInstrumented(final String member,
+ 			final String owner) throws IllegalStateException {
+-		if (member.equals(DATAFIELD_NAME) || member.equals(INITMETHOD_NAME)) {
+-			throw new IllegalStateException(format(
+-					"Cannot process instrumented class %s. Please supply original non-instrumented classes.",
+-					owner));
+-		}
+ 	}
+
+ 	/**
diff --git a/third_party/jacoco_internal.BUILD b/third_party/jacoco_internal.BUILD
index 38ac7f6..72669be 100644
--- a/third_party/jacoco_internal.BUILD
+++ b/third_party/jacoco_internal.BUILD
@@ -3,12 +3,12 @@
 java_import(
     name = "jacoco_internal",
     jars = ["jacoco_internal_shaded.jar"],
+    visibility = ["//visibility:public"],
     deps = [
         "@org_ow2_asm_asm//jar",
         "@org_ow2_asm_asm_commons//jar",
         "@org_ow2_asm_asm_tree//jar",
     ],
-    visibility = ["//visibility:public"],
 )
 
 jar_jar(
@@ -22,13 +22,13 @@
     srcs = glob([
         "org.jacoco.core/src/org/jacoco/core/**/*.java",
     ]),
-    resources = glob([
-        "org.jacoco.core/src/org/jacoco/core/**/*.properties",
-    ]),
     javacopts = [
         "-Xep:EqualsHashCode:OFF",
         "-Xep:ReturnValueIgnored:OFF",
     ],
+    resources = glob([
+        "org.jacoco.core/src/org/jacoco/core/**/*.properties",
+    ]),
     deps = [
         "@org_ow2_asm_asm//jar",
         "@org_ow2_asm_asm_commons//jar",
diff --git a/third_party/libFuzzer.BUILD b/third_party/libFuzzer.BUILD
index bf902f2..5506af9 100644
--- a/third_party/libFuzzer.BUILD
+++ b/third_party/libFuzzer.BUILD
@@ -1,8 +1,11 @@
 cc_library(
     name = "libfuzzer_no_main",
-    srcs = glob([
-        "*.cpp",
-    ], exclude = ["FuzzerMain.cpp"]),
+    srcs = glob(
+        [
+            "*.cpp",
+        ],
+        exclude = ["FuzzerMain.cpp"],
+    ),
     hdrs = glob([
         "*.h",
         "*.def",
@@ -13,7 +16,6 @@
         "-fno-exceptions",
         "-funwind-tables",
         "-fno-stack-protector",
-        "-fno-sanitize=safe-stack",
         "-fvisibility=hidden",
         "-fno-lto",
     ] + select({
@@ -32,7 +34,7 @@
             "-std=c++17",
         ],
     }),
-    alwayslink = True,
     linkstatic = True,
     visibility = ["//visibility:public"],
+    alwayslink = True,
 )
diff --git a/third_party/protobuf-disable-layering_check.patch b/third_party/protobuf-disable-layering_check.patch
new file mode 100644
index 0000000..69d3449
--- /dev/null
+++ b/third_party/protobuf-disable-layering_check.patch
@@ -0,0 +1,249 @@
+commit 0cb6965869ab94858d9b843ab5d94f7deaea5dc8
+Author: Fabian Meumertzheim <fabian@meumertzhe.im>
+Date:   Mon Jun 12 16:12:02 2023 +0200
+
+    Disable layering_check
+    
+    protobuf misses a sizeable number of dependency declarations, which
+    means that `layering_check` has to be disabled for it.
+    
+    Generated with:
+    ```
+    buildozer 'add features -layering_check' //src/...:__pkg__
+    ```
+    
+    Contains only the changes to `package` directives.
+
+diff --git src/BUILD.bazel src/BUILD.bazel
+index 0de1a4eb1..b7e405147 100644
+--- src/BUILD.bazel
++++ src/BUILD.bazel
+@@ -7,6 +7,8 @@ load("@rules_pkg//:mappings.bzl", "pkg_filegroup", "pkg_files", "strip_prefix")
+ load("@upb//cmake:build_defs.bzl", "staleness_test")
+ load("//conformance:defs.bzl", "conformance_test")
+ 
++package(features = ["-layering_check"])
++
+ pkg_files(
+     name = "dist_files",
+     srcs = glob(["**"]),
+diff --git src/google/protobuf/BUILD.bazel src/google/protobuf/BUILD.bazel
+index 77ed2309f..8c38fb872 100644
+--- src/google/protobuf/BUILD.bazel
++++ src/google/protobuf/BUILD.bazel
+@@ -13,6 +13,7 @@ package(
+         "//:__pkg__",  # "public" targets are alias rules in //.
+         "//json:__subpackages__",
+     ],
++    features = ["-layering_check"],
+ )
+ 
+ proto_library(
+diff --git src/google/protobuf/compiler/BUILD.bazel src/google/protobuf/compiler/BUILD.bazel
+index a2171c806..8dcd34667 100644
+--- src/google/protobuf/compiler/BUILD.bazel
++++ src/google/protobuf/compiler/BUILD.bazel
+@@ -13,6 +13,8 @@ load("@rules_proto//proto:defs.bzl", "proto_library")
+ load("//build_defs:arch_tests.bzl", "aarch64_test", "x86_64_test")
+ load("//build_defs:cpp_opts.bzl", "COPTS", "LINK_OPTS")
+ 
++package(features = ["-layering_check"])
++
+ proto_library(
+     name = "plugin_proto",
+     srcs = ["plugin.proto"],
+diff --git src/google/protobuf/compiler/allowlists/BUILD.bazel src/google/protobuf/compiler/allowlists/BUILD.bazel
+index 569a142fc..0a90b312f 100644
+--- src/google/protobuf/compiler/allowlists/BUILD.bazel
++++ src/google/protobuf/compiler/allowlists/BUILD.bazel
+@@ -1,7 +1,10 @@
+ load("@rules_cc//cc:defs.bzl", "cc_library", "cc_test")
+ load("//build_defs:cpp_opts.bzl", "COPTS")
+ 
+-package(default_visibility = ["//visibility:private"])
++package(
++    default_visibility = ["//visibility:private"],
++    features = ["-layering_check"],
++)
+ 
+ cc_library(
+     name = "allowlist",
+diff --git src/google/protobuf/compiler/cpp/BUILD.bazel src/google/protobuf/compiler/cpp/BUILD.bazel
+index ac1184d32..deacbf582 100644
+--- src/google/protobuf/compiler/cpp/BUILD.bazel
++++ src/google/protobuf/compiler/cpp/BUILD.bazel
+@@ -7,6 +7,8 @@ load("@rules_pkg//:mappings.bzl", "pkg_files", "strip_prefix")
+ load("@rules_proto//proto:defs.bzl", "proto_library")
+ load("//build_defs:cpp_opts.bzl", "COPTS")
+ 
++package(features = ["-layering_check"])
++
+ cc_library(
+     name = "names",
+     hdrs = ["names.h"],
+diff --git src/google/protobuf/compiler/csharp/BUILD.bazel src/google/protobuf/compiler/csharp/BUILD.bazel
+index 96b8dcbc0..a2d549f26 100644
+--- src/google/protobuf/compiler/csharp/BUILD.bazel
++++ src/google/protobuf/compiler/csharp/BUILD.bazel
+@@ -6,6 +6,8 @@ load("@rules_cc//cc:defs.bzl", "cc_library", "cc_test")
+ load("@rules_pkg//:mappings.bzl", "pkg_files", "strip_prefix")
+ load("//build_defs:cpp_opts.bzl", "COPTS")
+ 
++package(features = ["-layering_check"])
++
+ cc_library(
+     name = "names",
+     hdrs = ["names.h"],
+diff --git src/google/protobuf/compiler/java/BUILD.bazel src/google/protobuf/compiler/java/BUILD.bazel
+index 94573892c..c94f472d6 100644
+--- src/google/protobuf/compiler/java/BUILD.bazel
++++ src/google/protobuf/compiler/java/BUILD.bazel
+@@ -6,6 +6,8 @@ load("@rules_cc//cc:defs.bzl", "cc_library", "cc_test")
+ load("@rules_pkg//:mappings.bzl", "pkg_files", "strip_prefix")
+ load("//build_defs:cpp_opts.bzl", "COPTS")
+ 
++package(features = ["-layering_check"])
++
+ cc_library(
+     name = "names",
+     hdrs = ["names.h"],
+diff --git src/google/protobuf/compiler/objectivec/BUILD.bazel src/google/protobuf/compiler/objectivec/BUILD.bazel
+index f78990394..6c534219a 100644
+--- src/google/protobuf/compiler/objectivec/BUILD.bazel
++++ src/google/protobuf/compiler/objectivec/BUILD.bazel
+@@ -6,6 +6,8 @@ load("@rules_cc//cc:defs.bzl", "cc_library", "cc_test")
+ load("@rules_pkg//:mappings.bzl", "pkg_files", "strip_prefix")
+ load("//build_defs:cpp_opts.bzl", "COPTS")
+ 
++package(features = ["-layering_check"])
++
+ cc_library(
+     name = "names",
+     hdrs = ["names.h"],
+diff --git src/google/protobuf/compiler/php/BUILD.bazel src/google/protobuf/compiler/php/BUILD.bazel
+index fe9e75c2c..a569a1c9d 100644
+--- src/google/protobuf/compiler/php/BUILD.bazel
++++ src/google/protobuf/compiler/php/BUILD.bazel
+@@ -6,6 +6,8 @@ load("@rules_cc//cc:defs.bzl", "cc_library")
+ load("@rules_pkg//:mappings.bzl", "pkg_files", "strip_prefix")
+ load("//build_defs:cpp_opts.bzl", "COPTS")
+ 
++package(features = ["-layering_check"])
++
+ cc_library(
+     name = "names",
+     hdrs = ["names.h"],
+diff --git src/google/protobuf/compiler/python/BUILD.bazel src/google/protobuf/compiler/python/BUILD.bazel
+index 5d26e0ce9..ce017acf1 100644
+--- src/google/protobuf/compiler/python/BUILD.bazel
++++ src/google/protobuf/compiler/python/BUILD.bazel
+@@ -6,6 +6,8 @@ load("@rules_cc//cc:defs.bzl", "cc_library", "cc_test")
+ load("@rules_pkg//:mappings.bzl", "pkg_files", "strip_prefix")
+ load("//build_defs:cpp_opts.bzl", "COPTS")
+ 
++package(features = ["-layering_check"])
++
+ cc_library(
+     name = "python",
+     srcs = [
+diff --git src/google/protobuf/compiler/ruby/BUILD.bazel src/google/protobuf/compiler/ruby/BUILD.bazel
+index 520b69194..1e437e7bc 100644
+--- src/google/protobuf/compiler/ruby/BUILD.bazel
++++ src/google/protobuf/compiler/ruby/BUILD.bazel
+@@ -6,6 +6,8 @@ load("@rules_cc//cc:defs.bzl", "cc_library", "cc_test")
+ load("@rules_pkg//:mappings.bzl", "pkg_files", "strip_prefix")
+ load("//build_defs:cpp_opts.bzl", "COPTS")
+ 
++package(features = ["-layering_check"])
++
+ cc_library(
+     name = "ruby",
+     srcs = ["ruby_generator.cc"],
+diff --git src/google/protobuf/compiler/rust/BUILD.bazel src/google/protobuf/compiler/rust/BUILD.bazel
+index 7c1f5b856..4a10038d1 100644
+--- src/google/protobuf/compiler/rust/BUILD.bazel
++++ src/google/protobuf/compiler/rust/BUILD.bazel
+@@ -5,6 +5,8 @@
+ load("@rules_cc//cc:defs.bzl", "cc_library")
+ load("//build_defs:cpp_opts.bzl", "COPTS")
+ 
++package(features = ["-layering_check"])
++
+ cc_library(
+     name = "rust",
+     srcs = ["generator.cc"],
+diff --git src/google/protobuf/io/BUILD.bazel src/google/protobuf/io/BUILD.bazel
+index 8f39625c2..fc2f8e002 100644
+--- src/google/protobuf/io/BUILD.bazel
++++ src/google/protobuf/io/BUILD.bazel
+@@ -6,6 +6,7 @@ load("//build_defs:cpp_opts.bzl", "COPTS")
+ 
+ package(
+     default_visibility = ["//visibility:public"],
++    features = ["-layering_check"],
+ )
+ 
+ cc_library(
+diff --git src/google/protobuf/json/BUILD.bazel src/google/protobuf/json/BUILD.bazel
+index d6019f939..83caca985 100644
+--- src/google/protobuf/json/BUILD.bazel
++++ src/google/protobuf/json/BUILD.bazel
+@@ -1,10 +1,13 @@
+ load("@rules_cc//cc:defs.bzl", "cc_library", "cc_test")
+ load("//build_defs:cpp_opts.bzl", "COPTS")
+ 
+-package(default_visibility = [
+-    "//pkg:__pkg__",
+-    "//src/google/protobuf/json:__pkg__",
+-])
++package(
++    default_visibility = [
++        "//pkg:__pkg__",
++        "//src/google/protobuf/json:__pkg__",
++    ],
++    features = ["-layering_check"],
++)
+ 
+ licenses(["notice"])
+ 
+diff --git src/google/protobuf/stubs/BUILD.bazel src/google/protobuf/stubs/BUILD.bazel
+index c8fc3e9d0..9521f5103 100644
+--- src/google/protobuf/stubs/BUILD.bazel
++++ src/google/protobuf/stubs/BUILD.bazel
+@@ -7,6 +7,7 @@ load("//build_defs:cpp_opts.bzl", "COPTS", "LINK_OPTS")
+ 
+ package(
+     default_visibility = ["//:__subpackages__"],
++    features = ["-layering_check"],
+ )
+ 
+ cc_library(
+diff --git src/google/protobuf/testing/BUILD.bazel src/google/protobuf/testing/BUILD.bazel
+index 572c1f9f4..d10435cce 100644
+--- src/google/protobuf/testing/BUILD.bazel
++++ src/google/protobuf/testing/BUILD.bazel
+@@ -5,7 +5,10 @@ load("@rules_cc//cc:defs.bzl", "cc_library")
+ load("@rules_pkg//:mappings.bzl", "pkg_files", "strip_prefix")
+ load("//build_defs:cpp_opts.bzl", "COPTS", "LINK_OPTS")
+ 
+-package(default_visibility = ["//:__subpackages__"])
++package(
++    default_visibility = ["//:__subpackages__"],
++    features = ["-layering_check"],
++)
+ 
+ cc_library(
+     name = "testing",
+diff --git src/google/protobuf/util/BUILD.bazel src/google/protobuf/util/BUILD.bazel
+index 3afe464cf..03a3045b3 100644
+--- src/google/protobuf/util/BUILD.bazel
++++ src/google/protobuf/util/BUILD.bazel
+@@ -7,6 +7,8 @@ load("@rules_pkg//:mappings.bzl", "pkg_files", "strip_prefix")
+ load("@rules_proto//proto:defs.bzl", "proto_library")
+ load("//build_defs:cpp_opts.bzl", "COPTS")
+ 
++package(features = ["-layering_check"])
++
+ cc_library(
+     name = "delimited_message_util",
+     srcs = ["delimited_message_util.cc"],
diff --git a/third_party/slicer.BUILD b/third_party/slicer.BUILD
new file mode 100644
index 0000000..a7bc7b6
--- /dev/null
+++ b/third_party/slicer.BUILD
@@ -0,0 +1,31 @@
+cc_library(
+    name = "jazzer_slicer",
+    srcs = [
+        "slicer/bytecode_encoder.cc",
+        "slicer/code_ir.cc",
+        "slicer/common.cc",
+        "slicer/control_flow_graph.cc",
+        "slicer/debuginfo_encoder.cc",
+        "slicer/dex_bytecode.cc",
+        "slicer/dex_format.cc",
+        "slicer/dex_ir.cc",
+        "slicer/dex_ir_builder.cc",
+        "slicer/dex_utf8.cc",
+        "slicer/instrumentation.cc",
+        "slicer/reader.cc",
+        "slicer/tryblocks_encoder.cc",
+        "slicer/writer.cc",
+    ],
+    hdrs = glob(["slicer/export/slicer/*.h"]),
+    copts = [
+        "-Wall",
+        "-Wno-sign-compare",
+        "-Wno-unused-parameter",
+        "-Wno-shift-count-overflow",
+        "-Wno-missing-braces",
+    ],
+    includes = ["slicer/export"],
+    visibility = [
+        "//visibility:public",
+    ],
+)