No public description

PiperOrigin-RevId: 959298250
diff --git a/.github/workflows/bazel_test_centipede.yml b/.github/workflows/bazel_test_centipede.yml
index 1bbcba0..ce547af 100644
--- a/.github/workflows/bazel_test_centipede.yml
+++ b/.github/workflows/bazel_test_centipede.yml
@@ -30,6 +30,8 @@
     # TODO(xinhaoyuan): Bump to 24.04 after https://github.com/llvm/llvm-project/issues/102443
     # is fixed.
     runs-on: ubuntu-22.04
+    permissions:
+      contents: read
     timeout-minutes: 60
     strategy:
       matrix:
@@ -39,13 +41,13 @@
         run: |
           sudo sysctl -w kernel.core_pattern=""
       - name: Checkout repository
-        uses: actions/checkout@v4
+        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1
       - name: Install dependencies
         run: |
           sudo apt-get update && sudo apt-get install -yq \
             clang llvm libssl-dev
       - name: Restore latest cache
-        uses: actions/cache/restore@v4
+        uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25  # v5.1.0
         with:
           path: "~/.cache/bazel"
           key: bazel-centipede-cache-${{ matrix.config }}
@@ -84,19 +86,21 @@
           bazel test --no//fuzztest:use_riegeli --test_output=errors --linkopt=-fsanitize=address --copt=-fsanitize=address --test_env=ASAN_OPTIONS=detect_leaks=0 --platform_suffix=asan --test_timeout=600 centipede/puzzles:all
       - name: Save new cache based on main
         if: github.ref == 'refs/heads/main'
-        uses: actions/cache/save@v4
+        uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25  # v5.1.0
         with:
           path: "~/.cache/bazel"
           key: bazel-centipede-cache-${{ matrix.config }}-${{ github.run_id }}
   run_tests_mac:
     name: Run Centipede tests (MacOS)
     runs-on: macos-15
+    permissions:
+      contents: read
     timeout-minutes: 60
     steps:
       - name: Checkout repository
-        uses: actions/checkout@v4
+        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1
       - name: Restore latest cache
-        uses: actions/cache/restore@v4
+        uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25  # v5.1.0
         with:
           path: "~/.cache/bazel"
           key: bazel-centipede-cache-mac
@@ -127,7 +131,38 @@
           bazel --output_user_root="${HOME}/.cache/bazel" test --test_output=errors --no//fuzztest:use_riegeli --linkopt=-fsanitize=address --copt=-fsanitize=address --test_env=ASAN_OPTIONS=detect_leaks=0 --platform_suffix=asan --test_timeout=600 centipede/puzzles:all
       - name: Save new cache based on main
         if: github.ref == 'refs/heads/main'
-        uses: actions/cache/save@v4
+        uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25  # v5.1.0
         with:
           path: "~/.cache/bazel"
           key: bazel-centipede-cache-mac-${{ github.run_id }}
+  run_tests_win:
+    name: Run Centipede tests (Windows)
+    runs-on: windows-latest
+    permissions:
+      contents: read
+    timeout-minutes: 60
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1
+      - name: Restore latest cache
+        uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25  # v5.1.0
+        with:
+          path: "~/.cache/bazel"
+          key: bazel-centipede-cache-win-${{ matrix.config }}
+          restore-keys: bazel-centipede-cache-win-${{ matrix.config }}-
+      - name: Set environment variable
+        run: echo "USE_BAZEL_VERSION=8.7.0" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
+      - name: Run unit tests
+        if: ${{ !cancelled() }}
+        run: |
+          <# Only supported libraries are tested here. #> `
+          bazelisk test --disk_cache=~/.cache/bazel --local_test_jobs=1 --test_output=errors --no//fuzztest:use_riegeli `
+          --extra_toolchains=@local_config_cc//:cc-toolchain-x64_windows-clang-cl `
+          --extra_execution_platforms=//:x64_windows-clang-cl --enable_runfiles `
+          -- centipede:util_test centipede:command_test
+      - name: Save new cache based on main
+        # if: github.ref == 'refs/heads/main'
+        uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25  # v5.1.0
+        with:
+          path: "~/.cache/bazel"
+          key: bazel-centipede-cache-win-${{ github.run_id }}
diff --git a/BUILD b/BUILD
new file mode 100644
index 0000000..4f91cb3
--- /dev/null
+++ b/BUILD
@@ -0,0 +1,8 @@
+platform(
+    name = "x64_windows-clang-cl",
+    constraint_values = [
+        "@platforms//cpu:x86_64",
+        "@platforms//os:windows",
+        "@bazel_tools//tools/cpp:clang-cl",
+    ],
+)
diff --git a/MODULE.bazel b/MODULE.bazel
index 4610482..741fdab 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -23,6 +23,10 @@
     name = "rules_cc",
     version = "0.2.17",
 )
+
+cc_configure = use_extension("@rules_cc//cc:extensions.bzl", "cc_configure_extension")
+use_repo(cc_configure, "local_config_cc")
+
 bazel_dep(
     name = "rules_shell",
     version = "0.6.1",
diff --git a/centipede/BUILD b/centipede/BUILD
index 3ad6fae..78a2ea2 100644
--- a/centipede/BUILD
+++ b/centipede/BUILD
@@ -598,6 +598,8 @@
         ":stop",
         ":util",
         "@abseil-cpp//absl/base:core_headers",
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/hash",
         "@abseil-cpp//absl/status",
         "@abseil-cpp//absl/status:statusor",
         "@abseil-cpp//absl/strings",
@@ -605,6 +607,7 @@
         "@abseil-cpp//absl/synchronization",
         "@abseil-cpp//absl/time",
         "@com_google_fuzztest//common:logging",
+        "@com_google_fuzztest//fuzztest/internal:escaping",
     ],
 )
 
@@ -1413,7 +1416,12 @@
 cc_test(
     name = "util_test",
     srcs = ["util_test.cc"],
-    copts = ["-fno-signed-char"],
+    copts = select({
+        "@platforms//os:windows": [
+            "/J",
+        ],
+        "//conditions:default": ["-fno-signed-char"],
+    }),
     deps = [
         ":feature",
         ":thread_pool",
@@ -1740,10 +1748,13 @@
     name = "command_test_helper",
     srcs = ["command_test_helper.cc"],
     deps = [
-        ":runner_fork_server",
         "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/strings",
         "@abseil-cpp//absl/time",
-    ],
+    ] + select({
+        "@platforms//os:windows": [],
+        "//conditions:default": [":runner_fork_server"],
+    }),
 )
 
 cc_test(
diff --git a/centipede/command.cc b/centipede/command.cc
index 972a056..a5c7b16 100644
--- a/centipede/command.cc
+++ b/centipede/command.cc
@@ -14,6 +14,7 @@
 
 #include "./centipede/command.h"
 
+#if !defined(_WIN32)
 #include <errno.h>
 #include <fcntl.h>
 #include <spawn.h>
@@ -22,6 +23,7 @@
 #include <sys/types.h>
 #include <sys/wait.h>
 #include <unistd.h>
+#endif
 
 #ifdef __APPLE__
 #include <inttypes.h>
@@ -43,8 +45,11 @@
 #include <vector>
 
 #include "absl/base/const_init.h"
+#include "absl/container/flat_hash_map.h"
+#include "absl/hash/hash.h"
 #include "absl/status/status.h"
 #include "absl/status/statusor.h"
+#include "absl/strings/ascii.h"  // NOLINT
 #include "absl/strings/match.h"
 #include "absl/strings/numbers.h"
 #include "absl/strings/str_cat.h"
@@ -58,6 +63,7 @@
 #include "./centipede/stop.h"
 #include "./centipede/util.h"
 #include "./common/logging.h"
+#include "./fuzztest/internal/escaping.h"
 
 #if !defined(_MSC_VER)
 // Needed to pass the current environment to posix_spawn, which needs an
@@ -72,8 +78,10 @@
 constexpr std::string_view kCommandLineSeparator(" \\\n");
 constexpr std::string_view kNoForkServerRequestPrefix("%f");
 
+#if defined(_WIN32)
+#else
 absl::StatusOr<std::string> GetProcessCreationStamp(pid_t pid) {
-#ifdef __APPLE__
+#if defined(__APPLE__)
   struct proc_bsdinfo info = {};
   if (proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, PROC_PIDTBSDINFO_SIZE) !=
       PROC_PIDTBSDINFO_SIZE) {
@@ -112,6 +120,7 @@
   return std::string(fields[kFieldIndexOfStartTimeAfterComm]);
 #endif
 }
+#endif
 
 std::string GetUniqueSuffix() {
   static std::atomic<uint64_t> suffix_counter = {0};
@@ -122,6 +131,9 @@
 
 // TODO(ussuri): Encapsulate as much of the fork server functionality from
 //  this source as possible in this struct, and make it a class.
+#if defined(_WIN32)
+struct Command::ForkServerProps {};
+#else
 struct Command::ForkServerProps {
   // The file paths of the comms pipes.
   std::string fifo_path_[2];
@@ -176,6 +188,7 @@
     }
   }
 };
+#endif
 
 // NOTE: Because std::unique_ptr<T> requires T to be a complete type wherever
 // the deleter is instantiated, the special member functions must be defined
@@ -185,9 +198,13 @@
   if (is_executing()) {
     FUZZTEST_LOG(WARNING) << "Destructing Command object for " << path()
                           << " with "
+#if defined(_WIN32)
+                          << GetProcessId(win_process_handle_)
+#else
                           << (fork_server_ ? absl::StrCat("fork server PID ",
                                                           fork_server_->pid_)
                                            : absl::StrCat("PID ", pid_))
+#endif
                           << " still running. Requesting it to force-stop "
                              "without waiting for it...";
     RequestStop(/*force=*/true);
@@ -201,6 +218,41 @@
 Command::Command(std::string_view path) : Command{path, {}} {}
 
 std::string Command::ToString() const {
+#if defined(_WIN32)
+  std::string path = path_;
+  if (absl::StartsWith(path, kNoForkServerRequestPrefix)) {
+    path = path.substr(kNoForkServerRequestPrefix.size());
+  }
+  constexpr std::string_view kTempFileWildCard = "@@";
+  if (absl::StrContains(path, kTempFileWildCard)) {
+    FUZZTEST_CHECK(!options_.temp_file_path.empty());
+    std::string temp_file = options_.temp_file_path;
+    path = absl::StrReplaceAll(path, {{kTempFileWildCard, temp_file}});
+  }
+  std::string binary_cmd = path;
+  auto Escape = [](std::string s) {
+    std::string r = "\"";
+    size_t num_bs = 0;
+    for (size_t i = 0; i < s.size(); ++i) {
+      if (s[i] == '"') {
+        r.append(std::string(num_bs + 1, '\\'));
+        num_bs = 0;
+      } else if (s[i] == '\\') {
+        ++num_bs;
+      } else {
+        num_bs = 0;
+      }
+      r += s[i];
+    }
+    r.append(num_bs, '\\');
+    r += '"';
+    return r;
+  };
+  for (const auto& arg : options_.args) {
+    absl::StrAppend(&binary_cmd, " ", Escape(arg));
+  }
+  return binary_cmd;
+#else
   std::vector<std::string> ss;
   ss.reserve(/*env*/ 1 + options_.env_diff.size() + /*path*/ 1 +
              /*args*/ options_.args.size() + /*out/err*/ 2);
@@ -217,7 +269,7 @@
     }
   }
   for (auto& var : env_to_set) {
-    ss.push_back(std::move(var));
+    ss.push_back(ShellEscape(var));
   }
   // path.
   std::string path = path_;
@@ -235,25 +287,32 @@
   ss.push_back(std::move(path));
   // args.
   for (const auto& arg : options_.args) {
-    ss.push_back(arg);
+    ss.push_back(ShellEscape(arg));
   }
-  // out/err.
+  // in/out/err.
+  if (!options_.stdin_file_path.empty()) {
+    ss.push_back(absl::StrCat("< ", ShellEscape(options_.stdin_file_path)));
+  }
   if (!stdout_file_.empty()) {
-    ss.push_back(absl::StrCat("> ", stdout_file_));
+    ss.push_back(absl::StrCat("> ", ShellEscape(stdout_file_)));
   }
   if (!stderr_file_.empty()) {
     if (stdout_file_ != stderr_file_) {
-      ss.push_back(absl::StrCat("2> ", stderr_file_));
+      ss.push_back(absl::StrCat("2> ", ShellEscape(stderr_file_)));
     } else {
       ss.push_back("2>&1");
     }
   }
   // Trim trailing space and return.
   return absl::StrJoin(ss, kCommandLineSeparator);
+#endif
 }
 
 bool Command::StartForkServer(std::string_view temp_dir_path,
                               std::string_view prefix) {
+#if defined(_WIN32)
+  return false;
+#else
   if (absl::StartsWith(path_, kNoForkServerRequestPrefix)) {
     FUZZTEST_VLOG(2) << "Fork server disabled for " << path();
     return false;
@@ -340,6 +399,7 @@
   }
   fork_server_->creation_stamp = *std::move(creation_stamp);
   return true;
+#endif  // _WIN32
 }
 
 void Command::ResetRedirectionFiles(std::string_view new_suffix) {
@@ -364,6 +424,9 @@
 }
 
 absl::Status Command::VerifyForkServerIsHealthy() {
+#if defined(_WIN32)
+  return absl::UnimplementedError("Fork server not supported on Windows");
+#else
   // Preconditions: the callers (`Execute()`) should call us only when the fork
   // server is presumed to be running (`fork_server_pid_` >= 0). If it is, the
   // comms pipes are guaranteed to be opened by `StartForkServer()`.
@@ -389,11 +452,150 @@
         fork_server_->creation_stamp, ", but got ", *creation_stamp));
   }
   return absl::OkStatus();
+#endif
 }
 
 bool Command::ExecuteAsync() {
   FUZZTEST_CHECK(!is_executing());
 
+#if defined(_WIN32)
+  FUZZTEST_CHECK_EQ(win_process_handle_, INVALID_HANDLE_VALUE);
+  ResetRedirectionFiles(GetUniqueSuffix());
+  command_line_ = ToString();
+
+  struct CaseInsensitiveHash {
+    size_t operator()(std::string_view s) const {
+      return absl::Hash<std::string>{}(absl::AsciiStrToLower(s));
+    }
+  };
+
+  struct CaseInsensitiveEqual {
+    bool operator()(std::string_view a, std::string_view b) const {
+      return absl::EqualsIgnoreCase(a, b);
+    }
+  };
+
+  absl::flat_hash_map<std::string, std::string, CaseInsensitiveHash,
+                      CaseInsensitiveEqual>
+      env_map;
+
+  LPCH env_strings = GetEnvironmentStringsA();
+  if (env_strings != nullptr) {
+    const char* ptr = env_strings;
+    while (*ptr != '\0') {
+      std::string_view entry(ptr);
+      ptr += entry.size() + 1;
+      size_t eq_pos = entry.find('=', 1);
+      if (eq_pos != std::string_view::npos) {
+        env_map[std::string(entry.substr(0, eq_pos))] =
+            std::string(entry.substr(eq_pos + 1));
+      } else {
+        env_map[std::string(entry)] = "";
+      }
+    }
+    FreeEnvironmentStringsA(env_strings);
+  }
+
+  for (std::string_view env_var : options_.env_diff) {
+    if (absl::StartsWith(env_var, "-")) {
+      std::string_view key = env_var.substr(1);
+      if (absl::EndsWith(key, "=")) {
+        key = key.substr(0, key.size() - 1);
+      }
+      env_map.erase(std::string(key));
+    } else {
+      auto pos = env_var.find('=');
+      if (pos != std::string_view::npos) {
+        std::string key(env_var.substr(0, pos));
+        std::string val(env_var.substr(pos + 1));
+        env_map[key] = val;
+      }
+    }
+  }
+
+  std::vector<char> env_block;
+  for (const auto& [key, val] : env_map) {
+    std::string entry = absl::StrCat(key, "=", val);
+    env_block.insert(env_block.end(), entry.begin(), entry.end());
+    env_block.push_back('\0');
+  }
+  env_block.push_back('\0');
+
+  STARTUPINFOA si = {sizeof(si)};
+  PROCESS_INFORMATION pi = {};
+  si.dwFlags = STARTF_USESTDHANDLES;
+
+  SECURITY_ATTRIBUTES sa = {sizeof(sa), NULL, TRUE};
+  HANDLE hIn = INVALID_HANDLE_VALUE;
+  HANDLE hOut = INVALID_HANDLE_VALUE;
+  HANDLE hErr = INVALID_HANDLE_VALUE;
+
+  if (!options_.stdin_file_path.empty()) {
+    hIn = CreateFileA(options_.stdin_file_path.c_str(), FILE_READ_DATA,
+                      /*dwShareMode=*/0, &sa, OPEN_EXISTING,
+                      FILE_ATTRIBUTE_NORMAL, NULL);
+    if (hIn == INVALID_HANDLE_VALUE) {
+      FUZZTEST_LOG(ERROR) << "Failed to open stdin file: "
+                          << options_.stdin_file_path;
+      return false;
+    }
+    si.hStdInput = hIn;
+  } else {
+    si.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
+  }
+
+  if (!stdout_file_.empty()) {
+    hOut = CreateFileA(stdout_file_.c_str(), FILE_WRITE_DATA, FILE_SHARE_READ,
+                       &sa, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
+    if (hOut == INVALID_HANDLE_VALUE) {
+      FUZZTEST_LOG(ERROR) << "Failed to open stdout file: " << stdout_file_;
+      return false;
+    }
+    si.hStdOutput = hOut;
+  } else {
+    si.hStdOutput = GetStdHandle(STD_OUTPUT_HANDLE);
+  }
+
+  if (!stderr_file_.empty()) {
+    if (stderr_file_ == stdout_file_) {
+      si.hStdError = si.hStdOutput;
+    } else {
+      hErr = CreateFileA(stderr_file_.c_str(), FILE_WRITE_DATA, FILE_SHARE_READ,
+                         &sa, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
+      if (hErr == INVALID_HANDLE_VALUE) {
+        FUZZTEST_LOG(ERROR) << "Failed to open stderr file: " << stderr_file_;
+        return false;
+      }
+      si.hStdError = hErr;
+    }
+  } else {
+    si.hStdError = GetStdHandle(STD_ERROR_HANDLE);
+  }
+
+  std::string cmd = command_line_;
+  const BOOL cp_res = CreateProcessA(
+      NULL, cmd.data(), NULL, NULL, TRUE, 0,
+      env_block.empty() ? NULL : env_block.data(), NULL, &si, &pi);
+
+  if (!cp_res) {
+    DWORD err = GetLastError();
+    FUZZTEST_LOG(ERROR) << "CreateProcessA failed for '" << cmd
+                        << "': error=" << err;
+    if (hIn != INVALID_HANDLE_VALUE) CloseHandle(hIn);
+    if (hOut != INVALID_HANDLE_VALUE) CloseHandle(hOut);
+    if (hErr != INVALID_HANDLE_VALUE && hErr != hOut) CloseHandle(hErr);
+    return false;
+  }
+
+  if (hIn != INVALID_HANDLE_VALUE) CloseHandle(hIn);
+  if (hOut != INVALID_HANDLE_VALUE) CloseHandle(hOut);
+  if (hErr != INVALID_HANDLE_VALUE && hErr != hOut) CloseHandle(hErr);
+
+  win_process_handle_ = pi.hProcess;
+  CloseHandle(pi.hThread);
+  is_executing_ = true;
+  return true;
+#else
   if (fork_server_ != nullptr) {
     FUZZTEST_VLOG(1) << "Sending execution request to fork server";
 
@@ -430,11 +632,41 @@
 
   is_executing_ = true;
   return true;
+#endif
 }
 
 std::optional<int> Command::Wait(absl::Time deadline,
                                  StopCondition* stop_condition) {
   FUZZTEST_CHECK(is_executing());
+#if defined(_WIN32)
+  FUZZTEST_CHECK_NE(win_process_handle_, INVALID_HANDLE_VALUE);
+  DWORD timeout_ms = INFINITE;
+  if (deadline != absl::InfiniteFuture()) {
+    auto dur = deadline - absl::Now();
+    if (dur <= absl::ZeroDuration()) {
+      timeout_ms = 0;
+    } else {
+      timeout_ms = static_cast<DWORD>(absl::ToInt64Milliseconds(dur));
+    }
+  }
+  DWORD res = WaitForSingleObject(win_process_handle_, timeout_ms);
+  if (res == WAIT_TIMEOUT) {
+    VlogProblemInfo(
+        absl::StrCat("Timeout while waiting for command process: deadline is ",
+                     deadline),
+        /*vlog_level=*/1);
+    return std::nullopt;
+  }
+  DWORD exit_code = 0;
+  GetExitCodeProcess(win_process_handle_, &exit_code);
+  CloseHandle(win_process_handle_);
+  win_process_handle_ = INVALID_HANDLE_VALUE;
+  is_executing_ = false;
+  if (exit_code == STATUS_CONTROL_C_EXIT && stop_condition != nullptr) {
+    stop_condition->RequestEarlyStop(EXIT_FAILURE);
+  }
+  return static_cast<int>(exit_code);
+#else
   int exit_code = EXIT_SUCCESS;
 
   if (fork_server_ != nullptr) {
@@ -527,10 +759,15 @@
   }
 
   return exit_code;
+#endif
 }
 
 void Command::RequestStop(bool force) {
   FUZZTEST_CHECK(is_executing());
+#if defined(_WIN32)
+  FUZZTEST_CHECK_NE(win_process_handle_, INVALID_HANDLE_VALUE);
+  TerminateProcess(win_process_handle_, 1);
+#else
   if (fork_server_) {
     FUZZTEST_CHECK_NE(fork_server_->pid_, -1);
     // Cannot send SIGKILL to the fork server as it kills only the parent
@@ -541,6 +778,7 @@
   }
   FUZZTEST_CHECK_NE(pid_, -1);
   kill(pid_, force ? SIGKILL : SIGTERM);
+#endif
 }
 
 std::string Command::ReadRedirectedStdout() const {
diff --git a/centipede/command.h b/centipede/command.h
index b574a84..5cef8f5 100644
--- a/centipede/command.h
+++ b/centipede/command.h
@@ -15,6 +15,12 @@
 #ifndef THIRD_PARTY_CENTIPEDE_COMMAND_H_
 #define THIRD_PARTY_CENTIPEDE_COMMAND_H_
 
+#if defined(_WIN32)
+#define WIN32_LEAN_AND_MEAN
+#define NOGDI
+#include <windows.h>
+#endif
+
 #include <memory>
 #include <optional>
 #include <string>
@@ -52,6 +58,8 @@
     // `Command` automatically unlinks any previous redirected files on
     // execution and destruction.
     std::string stderr_file_prefix;
+    // Redirect stdin from this file path if non-empty.
+    std::string stdin_file_path;
     // "@@" in the command will be replaced with `temp_file_path`.
     std::string temp_file_path;
   };
@@ -127,7 +135,11 @@
  private:
   struct ForkServerProps;
 
+#if defined(_WIN32)
+  HANDLE win_process_handle_ = INVALID_HANDLE_VALUE;
+#else
   int pid_ = -1;
+#endif
   bool is_executing_ = false;
 
   // Derived from Options::{stdout,stderr}_file_prefix, with the realized suffix
diff --git a/centipede/command_test.cc b/centipede/command_test.cc
index 48f4d07..0640a06 100644
--- a/centipede/command_test.cc
+++ b/centipede/command_test.cc
@@ -15,7 +15,9 @@
 #include "./centipede/command.h"
 
 #include <signal.h>
+#if !defined(_WIN32)
 #include <sys/wait.h>  // NOLINT(for WTERMSIG)
+#endif
 
 #include <cstdlib>
 #include <filesystem>  // NOLINT
@@ -26,6 +28,7 @@
 
 #include "gmock/gmock.h"
 #include "gtest/gtest.h"
+#include "absl/strings/str_cat.h"
 #include "absl/strings/substitute.h"
 #include "absl/time/clock.h"
 #include "absl/time/time.h"
@@ -33,58 +36,120 @@
 #include "./centipede/util.h"
 #include "./common/test_util.h"
 
+#if defined(_WIN32)
+#define setenv(n, v, _r) _putenv_s(n, v)
+#endif
+
 namespace fuzztest::internal {
 namespace {
 
+using ::testing::AllOf;
+using ::testing::HasSubstr;
 using ::testing::Optional;
 
-TEST(CommandTest, ToString) {
-  EXPECT_EQ(Command{"x"}.ToString(), "exec env \\\nx");
-  {
-    Command::Options cmd_options;
-    cmd_options.args = {"arg1", "arg2"};
-    EXPECT_EQ((Command{"path", std::move(cmd_options)}.ToString()),
-              "exec env \\\npath \\\narg1 \\\narg2");
-  }
-  {
-    Command::Options cmd_options;
-    cmd_options.env_diff = {"K1=V1", "K2=V2", "-K3"};
-    EXPECT_EQ((Command{"x", std::move(cmd_options)}.ToString()),
-              "exec env \\\n-u K3 \\\nK1=V1 \\\nK2=V2 \\\nx");
-  }
-}
-
 TEST(CommandTest, Execute) {
+  const std::string helper =
+      GetDataDependencyFilepath("centipede/command_test_helper").string();
   StopCondition stop_condition;
 
   // Check for default exit code.
-  Command echo{"echo"};
-  EXPECT_EQ(echo.Execute(&stop_condition), 0);
+  Command::Options options_success;
+  options_success.args = {"success"};
+  Command success_cmd{helper, std::move(options_success)};
+  EXPECT_EQ(success_cmd.Execute(&stop_condition), 0);
   EXPECT_FALSE(stop_condition.ShouldStop());
 
   // Check for exit code 7.
-  Command exit7{"bash -c 'exit 7'"};
+  Command::Options options_ret7;
+  options_ret7.args = {"ret7"};
+  Command exit7{helper, std::move(options_ret7)};
   EXPECT_EQ(exit7.Execute(&stop_condition), 7);
   EXPECT_FALSE(stop_condition.ShouldStop());
 }
 
 TEST(CommandTest, HandlesInterruptedCommand) {
+  const std::string helper =
+      GetDataDependencyFilepath("centipede/command_test_helper").string();
   StopCondition stop_condition;
-  Command self_sigint{"bash -c 'kill -SIGINT $$'"};
-  self_sigint.ExecuteAsync();
-  self_sigint.Wait(absl::InfiniteFuture(), &stop_condition);
+  Command::Options options_ctrlc;
+  options_ctrlc.args = {"ctrlc"};
+  Command self_ctrlc{helper, std::move(options_ctrlc)};
+  // Cannot set to SIG_IGN as the command would inherit that.
+  signal(SIGINT, [](int) {});
+  self_ctrlc.ExecuteAsync();
+  signal(SIGINT, SIG_DFL);
+  self_ctrlc.Wait(absl::InfiniteFuture(), &stop_condition);
   EXPECT_TRUE(stop_condition.ShouldStop());
 }
 
-TEST(CommandTest, InputFileWildCard) {
-  Command::Options cmd_options;
-  cmd_options.temp_file_path = "TEMP_FILE";
-  Command cmd{"foo bar @@ baz", std::move(cmd_options)};
-  EXPECT_EQ(cmd.ToString(), "exec env \\\nfoo bar TEMP_FILE baz");
+TEST(CommandTest, ExecuteWithOptions) {
+  const std::filesystem::path test_tmpdir = GetTestTempDir(test_info_->name());
+  const std::string helper =
+      GetDataDependencyFilepath("centipede/command_test_helper").string();
+
+  {
+    const std::string log_prefix = (test_tmpdir / "args").string();
+    Command::Options cmd_options;
+    cmd_options.args = {"echo_args", "arg1", "arg2"};
+    cmd_options.stdout_file_prefix = log_prefix;
+    Command cmd{helper, std::move(cmd_options)};
+    EXPECT_EQ(cmd.Execute(), 0);
+    std::string log_contents;
+    ReadFromLocalFile(cmd.stdout_file(), log_contents);
+    EXPECT_THAT(log_contents,
+                AllOf(HasSubstr("arg[0]=arg1\n"), HasSubstr("arg[1]=arg2\n")));
+  }
+
+  {
+    setenv("K3", "V3", 1);
+    const std::string log_prefix = (test_tmpdir / "env").string();
+    Command::Options cmd_options;
+    cmd_options.args = {"echo_env", "K1", "K2", "K3"};
+    cmd_options.env_diff = {"K1=V1", "K2=V2", "-K3"};
+    cmd_options.stdout_file_prefix = log_prefix;
+    Command cmd{helper, std::move(cmd_options)};
+    EXPECT_EQ(cmd.Execute(), 0);
+    std::string log_contents;
+    ReadFromLocalFile(cmd.stdout_file(), log_contents);
+    EXPECT_THAT(log_contents, AllOf(HasSubstr("K1=V1\n"), HasSubstr("K2=V2\n"),
+                                    HasSubstr("K3=<UNSET>\n")));
+  }
+
+  {
+    const std::string stdin_file = (test_tmpdir / "input.txt").string();
+    WriteToLocalFile(stdin_file, "hello stdin");
+    const std::string log_prefix = (test_tmpdir / "stdin").string();
+    Command::Options cmd_options;
+    cmd_options.args = {"echo_stdin"};
+    cmd_options.stdin_file_path = stdin_file;
+    cmd_options.stdout_file_prefix = log_prefix;
+    Command cmd{helper, std::move(cmd_options)};
+    EXPECT_EQ(cmd.Execute(), 0);
+    std::string log_contents;
+    ReadFromLocalFile(cmd.stdout_file(), log_contents);
+    EXPECT_THAT(log_contents, HasSubstr("hello stdin"));
+  }
 }
 
+TEST(CommandTest, InputFileWildCard) {
+  const std::filesystem::path test_tmpdir = GetTestTempDir(test_info_->name());
+  const std::string helper =
+      GetDataDependencyFilepath("centipede/command_test_helper").string();
+  const std::string log_prefix = (test_tmpdir / "wildcard").string();
+
+  Command::Options cmd_options;
+  cmd_options.temp_file_path = "TEMP_FILE";
+  cmd_options.stdout_file_prefix = log_prefix;
+  Command cmd{absl::StrCat(helper, " @@"), std::move(cmd_options)};
+  EXPECT_EQ(cmd.Execute(), 17);
+  std::string log_contents;
+  ReadFromLocalFile(cmd.stdout_file(), log_contents);
+  EXPECT_EQ(log_contents, "Got input: TEMP_FILE\n");
+}
+
+#if !defined(_WIN32)
 TEST(CommandTest, ForkServer) {
-  const std::string test_tmpdir = GetTestTempDir(test_info_->name());
+  const std::filesystem::path test_tmpdir = GetTestTempDir(test_info_->name());
   const std::string helper =
       GetDataDependencyFilepath("centipede/command_test_helper");
 
@@ -92,93 +157,93 @@
 
   {
     const std::string input = "success";
-    const std::string log_prefix = std::filesystem::path{test_tmpdir} / input;
+    const std::string log_prefix = test_tmpdir / input;
     Command::Options cmd_options;
     cmd_options.args = {input};
     cmd_options.stdout_file_prefix = log_prefix;
     cmd_options.stderr_file_prefix = log_prefix;
     Command cmd{helper, std::move(cmd_options)};
-    EXPECT_TRUE(cmd.StartForkServer(test_tmpdir, "ForkServer"));
+    EXPECT_TRUE(cmd.StartForkServer(test_tmpdir.string(), "ForkServer"));
     EXPECT_EQ(cmd.Execute(), EXIT_SUCCESS);
     std::string log_contents;
     ReadFromLocalFile(cmd.stdout_file(), log_contents);
-    EXPECT_EQ(log_contents, absl::Substitute("Got input: $0", input));
+    EXPECT_EQ(log_contents, absl::Substitute("Got input: $0\n", input));
   }
 
   {
     const std::string input = "fail";
-    const std::string log_prefix = std::filesystem::path{test_tmpdir} / input;
+    const std::string log_prefix = test_tmpdir / input;
     Command::Options cmd_options;
     cmd_options.args = {input};
     cmd_options.stdout_file_prefix = log_prefix;
     cmd_options.stderr_file_prefix = log_prefix;
     Command cmd{helper, std::move(cmd_options)};
-    EXPECT_TRUE(cmd.StartForkServer(test_tmpdir, "ForkServer"));
+    EXPECT_TRUE(cmd.StartForkServer(test_tmpdir.string(), "ForkServer"));
     EXPECT_EQ(cmd.Execute(), EXIT_FAILURE);
     std::string log_contents;
     ReadFromLocalFile(cmd.stdout_file(), log_contents);
-    EXPECT_EQ(log_contents, absl::Substitute("Got input: $0", input));
+    EXPECT_EQ(log_contents, absl::Substitute("Got input: $0\n", input));
   }
 
   {
     const std::string input = "ret42";
-    const std::string log_prefix = std::filesystem::path{test_tmpdir} / input;
+    const std::string log_prefix = test_tmpdir / input;
     Command::Options cmd_options;
     cmd_options.args = {input};
     cmd_options.stdout_file_prefix = log_prefix;
     cmd_options.stderr_file_prefix = log_prefix;
     Command cmd{helper, std::move(cmd_options)};
-    EXPECT_TRUE(cmd.StartForkServer(test_tmpdir, "ForkServer"));
+    EXPECT_TRUE(cmd.StartForkServer(test_tmpdir.string(), "ForkServer"));
     EXPECT_EQ(cmd.Execute(), 42);
     std::string log_contents;
     ReadFromLocalFile(cmd.stdout_file(), log_contents);
-    EXPECT_EQ(log_contents, absl::Substitute("Got input: $0", input));
+    EXPECT_EQ(log_contents, absl::Substitute("Got input: $0\n", input));
   }
 
   {
     const std::string input = "abort";
-    const std::string log_prefix = std::filesystem::path{test_tmpdir} / input;
+    const std::string log_prefix = test_tmpdir / input;
     Command::Options cmd_options;
     cmd_options.args = {input};
     cmd_options.stdout_file_prefix = log_prefix;
     cmd_options.stderr_file_prefix = log_prefix;
     Command cmd{helper, std::move(cmd_options)};
-    EXPECT_TRUE(cmd.StartForkServer(test_tmpdir, "ForkServer"));
+    EXPECT_TRUE(cmd.StartForkServer(test_tmpdir.string(), "ForkServer"));
     // WTERMSIG() needs an lvalue on some platforms.
     const int ret = cmd.Execute();
     EXPECT_EQ(WTERMSIG(ret), SIGABRT);
     std::string log_contents;
     ReadFromLocalFile(cmd.stdout_file(), log_contents);
-    EXPECT_EQ(log_contents, absl::Substitute("Got input: $0", input));
+    EXPECT_EQ(log_contents, absl::Substitute("Got input: $0\n", input));
   }
 
   {
     const std::string input = "sleep";
-    const std::string log_prefix = std::filesystem::path{test_tmpdir} / input;
+    const std::string log_prefix = test_tmpdir / input;
     Command::Options cmd_options;
     cmd_options.args = {input};
     cmd_options.stdout_file_prefix = log_prefix;
     cmd_options.stderr_file_prefix = log_prefix;
     Command cmd{helper, std::move(cmd_options)};
-    ASSERT_TRUE(cmd.StartForkServer(test_tmpdir, "ForkServer"));
+    ASSERT_TRUE(cmd.StartForkServer(test_tmpdir.string(), "ForkServer"));
     ASSERT_TRUE(cmd.ExecuteAsync());
     EXPECT_EQ(cmd.Wait(absl::Now() + absl::Seconds(2)), std::nullopt);
     cmd.RequestStop(/*force=*/false);
     EXPECT_THAT(cmd.Wait(absl::Now() + absl::Seconds(2)), Optional(SIGTERM));
     std::string log_contents;
     ReadFromLocalFile(cmd.stdout_file(), log_contents);
-    EXPECT_EQ(log_contents, absl::Substitute("Got input: $0", input));
+    EXPECT_EQ(log_contents, absl::Substitute("Got input: $0\n", input));
   }
 
   {
     const std::string input = "hang";
-    const std::string log_prefix = std::filesystem::path{test_tmpdir} / input;
+    const std::string log_prefix = test_tmpdir / input;
     Command::Options cmd_options;
     cmd_options.args = {input};
     cmd_options.stdout_file_prefix = log_prefix;
     cmd_options.stderr_file_prefix = log_prefix;
     Command cmd{helper, std::move(cmd_options)};
-    ASSERT_TRUE(cmd.StartForkServer(test_tmpdir, "ForkServer"));
+    ASSERT_TRUE(cmd.StartForkServer(test_tmpdir.string(), "ForkServer"));
     ASSERT_TRUE(cmd.ExecuteAsync());
     EXPECT_EQ(cmd.Wait(absl::Now() + absl::Seconds(2)), std::nullopt);
     cmd.RequestStop(/*force=*/false);
@@ -187,11 +252,12 @@
     EXPECT_THAT(cmd.Wait(absl::Now() + absl::Seconds(2)), Optional(SIGKILL));
     std::string log_contents;
     ReadFromLocalFile(cmd.stdout_file(), log_contents);
-    EXPECT_EQ(log_contents, absl::Substitute("Got input: $0", input));
+    EXPECT_EQ(log_contents, absl::Substitute("Got input: $0\n", input));
   }
 
   // TODO(kcc): [impl] test what happens if the child is interrupted.
 }
+#endif
 
 }  // namespace
 }  // namespace fuzztest::internal
diff --git a/centipede/command_test_helper.cc b/centipede/command_test_helper.cc
index d3153a8..b7ad1be 100644
--- a/centipede/command_test_helper.cc
+++ b/centipede/command_test_helper.cc
@@ -12,33 +12,93 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-#include <unistd.h>
-
 #include <cassert>
 #include <csignal>
 #include <cstdio>
 #include <cstdlib>
 #include <cstring>
 
+#if defined(_WIN32)
+#define WIN32_LEAN_AND_MEAN
+#define NOGDI
+#include <fcntl.h>
+#include <io.h>
+#include <windows.h>
+#endif
+
 #include "absl/base/nullability.h"
+#include "absl/strings/match.h"
+#include "absl/strings/numbers.h"
+#include "absl/strings/string_view.h"
 #include "absl/time/clock.h"
 #include "absl/time/time.h"
 
 // A binary linked with the fork server that exits/crashes in different ways.
 int main(int argc, char** absl_nonnull argv) {
-  assert(argc == 2);
-  printf("Got input: %s", argv[1]);
+#if defined(_WIN32)
+  // Disable the automatic \n -> \r\n conversion.
+  _setmode(1, _O_BINARY);
+#endif
+
+  assert(argc >= 2);
+  printf("Got input: %s\n", argv[1]);
+
+  if (!strcmp(argv[1], "echo_args")) {
+    for (int i = 2; i < argc; ++i) {
+      printf("arg[%d]=%s\n", i - 2, argv[i]);
+    }
+    fflush(stdout);
+    return EXIT_SUCCESS;
+  }
+
+  if (!strcmp(argv[1], "echo_env")) {
+    for (int i = 2; i < argc; ++i) {
+      const char* val = getenv(argv[i]);
+      printf("%s=%s\n", argv[i], val ? val : "<UNSET>");
+    }
+    fflush(stdout);
+    return EXIT_SUCCESS;
+  }
+
+  if (!strcmp(argv[1], "echo_stdin")) {
+    char buf[1024];
+    while (fgets(buf, sizeof(buf), stdin)) {
+      fputs(buf, stdout);
+    }
+    fflush(stdout);
+    return EXIT_SUCCESS;
+  }
+
   fflush(stdout);
+
   if (!strcmp(argv[1], "success")) return EXIT_SUCCESS;
   if (!strcmp(argv[1], "fail")) return EXIT_FAILURE;
-  if (!strcmp(argv[1], "ret42")) return 42;
+
+  int ret_code = 0;
+  if (absl::StartsWith(argv[1], "ret") &&
+      absl::SimpleAtoi(argv[1] + 3, &ret_code)) {
+    return ret_code;
+  }
+
   if (!strcmp(argv[1], "abort")) abort();
+  if (!strcmp(argv[1], "ctrlc")) {
+#if defined(_WIN32)
+    SetConsoleCtrlHandler(NULL, FALSE);
+    GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0);
+    Sleep(INFINITE);
+#else
+    raise(SIGINT);
+#endif
+    return EXIT_SUCCESS;
+  }
   // Sleep longer than kTimeout in CommandDeathTest_ForkServerHangingBinary.
   if (!strcmp(argv[1], "sleep")) absl::SleepFor(absl::Seconds(5));
   if (!strcmp(argv[1], "hang")) {
+#if !defined(_WIN32)
     struct sigaction act{};
     act.sa_handler = [](int) {};
     sigaction(SIGTERM, &act, nullptr);
+#endif
     absl::SleepFor(absl::Seconds(10));
   }
 
diff --git a/centipede/puzzles/BUILD b/centipede/puzzles/BUILD
index b87aad0..3d83aef 100644
--- a/centipede/puzzles/BUILD
+++ b/centipede/puzzles/BUILD
@@ -26,7 +26,6 @@
 
 package(default_visibility = ["@com_google_fuzztest//centipede/puzzles:__subpackages__"])
 
-# The puzzles use a simple configuration language, see run_puzzle.sh.
 [puzzle(name = n) for n in [
     "byte_cmp_4",
     "callstack",
diff --git a/centipede/puzzles/autodictionary_stress.cc b/centipede/puzzles/autodictionary_stress.cc
index 297d5ee..40a415d 100644
--- a/centipede/puzzles/autodictionary_stress.cc
+++ b/centipede/puzzles/autodictionary_stress.cc
@@ -12,9 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-// Centipede puzzle: stress test for --use_auto_dictionary=1.
-// RUN: Run --use_auto_dictionary=1 --use_cmp_features=0 -j 5
-// RUN: ExpectInLog "Input bytes.*: abcdxyzVeryLongStringKeyword"
+// CASE main: ARG: --use_auto_dictionary=1
+// CASE main: ARG: --use_cmp_features=0
+// CASE main: ARG: --j=5
+// CASE main: MATCH: Input bytes.*: abcdxyzVeryLongStringKeyword
 
 // TODO(kcc): we currently use --use_cmp_features=0 because otherwise
 // the corpus gets too large and the puzzle does not get solved quickly.
diff --git a/centipede/puzzles/byte_cmp_4.cc b/centipede/puzzles/byte_cmp_4.cc
index 8fb9c4c..9bdcf96 100644
--- a/centipede/puzzles/byte_cmp_4.cc
+++ b/centipede/puzzles/byte_cmp_4.cc
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 // Centipede puzzle: sequence of 4 1-byte comparisons.
-// RUN: Run && SolutionIs fuzZ
+// CASE main: MATCH: Input bytes *: fuzZ
 #include <cstddef>
 #include <cstdint>
 #include <cstdlib>
diff --git a/centipede/puzzles/callstack.cc b/centipede/puzzles/callstack.cc
index 4fc9a8b..7fd1601 100644
--- a/centipede/puzzles/callstack.cc
+++ b/centipede/puzzles/callstack.cc
@@ -18,10 +18,10 @@
 // All functions but F0 also modify `g_result`.
 // The puzzle can be solved if the call sequence is FA->FB->FC->FD->FE.
 
-// clang-format off
-// RUN: Run --callstack_level=10 --use_cmp_features=0 --use_dataflow_features=0 // NOLINT
-// RUN: SolutionIs ABCDE
-// clang-format on
+// CASE main: ARG: --callstack_level=10
+// CASE main: ARG: --use_cmp_features=0
+// CASE main: ARG: --use_dataflow_features=0
+// CASE main: MATCH: Input bytes *: ABCDE
 #include <cstddef>
 #include <cstdint>
 #include <cstdlib>
diff --git a/centipede/puzzles/deep_recursion.cc b/centipede/puzzles/deep_recursion.cc
index 6e7572d..9440cbd 100644
--- a/centipede/puzzles/deep_recursion.cc
+++ b/centipede/puzzles/deep_recursion.cc
@@ -13,11 +13,11 @@
 // limitations under the License.
 
 // Centipede puzzle: use callstack features to reach deep recursion
-// clang-format off
-// RUN: Run --callstack_level=10 --use_cmp_features=0 --max_len=10 --num_runs=10000000 # NOLINT
-// RUN: SolutionIs ABCDEF
-// clang-format on
-#include <sys/resource.h>
+// CASE main: ARG: --callstack_level=10
+// CASE main: ARG: --use_cmp_features=0
+// CASE main: ARG: --max_len=10
+// CASE main: ARG: --num_runs=10000000
+// CASE main: MATCH: Input bytes *: ABCDEF
 
 #include <cstddef>
 #include <cstdint>
diff --git a/centipede/puzzles/independent_compares.cc b/centipede/puzzles/independent_compares.cc
index 07294f9..0679482 100644
--- a/centipede/puzzles/independent_compares.cc
+++ b/centipede/puzzles/independent_compares.cc
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 // Centipede puzzle: 4 independent compares setting a mask.
-// RUN: Run && SolutionIs FUZZ
+// CASE main: MATCH: Input bytes *: FUZZ
 #include <cstddef>
 #include <cstdint>
 #include <cstdlib>
diff --git a/centipede/puzzles/memcmp_3.cc b/centipede/puzzles/memcmp_3.cc
index 36244e3..c12ffe0 100644
--- a/centipede/puzzles/memcmp_3.cc
+++ b/centipede/puzzles/memcmp_3.cc
@@ -14,9 +14,10 @@
 
 // Centipede puzzle: one 3-byte memcmp. Check the output in the log.
 // Disable use_auto_dictionary so that we test other functionality.
-// RUN: Run --use_auto_dictionary=false && SolutionIs fUz
-// RUN: ExpectInLog "TEXT IN STDOUT"
-// RUN: ExpectInLog "TEXT IN STDERR"
+// CASE main: ARG: --use_auto_dictionary=false
+// CASE main: MATCH: Input bytes *: fUz
+// CASE main: MATCH: TEXT IN STDOUT
+// CASE main: MATCH: TEXT IN STDERR
 
 #include <cstdint>
 #include <cstdio>
diff --git a/centipede/puzzles/memcmp_4.cc b/centipede/puzzles/memcmp_4.cc
index 97bbbd5..deefe4a 100644
--- a/centipede/puzzles/memcmp_4.cc
+++ b/centipede/puzzles/memcmp_4.cc
@@ -14,7 +14,9 @@
 
 // Centipede puzzle: one 4-byte memcmp.
 // Disable use_auto_dictionary so that we test other functionality.
-// RUN: Run --use_auto_dictionary=false --max_len=10 && SolutionIs fuZz
+// CASE main: ARG: --use_auto_dictionary=false
+// CASE main: ARG: --max_len=10
+// CASE main: MATCH: Input bytes *: fuZz
 
 #include <cstdint>
 #include <cstdlib>
diff --git a/centipede/puzzles/memcmp_4_may_inline.cc b/centipede/puzzles/memcmp_4_may_inline.cc
index bc2f062..746796d 100644
--- a/centipede/puzzles/memcmp_4_may_inline.cc
+++ b/centipede/puzzles/memcmp_4_may_inline.cc
@@ -14,7 +14,8 @@
 
 // Centipede puzzle: one 4-byte memcmp, which may get inlined.
 // Disable use_auto_dictionary so that we test other functionality.
-// RUN: Run --use_auto_dictionary=false && SolutionIs FUZz
+// CASE main: ARG: --use_auto_dictionary=false
+// CASE main: MATCH: Input bytes *: FUZz
 
 #include <cstdint>
 #include <cstdlib>
diff --git a/centipede/puzzles/oom.cc b/centipede/puzzles/oom.cc
index bb20e5d..07270d2 100644
--- a/centipede/puzzles/oom.cc
+++ b/centipede/puzzles/oom.cc
@@ -13,7 +13,9 @@
 // limitations under the License.
 
 // Centipede puzzle: easy-to-reach OOM.
-// RUN: Run --rss_limit_mb=1000 && SolutionIs OOM && ExpectOOM
+// CASE main: ARG: --rss_limit_mb=1000
+// CASE main: MATCH: Input bytes *: OOM
+// CASE main: MATCH: Failure.*: rss-limit-exceeded
 #include <cstddef>
 #include <cstdint>
 #include <cstdio>
diff --git a/centipede/puzzles/paths.cc b/centipede/puzzles/paths.cc
index ab8de1a..654300b 100644
--- a/centipede/puzzles/paths.cc
+++ b/centipede/puzzles/paths.cc
@@ -16,8 +16,10 @@
 // We disable use_dataflow_features because on this puzzle
 // it is also effective.
 
-// RUN: Run --use_dataflow_features=0 --use_cmp_features=0 --path_level=10
-// RUN: ExpectInLog "Input bytes.*: .x1.x2.x3"
+// CASE main: ARG: --use_dataflow_features=0
+// CASE main: ARG: --use_cmp_features=0
+// CASE main: ARG: --path_level=10
+// CASE main: MATCH: Input bytes.*: .x1.x2.x3
 
 // This puzzle aborts on input "\x1\x2\x3"
 // The code here has very little control flow, but an exponential number of
diff --git a/centipede/puzzles/per_batch_timeout.cc b/centipede/puzzles/per_batch_timeout.cc
index f64f009..9b2e506 100644
--- a/centipede/puzzles/per_batch_timeout.cc
+++ b/centipede/puzzles/per_batch_timeout.cc
@@ -13,10 +13,10 @@
 // limitations under the License.
 
 // Centipede puzzle: easy-to-reach per-batch timeout.
-// clang-format off
-// NOLINTNEXTLINE
-// RUN: Run --batch_size=10 --timeout_per_input=2 --timeout_per_batch=7 && ExpectPerBatchTimeout
-// clang-format on
+// CASE main: ARG: --batch_size=10
+// CASE main: ARG: --timeout_per_input=2
+// CASE main: ARG: --timeout_per_batch=7
+// CASE main: MATCH: Failure.*: per-batch-timeout-exceeded
 
 #include <unistd.h>
 
diff --git a/centipede/puzzles/per_input_timeout.cc b/centipede/puzzles/per_input_timeout.cc
index d286543..3f2e4e3 100644
--- a/centipede/puzzles/per_input_timeout.cc
+++ b/centipede/puzzles/per_input_timeout.cc
@@ -12,8 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-// Centipede puzzle: easy-to-reach per-input timeout.
-// RUN: Run --timeout_per_input=2 && SolutionIs SLO && ExpectPerInputTimeout
+// CASE main: ARG: --timeout_per_input=2
+// CASE main: MATCH: Input bytes *: SLO
+// CASE main: MATCH: Failure.*: per-input-timeout-exceeded
 
 #include <unistd.h>
 
diff --git a/centipede/puzzles/pthread_exit_uint32_cmp_1.cc b/centipede/puzzles/pthread_exit_uint32_cmp_1.cc
index 1e5bade..f132c08 100644
--- a/centipede/puzzles/pthread_exit_uint32_cmp_1.cc
+++ b/centipede/puzzles/pthread_exit_uint32_cmp_1.cc
@@ -15,10 +15,11 @@
 // Centipede puzzle: one 4-byte cmp, in a separate thread using pthread
 // interface. We should be able to solve it w/o cmp features *or* w/o auto
 // dictionary.
-//
-// RUN: Run && SolutionIs Fuzz
-// RUN: Run --use_auto_dictionary=0 && SolutionIs Fuzz
-// RUN: Run --use_cmp_features=0 && SolutionIs Fuzz
+// CASE main: MATCH: Input bytes *: Fuzz
+// CASE no_cmp: ARG: --use_cmp_features=0
+// CASE no_cmp: MATCH: Input bytes *: Fuzz
+// CASE no_autodict: ARG: --use_auto_dictionary=0
+// CASE no_autodict: MATCH: Input bytes *: Fuzz
 
 #include <pthread.h>
 
diff --git a/centipede/puzzles/puzzle.bzl b/centipede/puzzles/puzzle.bzl
index a446b96..91189ff 100644
--- a/centipede/puzzles/puzzle.bzl
+++ b/centipede/puzzles/puzzle.bzl
@@ -14,7 +14,7 @@
 
 """BUILD rule for Centipede puzzles"""
 
-load("@rules_shell//shell:sh_test.bzl", "sh_test")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
 load("@com_google_fuzztest//centipede/testing:build_defs.bzl", "centipede_fuzz_target")
 
 def puzzle(name):
@@ -36,13 +36,23 @@
     # repeatability. Each sh_test performs a single run with a single seed, so
     # that the log is minimal.
     for seed in ["1", "2"]:
-        sh_test(
+        cc_test(
             name = "run_" + seed + "_" + name,
-            srcs = ["run_puzzle.sh"],
+            srcs = ["run_puzzle.cc"],
+            args = ["--seed=" + seed, "--puzzle=" + name],
             data = [
                 ":" + name,
                 name + ".cc",
                 "@com_google_fuzztest//centipede:centipede_uninstrumented",
-                "@com_google_fuzztest//centipede:test_util_sh",
+            ],
+            deps = [
+                "@googletest//:gtest",
+                "@abseil-cpp//absl/flags:flag",
+                "@abseil-cpp//absl/flags:parse",
+                "@abseil-cpp//absl/strings",
+                "@abseil-cpp//absl/time",
+                "@com_google_fuzztest//centipede:command",
+                "@com_google_fuzztest//common:logging",
+                "@com_google_fuzztest//common:test_util",
             ],
         )
diff --git a/centipede/puzzles/run_puzzle.cc b/centipede/puzzles/run_puzzle.cc
new file mode 100644
index 0000000..939675a
--- /dev/null
+++ b/centipede/puzzles/run_puzzle.cc
@@ -0,0 +1,179 @@
+// Copyright 2026 The FuzzTest Authors.
+//
+// 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
+//
+//      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.
+
+#include <filesystem>  // NOLINT
+#include <fstream>
+#include <set>
+#include <sstream>
+#include <string>
+#include <string_view>
+#include <system_error>  // NOLINT
+#include <utility>
+#include <vector>
+
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "absl/flags/flag.h"
+#include "absl/flags/parse.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/str_split.h"
+#include "absl/time/time.h"
+#include "./centipede/command.h"
+#include "./common/logging.h"
+#include "./common/test_util.h"
+
+ABSL_FLAG(int, seed, 0, "The random seed to use");
+ABSL_FLAG(std::string, puzzle, "", "The name of the puzzle to run");
+
+namespace fuzztest::internal {
+namespace {
+
+using testing::ContainsRegex;
+using testing::Value;
+
+std::string ReadFile(std::filesystem::path path) {
+  std::ifstream file(path);
+  std::stringstream ss;
+  ss << file.rdbuf();
+  return ss.str();
+}
+
+constexpr std::string_view kCasePrefix = "// CASE ";
+constexpr std::string_view kArgPrefix = "ARG:";
+constexpr std::string_view kMatchPrefix = "MATCH:";
+
+std::vector<std::string> GetCasesInPuzzle(std::string_view puzzle) {
+  const std::string puzzle_source_path =
+      GetDataDependencyFilepath(
+          absl::StrCat("centipede/puzzles/", puzzle, ".cc"))
+          .string();
+  std::error_code ec;
+  FUZZTEST_CHECK(std::filesystem::exists(puzzle_source_path, ec))
+      << "Puzzle source " << puzzle_source_path << " does not exist";
+  const std::string puzzle_source = ReadFile(puzzle_source_path);
+  std::set<std::string> found_cases;
+  for (std::string_view line : absl::StrSplit(puzzle_source, '\n')) {
+    const auto case_start_pos = line.find(kCasePrefix);
+    if (case_start_pos == line.npos) continue;
+    const auto case_end_pos =
+        line.find(":", case_start_pos + kCasePrefix.size());
+    if (case_end_pos == line.npos) continue;
+    if (line.find(kMatchPrefix, case_end_pos) == line.npos) continue;
+    found_cases.insert(std::string{
+        line.substr(case_start_pos + kCasePrefix.size(),
+                    case_end_pos - case_start_pos - kCasePrefix.size())});
+  }
+  return {found_cases.begin(), found_cases.end()};
+}
+
+class PuzzleTest : public testing::Test {
+ public:
+  PuzzleTest(std::string_view puzzle, std::string_view case_name)
+      : puzzle_{puzzle}, case_name_{case_name} {}
+
+  void TestBody() {
+    const std::string centipede_path =
+        GetDataDependencyFilepath("centipede/centipede_uninstrumented")
+            .string();
+    const std::string puzzle_binary_path =
+        GetDataDependencyFilepath(absl::StrCat("centipede/puzzles/", puzzle_))
+            .string();
+    const std::string puzzle_source_path =
+        GetDataDependencyFilepath(
+            absl::StrCat("centipede/puzzles/", puzzle_, ".cc"))
+            .string();
+    std::error_code ec;
+    FUZZTEST_CHECK(std::filesystem::exists(puzzle_binary_path, ec))
+        << "Puzzle binary " << puzzle_binary_path << " does not exist";
+    FUZZTEST_CHECK(std::filesystem::exists(puzzle_source_path, ec))
+        << "Puzzle source " << puzzle_source_path << " does not exist";
+    TempDir tmp_dir{absl::StrCat(puzzle_, "_", case_name_)};
+
+    const std::string case_prefix = absl::StrCat(kCasePrefix, case_name_, ":");
+    std::vector<std::string> extra_args;
+    std::vector<std::string> output_matches;
+    const std::string puzzle_source = ReadFile(puzzle_source_path);
+    for (std::string_view line : absl::StrSplit(puzzle_source, '\n')) {
+      const auto case_pos = line.find(case_prefix);
+      if (case_pos == line.npos) continue;
+      if (const auto arg_pos =
+              line.find(kArgPrefix, case_pos + case_prefix.size());
+          arg_pos != line.npos) {
+        auto arg = std::string{absl::StripAsciiWhitespace(
+            line.substr(arg_pos + kArgPrefix.size()))};
+        FUZZTEST_LOG(INFO) << "Using arg: " << arg;
+        extra_args.push_back(std::move(arg));
+        continue;
+      }
+      if (const auto output_match_pos =
+              line.find(kMatchPrefix, case_pos + case_prefix.size());
+          output_match_pos != line.npos) {
+        auto output_match = std::string{absl::StripAsciiWhitespace(
+            line.substr(output_match_pos + kMatchPrefix.size()))};
+        FUZZTEST_LOG(INFO) << "Using match: " << output_match;
+        output_matches.push_back(std::move(output_match));
+        continue;
+      }
+    }
+    FUZZTEST_CHECK(!output_matches.empty())
+        << "Need at least one output match for case " << case_name_;
+
+    Command::Options cmd_options;
+    cmd_options.args = {
+        absl::StrCat("--workdir=", (tmp_dir.path() / "workdir").string()),
+        absl::StrCat("--binary=", puzzle_binary_path),
+        "--populate_binary_info=0",
+        absl::StrCat("--seed=", absl::GetFlag(FLAGS_seed)),
+        "--num_runs=2000000",
+        "--shmem_size_mb=100",
+        "--exit_on_crash",
+    };
+    cmd_options.args.insert(cmd_options.args.end(), extra_args.begin(),
+                            extra_args.end());
+
+    const std::string output_prefix = (tmp_dir.path() / "out_").string();
+    cmd_options.stdout_file_prefix = output_prefix;
+    cmd_options.stderr_file_prefix = output_prefix;
+    Command cmd(centipede_path, cmd_options);
+    FUZZTEST_CHECK(cmd.ExecuteAsync());
+    (void)cmd.Wait(absl::Now() + absl::Seconds(30));
+    const std::string output = ReadFile(cmd.stdout_file());
+    for (const auto& output_match : output_matches) {
+      EXPECT_TRUE(Value(output, ContainsRegex(output_match))) << output;
+    }
+  }
+
+ private:
+  std::string puzzle_;
+  std::string case_name_;
+};
+
+}  // namespace
+}  // namespace fuzztest::internal
+
+int main(int argc, char** argv) {
+  absl::ParseCommandLine(argc, argv);
+  testing::InitGoogleTest(&argc, argv);
+  const std::string puzzle = absl::GetFlag(FLAGS_puzzle);
+  FUZZTEST_CHECK(!puzzle.empty());
+  const auto cases = fuzztest::internal::GetCasesInPuzzle(puzzle);
+  for (const auto& case_name : cases) {
+    testing::RegisterTest(
+        "Puzzle", case_name.c_str(), nullptr, nullptr, __FILE__, __LINE__,
+        [puzzle, case_name]() -> testing::Test* {
+          return new fuzztest::internal::PuzzleTest{puzzle, case_name};
+        });
+  }
+  return RUN_ALL_TESTS();
+}
diff --git a/centipede/puzzles/run_puzzle.sh b/centipede/puzzles/run_puzzle.sh
deleted file mode 100755
index d6d12c8..0000000
--- a/centipede/puzzles/run_puzzle.sh
+++ /dev/null
@@ -1,122 +0,0 @@
-#!/bin/bash
-
-# Copyright 2022 The Centipede Authors.
-#
-# 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
-#
-#      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.
-
-# Run a short fuzzing session for one puzzle and check the outcome.
-# This script is executed under the name run_S_PUZZLE_NAME, where S is a single
-# digit representing the seed, so we get the seed and puzzle name from $0.
-# Every puzzle must have one or more lines containing "RUN:<program-text>"
-# This script will execute <program-text> directly in the current context.
-# <program-text> can use the functions defined in this file, see USER_FUNCTIONS.
-
-set -eu -o pipefail
-
-ls -la "$(dirname "$0")"
-
-source "$(dirname "$0")/../test_util.sh"
-
-readonly centipede_dir="$(fuzztest::internal::get_centipede_test_srcdir)"
-fuzztest::internal::maybe_set_var_to_executable_path centipede "${centipede_dir}/centipede_uninstrumented"
-readonly centipede
-fuzztest::internal::maybe_set_var_to_executable_path llvm_symbolizer "$(fuzztest::internal::get_llvm_symbolizer_path)"
-readonly llvm_symbolizer
-fuzztest::internal::maybe_set_var_to_executable_path objdump "$(fuzztest::internal::get_objdump_path)"
-readonly objdump
-
-readonly target_name="$(basename "$0")"
-readonly seed_and_puzzle_name="${target_name#run_}"
-readonly seed="${seed_and_puzzle_name:0:1}"
-readonly puzzle_name="${seed_and_puzzle_name:2}"
-readonly puzzle_source_name="${puzzle_name}.cc"
-readonly puzzle_path="${centipede_dir}/puzzles/${puzzle_name}"
-readonly puzzle_source_path="${centipede_dir}/puzzles/${puzzle_source_name}"
-
-readonly workdir="${TEST_TMPDIR}/workdir"
-readonly log="${TEST_TMPDIR}/log"
-readonly script="${TEST_TMPDIR}/script"
-
-# Read the configuration from the puzzle source.
-grep 'RUN:' "${puzzle_source_path}" | sed 's/^.*RUN://' > "${script}"
-echo "======== SCRIPT"
-cat "${script}"
-echo "======== END SCRIPT"
-
-##################################### USER_FUNCTIONS
-
-# Runs Centipede with additional parameters in $@, saves the result in log, cats
-# the log. Expects Centipede to exit with failure.
-function Run() {
-  echo "======== Run $*"
-  rm -rf "${workdir}"
-  mkdir "${workdir}"
-  if "${centipede}" \
-    --workdir "${workdir}" \
-    --binary "${puzzle_path}" \
-    --symbolizer_path="${llvm_symbolizer}" \
-    --objdump_path="${objdump}$" \
-    --seed="${seed}" \
-    --num_runs=2000000 \
-    --timeout_per_input=10 \
-    --exit_on_crash \
-    "$@" \
-    2>&1 | tee "${log}"
-  then
-    # Centipede must exit with failure.
-    return 1
-  fi
-}
-
-# Checks that $1 is the solution for the puzzle.
-function SolutionIs() {
-  echo "====== ${FUNCNAME[0]}: $1"
-  fuzztest::internal::assert_regex_in_file "Input bytes.*: $1" "${log}"
-}
-
-# Expects that Centipede found a per-input timeout.
-function ExpectPerInputTimeout() {
-  echo "======= ${FUNCNAME[0]}"
-  fuzztest::internal::assert_regex_in_file "Per-input timeout exceeded" "${log}"
-  fuzztest::internal::assert_regex_in_file "Failure.*: per-input-timeout-exceeded" "${log}"
-}
-
-# Expects that Centipede found a per-batch timeout.
-function ExpectPerBatchTimeout() {
-  echo "======= ${FUNCNAME[0]}"
-  fuzztest::internal::assert_regex_in_file "Failure.*: per-batch-timeout-exceeded" "${log}"
-  fuzztest::internal::assert_regex_in_file \
-    "Failure applies to entire batch: not executing inputs one-by-one" "${log}"
-  fuzztest::internal::assert_regex_not_in_file \
-    "Executing inputs one-by-one, trying to find the reproducer" "${log}"
-}
-
-# Expects that Centipede found a OOM.
-function ExpectOOM() {
-  echo "======= ${FUNCNAME[0]}"
-  fuzztest::internal::assert_regex_in_file "RSS limit exceeded" "${log}"
-  fuzztest::internal::assert_regex_in_file "Failure.*: rss-limit-exceeded" "${log}"
-}
-
-# Expects that $1 is found in the log.
-function ExpectInLog() {
-  echo "======= ${FUNCNAME[0]}: $1"
-  fuzztest::internal::assert_regex_in_file "$1" "${log}"
-}
-
-##################################### end USER_FUNCTIONS
-
-# shellcheck disable=SC1090
-source "${script}"
-
-echo PASS
diff --git a/centipede/puzzles/strcasecmp.cc b/centipede/puzzles/strcasecmp.cc
index c9aaadd..670a9f5 100644
--- a/centipede/puzzles/strcasecmp.cc
+++ b/centipede/puzzles/strcasecmp.cc
@@ -15,9 +15,10 @@
 // Centipede puzzle: one 3-byte strncasecmp and one 3-byte strcasecmp. Check
 // the output in the log. Disable use_auto_dictionary so that we test other
 // functionality.
-// RUN: Run --use_auto_dictionary=false && SolutionIs 123456
-// RUN: ExpectInLog "TEXT IN STDOUT"
-// RUN: ExpectInLog "TEXT IN STDERR"
+// CASE main: ARG: --use_auto_dictionary=false
+// CASE main: MATCH: Input bytes *: 123456
+// CASE main: MATCH: TEXT IN STDOUT
+// CASE main: MATCH: TEXT IN STDERR
 
 #include <strings.h>
 
diff --git a/centipede/puzzles/strcmp.cc b/centipede/puzzles/strcmp.cc
index c61bb17..338d338 100644
--- a/centipede/puzzles/strcmp.cc
+++ b/centipede/puzzles/strcmp.cc
@@ -14,9 +14,10 @@
 
 // Centipede puzzle: one 4-byte strcmp. Check the output in the log.
 // Disable use_auto_dictionary so that we test other functionality.
-// RUN: Run --use_auto_dictionary=false && SolutionIs fUzZ
-// RUN: ExpectInLog "TEXT IN STDOUT"
-// RUN: ExpectInLog "TEXT IN STDERR"
+// CASE main: ARG: --use_auto_dictionary=false
+// CASE main: MATCH: Input bytes *: fUzZ
+// CASE main: MATCH: TEXT IN STDOUT
+// CASE main: MATCH: TEXT IN STDERR
 
 #include <cstdint>
 #include <cstdio>
diff --git a/centipede/puzzles/strncmp.cc b/centipede/puzzles/strncmp.cc
index 4668e11..74f622d 100644
--- a/centipede/puzzles/strncmp.cc
+++ b/centipede/puzzles/strncmp.cc
@@ -14,9 +14,10 @@
 
 // Centipede puzzle: one 4-byte strncmp. Check the output in the log.
 // Disable use_auto_dictionary so that we test other functionality.
-// RUN: Run --use_auto_dictionary=false && SolutionIs fUzZ
-// RUN: ExpectInLog "TEXT IN STDOUT"
-// RUN: ExpectInLog "TEXT IN STDERR"
+// CASE main: ARG: --use_auto_dictionary=false
+// CASE main: MATCH: Input bytes *: fUzZ
+// CASE main: MATCH: TEXT IN STDOUT
+// CASE main: MATCH: TEXT IN STDERR
 
 #include <cstdint>
 #include <cstdio>
diff --git a/centipede/puzzles/switch.cc b/centipede/puzzles/switch.cc
index 40a61a2..8e7f09e 100644
--- a/centipede/puzzles/switch.cc
+++ b/centipede/puzzles/switch.cc
@@ -13,7 +13,9 @@
 // limitations under the License.
 
 // Centipede puzzle: one 4-byte switch.
-// RUN: Run --max_len=10 --use_cmp_features=0 && ExpectInLog "deadbeef found!"
+// CASE main: ARG: --max_len=10
+// CASE main: ARG: --use_cmp_features=0
+// CASE main: MATCH: deadbeef found!
 
 #include <cstdint>
 #include <cstdio>
diff --git a/centipede/puzzles/thread_uint32_cmp_1.cc b/centipede/puzzles/thread_uint32_cmp_1.cc
index 0665815..962fe3e 100644
--- a/centipede/puzzles/thread_uint32_cmp_1.cc
+++ b/centipede/puzzles/thread_uint32_cmp_1.cc
@@ -14,9 +14,11 @@
 
 // Centipede puzzle: one 4-byte cmp, in a separate thread.
 // We should be able to solve it w/o cmp features *or* w/o auto dictionary.
-// RUN: Run && SolutionIs Fuzz
-// RUN: Run --use_auto_dictionary=0 && SolutionIs Fuzz
-// RUN: Run --use_cmp_features=0 && SolutionIs Fuzz
+// CASE main: MATCH: Input bytes *: Fuzz
+// CASE no_cmp: ARG: --use_cmp_features=0
+// CASE no_cmp: MATCH: Input bytes *: Fuzz
+// CASE no_autodict: ARG: --use_auto_dictionary=0
+// CASE no_autodict: MATCH: Input bytes *: Fuzz
 
 #include <cstdint>
 #include <cstdlib>
diff --git a/centipede/puzzles/uint32_cmp_1.cc b/centipede/puzzles/uint32_cmp_1.cc
index f41879b..3b4df01 100644
--- a/centipede/puzzles/uint32_cmp_1.cc
+++ b/centipede/puzzles/uint32_cmp_1.cc
@@ -14,9 +14,11 @@
 
 // Centipede puzzle: one 4-byte cmp.
 // We should be able to solve it w/o cmp features *or* w/o auto dictionary.
-// RUN: Run && SolutionIs Fuzz
-// RUN: Run --use_cmp_features=0 && SolutionIs Fuzz
-// RUN: Run --use_auto_dictionary=0 && SolutionIs Fuzz
+// CASE main: MATCH: Input bytes *: Fuzz
+// CASE no_cmp: ARG: --use_cmp_features=0
+// CASE no_cmp: MATCH: Input bytes *: Fuzz
+// CASE no_autodict: ARG: --use_auto_dictionary=0
+// CASE no_autodict: MATCH: Input bytes *: Fuzz
 
 #include <cstdint>
 #include <cstdlib>
diff --git a/centipede/symbol_table.cc b/centipede/symbol_table.cc
index e693b48..be0d8e8 100644
--- a/centipede/symbol_table.cc
+++ b/centipede/symbol_table.cc
@@ -99,7 +99,8 @@
                                        std::string_view tmp_dir_path) {
   static std::atomic_size_t unique_id = 0;
   size_t unique_id_value = unique_id.fetch_add(1);
-  const std::string dso_basename = std::filesystem::path{dso_path}.filename();
+  const std::string dso_basename =
+      std::filesystem::path{dso_path}.filename().string();
   const ScopedFile pcs_file{
       tmp_dir_path, absl::StrCat(dso_basename, ".pcs.", unique_id_value)};
   const auto symbols_file_prefix = std::filesystem::path{tmp_dir_path} /
@@ -120,10 +121,9 @@
       "--no-inlines",
       "-e",
       std::string(dso_path),
-      "<",
-      std::string(pcs_file.path()),
   };
-  cmd_options.stdout_file_prefix = symbols_file_prefix;
+  cmd_options.stdin_file_path = std::string(pcs_file.path());
+  cmd_options.stdout_file_prefix = symbols_file_prefix.string();
   Command cmd{symbolizer_path, std::move(cmd_options)};
   int exit_code = cmd.Execute();
   if (exit_code != EXIT_SUCCESS) {
@@ -172,7 +172,7 @@
   {
     // Symbolization is quite IO-bound so we arbitrarily run 30 at once
     // even if we have few CPUs.
-    const size_t num_threads = std::min(dso_table.size(), 30UL);
+    const size_t num_threads = std::min(dso_table.size(), size_t{30});
     fuzztest::internal::ThreadPool thread_pool(num_threads);
     for (size_t dso_id = 0; dso_id < dso_table.size(); ++dso_id) {
       const auto &dso_info = dso_table[dso_id];
diff --git a/centipede/util.cc b/centipede/util.cc
index ab2eb19..748b76c 100644
--- a/centipede/util.cc
+++ b/centipede/util.cc
@@ -17,8 +17,16 @@
 
 #include "./centipede/util.h"
 
+#if defined(_WIN32)
+#define WIN32_LEAN_AND_MEAN
+#define NOGDI
+#include <process.h>
+#include <windows.h>
+#include <winsock2.h>
+#else
 #include <sys/mman.h>
 #include <unistd.h>
+#endif
 
 #include <algorithm>
 #include <cctype>
@@ -47,6 +55,7 @@
 #include "absl/base/const_init.h"
 #include "absl/base/nullability.h"
 #include "absl/base/thread_annotations.h"
+#include "absl/strings/match.h"
 #include "absl/strings/str_format.h"
 #include "absl/strings/str_replace.h"
 #include "absl/strings/str_split.h"
@@ -63,8 +72,13 @@
 
 size_t GetRandomSeed(size_t seed) {
   if (seed != 0) return seed;
+#if defined(_WIN32)
+  return time(nullptr) + GetCurrentProcessId() +
+         std::hash<std::thread::id>{}(std::this_thread::get_id());
+#else
   return time(nullptr) + getpid() +
          std::hash<std::thread::id>{}(std::this_thread::get_id());
+#endif
 }
 
 std::string AsPrintableString(ByteSpan data, size_t max_len) {
@@ -83,7 +97,7 @@
 
 template <typename Container>
 void ReadFromLocalFile(std::string_view file_path, Container &data) {
-  std::ifstream f(std::string{file_path});
+  std::ifstream f(std::string{file_path}, std::ios::in | std::ios::binary);
   if (!f) return;
   f.seekg(0, std::ios_base::end);
   auto size = f.tellg();
@@ -112,12 +126,13 @@
 }
 
 void ClearLocalFileContents(std::string_view file_path) {
-  std::ofstream f(std::string{file_path}, std::ios::out | std::ios::trunc);
+  std::ofstream f(std::string{file_path},
+                  std::ios::out | std::ios::trunc | std::ios::binary);
   FUZZTEST_CHECK(f) << "Failed to clear the file: " << file_path;
 }
 
 void WriteToLocalFile(std::string_view file_path, ByteSpan data) {
-  std::ofstream f(std::string{file_path});
+  std::ofstream f(std::string{file_path}, std::ios::out | std::ios::binary);
   FUZZTEST_CHECK(f) << "Failed to open local file: " << file_path;
   f.write(reinterpret_cast<const char *>(data.data()),
           static_cast<int64_t>(data.size()));
@@ -136,13 +151,15 @@
 
 void WriteToLocalHashedFileInDir(std::string_view dir_path, ByteSpan data) {
   if (dir_path.empty()) return;
-  std::string file_path = std::filesystem::path(dir_path).append(Hash(data));
+  std::string file_path =
+      std::filesystem::path(dir_path).append(Hash(data)).string();
   WriteToLocalFile(file_path, data);
 }
 
 void WriteToRemoteHashedFileInDir(std::string_view dir_path, ByteSpan data) {
   if (dir_path.empty()) return;
-  std::string file_path = std::filesystem::path(dir_path).append(Hash(data));
+  std::string file_path =
+      std::filesystem::path(dir_path).append(Hash(data)).string();
   FUZZTEST_CHECK_OK(
       RemoteFileSetContents(file_path, std::string(data.begin(), data.end())));
 }
@@ -155,17 +172,24 @@
 }
 
 std::string ProcessAndThreadUniqueID(std::string_view prefix) {
-  // operator << is the only way to serialize std::this_thread::get_id().
   std::ostringstream oss;
+#if defined(_WIN32)
+  oss << prefix << GetCurrentProcessId() << "-" << GetCurrentThreadId();
+#else
+  // operator << is the only way to serialize std::this_thread::get_id().
   oss << prefix << getpid() << "-" << std::this_thread::get_id();
+#endif
   return oss.str();
 }
 
 std::string TemporaryLocalDirPath() {
   const char *TMPDIR = getenv("TMPDIR");
+  if (!TMPDIR) TMPDIR = getenv("TEMP");
+  if (!TMPDIR) TMPDIR = getenv("TMP");
   std::string tmp = TMPDIR ? TMPDIR : "/tmp";
-  return std::filesystem::path(tmp).append(
-      ProcessAndThreadUniqueID("centipede-"));
+  return std::filesystem::path(tmp)
+      .append(ProcessAndThreadUniqueID("centipede-"))
+      .string();
 }
 
 // We need to maintain a global set of dirs that CreateLocalDirRemovedAtExit()
@@ -189,13 +213,15 @@
 
 void CreateLocalDirRemovedAtExit(std::string_view path) {
   // Safeguard against removing dirs not created by TemporaryLocalDirPath().
-  FUZZTEST_CHECK_NE(path.find("/centipede-"), std::string::npos);
+  FUZZTEST_CHECK(absl::StrContains(path, "/centipede-") ||
+                 absl::StrContains(path, "\\centipede-"));
   // Create the dir.
   std::error_code error;
-  std::filesystem::remove_all(path, error);
-  FUZZTEST_LOG_IF(ERROR, error)
-      << "Unable to clean up existing dir " << path << ": " << error.message();
-  std::filesystem::create_directories(path);
+  std::filesystem::path p(path);
+  if (std::filesystem::exists(p, error)) {
+    std::filesystem::remove_all(p, error);
+  }
+  std::filesystem::create_directories(p, error);
   // Add to dirs_to_delete_at_exit.
   absl::MutexLock lock(dirs_to_delete_at_exit_mutex);
   if (!dirs_to_delete_at_exit) {
@@ -206,7 +232,7 @@
 }
 
 ScopedFile::ScopedFile(std::string_view dir_path, std::string_view name)
-    : my_path_(std::filesystem::path(dir_path) / name) {}
+    : my_path_((std::filesystem::path(dir_path) / name).string()) {}
 
 ScopedFile::~ScopedFile() {
   std::error_code error;
@@ -359,16 +385,52 @@
   return res;
 }
 
+#if defined(_WIN32)
+static LONG CALLBACK
+AutoCommitPageFaultHandler(PEXCEPTION_POINTERS ExceptionInfo) {
+  if (ExceptionInfo->ExceptionRecord->ExceptionCode ==
+      EXCEPTION_ACCESS_VIOLATION) {
+    ULONG_PTR fault_addr =
+        ExceptionInfo->ExceptionRecord->ExceptionInformation[1];
+    if (VirtualAlloc(reinterpret_cast<void*>(fault_addr), 1, MEM_COMMIT,
+                     PAGE_READWRITE) != nullptr) {
+      return EXCEPTION_CONTINUE_EXECUTION;
+    }
+  }
+  return EXCEPTION_CONTINUE_SEARCH;
+}
+#endif
+
 uint8_t *MmapNoReserve(size_t size) {
+#if defined(_WIN32)
+  // Set up page fault handler to commit page on demand.
+  static bool installed_veh = []() {
+    AddVectoredExceptionHandler(1, AutoCommitPageFaultHandler);
+    return true;
+  }();
+  (void)installed_veh;
+  // MEM_RESERVE has different semantics and does not contradict with
+  // MAP_NORESERVE for mmap.
+  auto result = VirtualAlloc(nullptr, size, MEM_RESERVE, PAGE_READWRITE);
+  FUZZTEST_CHECK(result != nullptr)
+      << "VirtualAlloc failed for size " << size << " err=" << GetLastError();
+  return reinterpret_cast<uint8_t*>(result);
+#else
   auto result = mmap(0, size, PROT_READ | PROT_WRITE,
                      MAP_PRIVATE | MAP_ANON | MAP_NORESERVE, -1, 0);
   FUZZTEST_CHECK(result != MAP_FAILED);
   return reinterpret_cast<uint8_t *>(result);
+#endif
 }
 
 void Munmap(uint8_t *ptr, size_t size) {
+#if defined(_WIN32)
+  BOOL result = VirtualFree(ptr, 0, MEM_RELEASE);
+  FUZZTEST_CHECK(result != 0);
+#else
   auto result = munmap(ptr, size);
   FUZZTEST_CHECK_EQ(result, 0);
+#endif
 }
 
 int PollTimeoutMs(absl::Duration timeout) {
diff --git a/centipede/util_test.cc b/centipede/util_test.cc
index 1629f92..c5a6aae 100644
--- a/centipede/util_test.cc
+++ b/centipede/util_test.cc
@@ -32,6 +32,10 @@
 #include "./common/hash.h"
 #include "./common/logging.h"
 
+#if defined(_WIN32)
+#define setenv(n, v, _r) _putenv_s(n, v)
+#endif
+
 namespace fuzztest::internal {
 
 TEST(UtilTest, AsString) {
@@ -116,7 +120,8 @@
     auto temp_dir = TemporaryLocalDirPath();
     // Create dir, create a file there, write to file, read from it, remove dir.
     std::filesystem::create_directories(temp_dir);
-    std::string temp_file_path = std::filesystem::path(temp_dir).append("blah");
+    std::string temp_file_path =
+        std::filesystem::path(temp_dir).append("blah").string();
     ByteArray written_data{1, 2, 3};
     WriteToLocalFile(temp_file_path, written_data);
     ByteArray read_data;
@@ -162,8 +167,8 @@
   EXPECT_TRUE(std::filesystem::exists(tmpdir));
   setenv("CENTIPEDE_UTIL_TEST_TEMP_DIR", tmpdir.c_str(), 1);
   // Create two subdirs via CreateLocalDirRemovedAtExit.
-  std::string subdir1 = std::filesystem::path(tmpdir).append("1");
-  std::string subdir2 = std::filesystem::path(tmpdir).append("2");
+  std::string subdir1 = std::filesystem::path(tmpdir).append("1").string();
+  std::string subdir2 = std::filesystem::path(tmpdir).append("2").string();
   CreateLocalDirRemovedAtExit(subdir1);
   CreateLocalDirRemovedAtExit(subdir2);
   EXPECT_TRUE(std::filesystem::exists(subdir1));
@@ -293,6 +298,15 @@
               testing::ElementsAre(std::vector<int>{1}, std::vector<int>{3}));
 }
 
+TEST(UtilTest, MmapTest) {
+  static constexpr size_t kBufSize = 1 << 30;  // 1 GiB
+  auto* buf = MmapNoReserve(kBufSize);
+  ASSERT_NE(buf, nullptr);
+  EXPECT_EQ(buf[1234], 0);
+  EXPECT_EQ(buf[567890], 0);
+  Munmap(buf, kBufSize);
+}
+
 TEST(UtilTest, PollTimeoutMsWorks) {
   EXPECT_GT(PollTimeoutMs(absl::ZeroDuration()), 0);
   EXPECT_GT(PollTimeoutMs(-absl::InfiniteDuration()), 0);
diff --git a/common/test_util.cc b/common/test_util.cc
index 3232d55..4e04ecf 100644
--- a/common/test_util.cc
+++ b/common/test_util.cc
@@ -18,10 +18,23 @@
 #include <string_view>
 #include <system_error>  // NOLINT
 
+#if !defined(_WIN32)
+#include <unistd.h>
+#else
+#define WIN32_LEAN_AND_MEAN
+#define NOGDI
+#include <process.h>
+#include <windows.h>
+#endif
+
 #include "gtest/gtest.h"
 #include "absl/strings/str_cat.h"
 #include "./common/logging.h"
 
+#if defined(_WIN32)
+#define setenv(n, v, _r) _putenv_s(n, v)
+#endif
+
 namespace fuzztest::internal {
 
 std::filesystem::path GetTestTempDir(std::string_view subdir) {
@@ -35,11 +48,18 @@
     FUZZTEST_CHECK(!error) << "Failed to create dir: " VV(dir)
                            << error.message();
   }
-  return std::filesystem::canonical(dir);
+  return std::filesystem::absolute(dir);
 }
 
 std::string GetTempFilePath(std::string_view subdir, size_t i) {
-  return GetTestTempDir(subdir) / absl::StrCat("tmp.", getpid(), ".", i);
+  return (GetTestTempDir(subdir) / absl::StrCat("tmp.",
+#if defined(_WIN32)
+                                                GetCurrentProcessId(),
+#else
+                                                getpid(),
+#endif
+                                                ".", i))
+      .string();
 }
 
 std::filesystem::path GetTestRunfilesDir() {
@@ -59,6 +79,13 @@
   const auto runfiles_dir = GetTestRunfilesDir();
   auto path = runfiles_dir;
   path.append(rel_path);
+  std::error_code ec;
+  if (std::filesystem::exists(path, ec)) return path;
+#if defined(_WIN32)
+  auto win_path = path;
+  win_path += ".exe";
+  if (std::filesystem::exists(win_path, ec)) return win_path;
+#endif
   FUZZTEST_CHECK(std::filesystem::exists(path))  //
       << "No such path: " << VV(path) << VV(runfiles_dir) << VV(rel_path);
   return path;
diff --git a/common/test_util.h b/common/test_util.h
index c859bff..e363f18 100644
--- a/common/test_util.h
+++ b/common/test_util.h
@@ -79,7 +79,7 @@
   const std::filesystem::path& path() const { return path_; }
 
   std::string GetFilePath(std::string_view file_name) const {
-    return path_ / file_name;
+    return (path_ / file_name).string();
   }
 
   std::string CreateSubdir(std::string_view name) const {