No public description

PiperOrigin-RevId: 954634386
diff --git a/centipede/BUILD b/centipede/BUILD
index 518d7a6..0513725 100644
--- a/centipede/BUILD
+++ b/centipede/BUILD
@@ -31,6 +31,11 @@
 
 licenses(["notice"])
 
+exports_files([
+    "crash_deduplication.h",
+    "crash_deduplication.cc",
+])
+
 ################################################################################
 #                                  Binaries
 ################################################################################
@@ -907,6 +912,8 @@
         "@abseil-cpp//absl/status:statusor",
         "@abseil-cpp//absl/strings",
         "@abseil-cpp//absl/time",
+        "@abseil-cpp//absl/time:clock_interface",
+        "@abseil-cpp//absl/types:span",
         "@com_google_fuzztest//common:crashing_input_filename",
         "@com_google_fuzztest//common:defs",
         "@com_google_fuzztest//common:hash",
@@ -917,6 +924,22 @@
 )
 
 cc_library(
+    name = "crash_deduplication_test_util",
+    testonly = 1,
+    srcs = ["crash_deduplication_test_util.cc"],
+    hdrs = ["crash_deduplication_test_util.h"],
+    deps = [
+        ":centipede_callbacks",
+        ":environment",
+        ":runner_result",
+        ":stop",
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/types:span",
+        "@com_google_fuzztest//common:defs",
+    ],
+)
+
+cc_library(
     name = "crash_summary",
     srcs = ["crash_summary.cc"],
     hdrs = ["crash_summary.h"],
@@ -1921,18 +1944,18 @@
     deps = [
         ":centipede_callbacks",
         ":crash_deduplication",
+        ":crash_deduplication_test_util",
         ":crash_summary",
         ":environment",
-        ":runner_result",
         ":stop",
         ":util",
         ":workdir",
         "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/status",
         "@abseil-cpp//absl/strings:str_format",
         "@abseil-cpp//absl/time",
-        "@abseil-cpp//absl/types:span",
-        "@com_google_fuzztest//common:defs",
-        "@com_google_fuzztest//common:hash",
+        "@abseil-cpp//absl/time:clock_interface",
+        "@abseil-cpp//absl/time:simulated_clock",
         "@com_google_fuzztest//common:temp_dir",
         "@googletest//:gtest_main",
     ],
diff --git a/centipede/centipede_interface.cc b/centipede/centipede_interface.cc
index ebc1932..489bd97 100644
--- a/centipede/centipede_interface.cc
+++ b/centipede/centipede_interface.cc
@@ -74,6 +74,8 @@
 
 namespace {
 
+constexpr absl::Duration kDefaultRegressionTtl = absl::Hours(24 * 7);
+
 // Runs env.for_each_blob on every blob extracted from env.args.
 // Returns EXIT_SUCCESS on success, EXIT_FAILURE otherwise.
 void ForEachBlob(const Environment& env, StopCondition& stop_condition) {
@@ -546,9 +548,13 @@
     crash_summary.Report(&std::cerr);
     return;
   }
-  OrganizeCrashingInputs(regression_dir, fuzztest_db_path / "crashing", env,
-                         callbacks_factory, crashes_by_signature, crash_summary,
-                         stop_condition);
+  const absl::Status status = OrganizeCrashingInputs(
+      regression_dir, fuzztest_db_path / "crashing",
+      fuzztest_db_path / "incubating", env, callbacks_factory,
+      crashes_by_signature, crash_summary, stop_condition,
+      kDefaultRegressionTtl);
+  FUZZTEST_LOG_IF(ERROR, !status.ok())
+      << "Failed to organize crashing inputs: " << status;
   if (env.report_crash_summary) crash_summary.Report(&std::cerr);
 
   // Distill and store the coverage corpus.
diff --git a/centipede/crash_deduplication.cc b/centipede/crash_deduplication.cc
index 268c13c..b5ae094 100644
--- a/centipede/crash_deduplication.cc
+++ b/centipede/crash_deduplication.cc
@@ -17,6 +17,7 @@
 #include <cstddef>
 #include <cstdlib>
 #include <filesystem>  // NOLINT
+#include <optional>
 #include <string>
 #include <string_view>
 #include <utility>
@@ -27,8 +28,11 @@
 #include "absl/status/status.h"
 #include "absl/status/statusor.h"
 #include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
 #include "absl/time/clock.h"
+#include "absl/time/clock_interface.h"
 #include "absl/time/time.h"
+#include "absl/types/span.h"
 #include "./centipede/centipede_callbacks.h"
 #include "./centipede/crash_summary.h"
 #include "./centipede/environment.h"
@@ -45,12 +49,409 @@
 namespace fuzztest::internal {
 namespace {
 
+constexpr size_t kMaxCrashInputCount = 10;
+
 std::string GetInputFileName(std::string_view bug_id,
                              std::string_view crash_signature,
                              std::string_view input_signature) {
   return absl::StrCat(bug_id, "-", crash_signature, "-", input_signature);
 }
 
+struct CrashReport {
+  CrashDetails details;
+  std::string signature;
+  std::string bug_id;
+};
+
+enum class ActionType {
+  kTouch,
+  kKeep,
+  // Replace the input but keep the bug and signature.
+  kReplaceInput,
+  // Incubate the input and then replace it.
+  kIncubateAndReplaceInput,
+  kDelete
+};
+
+struct ExistingCrash {
+  CrashReport crash_report;
+  std::string new_signature;
+};
+
+struct IncubatingCrash {
+  CrashDetails details;
+  std::string new_signature;
+};
+
+struct ExistingCrashAction {
+  const ExistingCrash& existing_crash;
+  ActionType action_type;
+  std::optional<CrashDetails> new_details;
+};
+
+absl::StatusOr<std::vector<ExistingCrash>> ReadExistingCrashes(
+    const std::filesystem::path& crashing_dir) {
+  std::vector<ExistingCrash> existing_crashes;
+  ASSIGN_OR_RETURN_IF_NOT_OK(
+      const std::vector<std::string> input_files,
+      RemoteListFiles(crashing_dir.c_str(), /*recursively=*/false));
+
+  existing_crashes.reserve(input_files.size());
+  for (const std::string& input_file : input_files) {
+    auto input_file_components = ParseCrashingInputFilename(input_file);
+    ExistingCrash existing;
+    existing.crash_report.details.input_path = input_file;
+
+    if (input_file_components.ok()) {
+      existing.crash_report.bug_id = input_file_components->bug_id;
+      existing.crash_report.signature = input_file_components->crash_signature;
+      existing.crash_report.details.input_signature =
+          input_file_components->input_signature;
+    } else {
+      existing.crash_report.signature = "";
+      existing.crash_report.bug_id = "";
+    }
+    existing_crashes.push_back(std::move(existing));
+  }
+  return existing_crashes;
+}
+
+absl::StatusOr<std::vector<IncubatingCrash>> ReadIncubatingCrashes(
+    const std::filesystem::path& incubating_dir) {
+  std::vector<IncubatingCrash> incubating_crashes;
+  ASSIGN_OR_RETURN_IF_NOT_OK(
+      const std::vector<std::string> input_files,
+      RemoteListFiles(incubating_dir.c_str(), /*recursively=*/false));
+
+  incubating_crashes.reserve(input_files.size());
+  for (const std::string& input_file : input_files) {
+    IncubatingCrash incubating;
+    incubating.details.input_path = input_file;
+    incubating.details.input_signature =
+        std::filesystem::path(input_file).filename().c_str();
+    incubating_crashes.push_back(std::move(incubating));
+  }
+  return incubating_crashes;
+}
+
+absl::Status ReplayCrash(CentipedeCallbacks& callbacks, const Environment& env,
+                         absl::string_view input_path,
+                         std::string& out_signature,
+                         std::string& out_description) {
+  ByteArray input_bytes;
+  RETURN_IF_NOT_OK(RemoteFileGetContents(input_path, input_bytes));
+
+  BatchResult batch_result;
+  const bool is_reproducible =
+      !callbacks.Execute(env.binary, {input_bytes}, batch_result) &&
+      batch_result.IsInputFailure();
+
+  if (is_reproducible) {
+    out_signature = batch_result.failure_signature();
+    out_description = batch_result.failure_description();
+  } else {
+    out_signature = "";
+    out_description = "";
+  }
+  return absl::OkStatus();
+}
+
+absl::Status ReplayExistingCrashes(
+    CentipedeCallbacks& callbacks, const Environment& env,
+    std::vector<ExistingCrash>& existing_crashes) {
+  for (auto& existing : existing_crashes) {
+    if (existing.crash_report.signature.empty()) {
+      continue;
+    }
+    RETURN_IF_NOT_OK(ReplayCrash(
+        callbacks, env, existing.crash_report.details.input_path,
+        existing.new_signature, existing.crash_report.details.description));
+  }
+  return absl::OkStatus();
+}
+
+absl::Status ReplayIncubatingCrashes(
+    CentipedeCallbacks& callbacks, const Environment& env,
+    std::vector<IncubatingCrash>& incubating_crashes) {
+  for (auto& incubating : incubating_crashes) {
+    RETURN_IF_NOT_OK(ReplayCrash(callbacks, env, incubating.details.input_path,
+                                 incubating.new_signature,
+                                 incubating.details.description));
+  }
+  return absl::OkStatus();
+}
+
+absl::flat_hash_map<std::string, CrashDetails> FindNewCrashes(
+    const absl::flat_hash_map<std::string, CrashDetails>&
+        new_crashes_by_signature,
+    absl::Span<const IncubatingCrash> incubating_crashes,
+    absl::Span<const ExistingCrash> existing_crashes) {
+  absl::flat_hash_map<std::string, CrashDetails> new_crashes;
+
+  // Add new crashes from the current run.
+  for (const auto& [signature, details] : new_crashes_by_signature) {
+    new_crashes[signature] = details;
+  }
+
+  // Add reproducing incubating crashes.
+  for (const auto& incubating : incubating_crashes) {
+    if (!incubating.new_signature.empty()) {
+      new_crashes[incubating.new_signature] = incubating.details;
+    }
+  }
+
+  // Add reproducing existing crashes (highest priority)
+  for (const auto& existing : existing_crashes) {
+    if (!existing.new_signature.empty()) {
+      new_crashes[existing.new_signature] = existing.crash_report.details;
+    }
+  }
+
+  return new_crashes;
+}
+
+ExistingCrashAction ComputeExistingCrashAction(
+    const ExistingCrash& existing,
+    const absl::flat_hash_map<std::string, CrashDetails>& new_crashes) {
+  const std::string& sig = existing.crash_report.signature;
+  const std::string& sig_new = existing.new_signature;
+
+  // If the crash was malformed (no signature), we delete it immediately.
+  if (sig.empty()) {
+    return {existing, ActionType::kDelete, std::nullopt};
+  }
+
+  if (sig == sig_new) {
+    return {existing, ActionType::kTouch, std::nullopt};
+  }
+
+  // The signature changed or it no longer reproduces.
+  auto it = new_crashes.find(sig);
+  if (it == new_crashes.end()) {
+    // No input reproduces this signature anymore. Keep it on disk (subject to
+    // TTL).
+    return {existing, ActionType::kKeep, std::nullopt};
+  }
+
+  // We have an input for this crash signature.
+  if (sig_new.empty()) {
+    if (it->second.input_signature ==
+        existing.crash_report.details.input_signature) {
+      // The crashing input is the same which means that this input is flakey.
+      // Just touch it.
+      return {existing, ActionType::kTouch, it->second};
+    }
+    // The old crash did not reproduce at all. Move it to incubating and
+    // replace.
+    return {existing, ActionType::kIncubateAndReplaceInput, it->second};
+  }
+
+  // The old crash reproduced with a different signature. Replace it.
+  return {existing, ActionType::kReplaceInput, it->second};
+}
+
+std::vector<ExistingCrashAction> ComputeExistingCrashActions(
+    absl::Span<const ExistingCrash> existing_crashes,
+    const absl::flat_hash_map<std::string, CrashDetails>& new_crashes) {
+  std::vector<ExistingCrashAction> actions;
+  actions.reserve(existing_crashes.size());
+  for (const auto& existing : existing_crashes) {
+    actions.push_back(ComputeExistingCrashAction(existing, new_crashes));
+  }
+  return actions;
+}
+
+std::string GenerateBugId(std::string_view input_signature) {
+  return Hash(absl::StrCat(absl::FormatTime(absl::Now()), input_signature));
+}
+
+absl::Status WriteCrashToFile(const std::filesystem::path& crashing_dir,
+                              std::string_view bug_id,
+                              std::string_view crash_signature,
+                              const CrashDetails& details,
+                              CrashSummary& crash_summary) {
+  std::string new_input_file_name =
+      GetInputFileName(bug_id, crash_signature, details.input_signature);
+  std::filesystem::path new_input_path = crashing_dir / new_input_file_name;
+
+  if (details.input_path != new_input_path.c_str()) {
+    RETURN_IF_NOT_OK(
+        RemoteFileCopy(details.input_path, new_input_path.c_str()));
+  }
+
+  crash_summary.AddCrash({/*id=*/new_input_file_name,
+                          /*category=*/details.description,
+                          std::string(crash_signature), details.description});
+  return absl::OkStatus();
+}
+
+absl::flat_hash_map<std::string, CrashReport> GenerateNewCrashReports(
+    const absl::flat_hash_map<std::string, CrashDetails>& new_crashes,
+    absl::Span<const ExistingCrash> existing_crashes) {
+  absl::flat_hash_set<std::string> existing_sigs;
+  for (const auto& existing : existing_crashes) {
+    if (!existing.crash_report.signature.empty()) {
+      existing_sigs.insert(existing.crash_report.signature);
+    }
+  }
+
+  absl::flat_hash_map<std::string, CrashReport> new_crash_reports;
+  for (const auto& [signature, details] : new_crashes) {
+    if (existing_sigs.contains(signature)) {
+      continue;
+    }
+    std::string bug_id = GenerateBugId(details.input_signature);
+    new_crash_reports[signature] = CrashReport{details, signature, bug_id};
+  }
+  return new_crash_reports;
+}
+
+absl::Status TouchCrash(const ExistingCrash& existing,
+                        const std::optional<CrashDetails>& new_details,
+                        CrashSummary& crash_summary) {
+  RETURN_IF_NOT_OK(
+      RemotePathTouchExistingFile(existing.crash_report.details.input_path));
+
+  const std::string& description =
+      existing.crash_report.details.description.empty() &&
+              new_details.has_value()
+          ? new_details->description
+          : existing.crash_report.details.description;
+
+  crash_summary.AddCrash({
+      /*id=*/std::filesystem::path(existing.crash_report.details.input_path)
+          .filename()
+          .c_str(),
+      /*category=*/description,
+      existing.crash_report.signature,
+      description,
+  });
+  return absl::OkStatus();
+}
+
+absl::Status ExecuteExistingCrashPreservationActions(
+    const std::filesystem::path& crashing_dir,
+    absl::Span<const ExistingCrashAction> actions,
+    CrashSummary& crash_summary) {
+  for (const auto& action : actions) {
+    const auto& existing = action.existing_crash;
+
+    switch (action.action_type) {
+      case ActionType::kTouch:
+        RETURN_IF_NOT_OK(
+            TouchCrash(existing, action.new_details, crash_summary));
+        break;
+
+      case ActionType::kReplaceInput:
+      case ActionType::kIncubateAndReplaceInput:
+        RETURN_IF_NOT_OK(WriteCrashToFile(crashing_dir,
+                                          existing.crash_report.bug_id,
+                                          existing.crash_report.signature,
+                                          *action.new_details, crash_summary));
+        break;
+
+      case ActionType::kKeep:
+      case ActionType::kDelete:
+        break;
+    }
+  }
+  return absl::OkStatus();
+}
+
+absl::Status ExecuteExistingCrashDestructiveActions(
+    const std::filesystem::path& incubating_dir,
+    absl::Span<const ExistingCrashAction> actions) {
+  for (const auto& action : actions) {
+    const auto& existing = action.existing_crash;
+
+    switch (action.action_type) {
+      case ActionType::kDelete:
+      case ActionType::kReplaceInput:
+        RETURN_IF_NOT_OK(RemotePathDelete(
+            existing.crash_report.details.input_path, /*recursively=*/false));
+        break;
+
+      case ActionType::kIncubateAndReplaceInput: {
+        std::filesystem::path dest_path =
+            incubating_dir / existing.crash_report.details.input_signature;
+        RETURN_IF_NOT_OK(RemoteFileRename(
+            existing.crash_report.details.input_path, dest_path.c_str()));
+        break;
+      }
+
+      case ActionType::kKeep:
+      case ActionType::kTouch:
+        break;
+    }
+  }
+  return absl::OkStatus();
+}
+
+absl::Status WriteNewCrashes(
+    const std::filesystem::path& crashing_dir,
+    const absl::flat_hash_map<std::string, CrashReport>& new_crash_reports,
+    size_t num_new_allowed, CrashSummary& crash_summary) {
+  size_t new_signatures_written = 0;
+  for (const auto& [signature, report] : new_crash_reports) {
+    if (new_signatures_written < num_new_allowed) {
+      RETURN_IF_NOT_OK(WriteCrashToFile(crashing_dir, report.bug_id,
+                                        report.signature, report.details,
+                                        crash_summary));
+      ++new_signatures_written;
+    } else {
+      FUZZTEST_LOG(WARNING)
+          << "Reached the maximum number of crash inputs: "
+          << kMaxCrashInputCount
+          << ". Not storing new crash with signature: " << signature;
+    }
+  }
+  return absl::OkStatus();
+}
+
+absl::Status CleanUpIncubating(
+    const std::filesystem::path& incubating_dir,
+    absl::Span<const IncubatingCrash> incubating_crashes) {
+  for (const auto& incubating : incubating_crashes) {
+    if (!incubating.new_signature.empty()) {
+      RETURN_IF_NOT_OK(RemotePathDelete(incubating.details.input_path,
+                                        /*recursively=*/false));
+    }
+  }
+  return absl::OkStatus();
+}
+
+absl::Status MoveExpiredCrashesToRegression(
+    const std::filesystem::path& source_dir,
+    const std::filesystem::path& regression_dir, absl::Duration ttl,
+    absl::Clock& clock) {
+  if (!RemotePathExists(source_dir.c_str())) {
+    return absl::OkStatus();
+  }
+  ASSIGN_OR_RETURN_IF_NOT_OK(
+      const std::vector<std::string> active_crash_files,
+      RemoteListFiles(source_dir.c_str(), /*recursively=*/false));
+
+  absl::Time now = clock.TimeNow();
+
+  for (const std::string& file_path : active_crash_files) {
+    ASSIGN_OR_RETURN_IF_NOT_OK(absl::Time mtime, RemoteFileGetMTime(file_path));
+
+    if (now - mtime > ttl) {
+      auto input_file_components = ParseCrashingInputFilename(file_path);
+      std::string dest_filename;
+      if (input_file_components.ok()) {
+        dest_filename = input_file_components->input_signature;
+      } else {
+        dest_filename = std::filesystem::path(file_path).filename().c_str();
+      }
+
+      std::filesystem::path dest_path = regression_dir / dest_filename;
+      RETURN_IF_NOT_OK(RemoteFileRename(file_path, dest_path.c_str()));
+    }
+  }
+  return absl::OkStatus();
+}
+
 }  // namespace
 
 absl::flat_hash_map<std::string, CrashDetails> GetCrashesFromWorkdir(
@@ -127,195 +528,61 @@
   return crashes;
 }
 
-void OrganizeCrashingInputs(
+absl::Status OrganizeCrashingInputs(
     const std::filesystem::path& regression_dir,
-    const std::filesystem::path& crashing_dir, const Environment& env,
+    const std::filesystem::path& crashing_dir,
+    const std::filesystem::path& incubating_dir, const Environment& env,
     CentipedeCallbacksFactory& callbacks_factory,
     const absl::flat_hash_map<std::string, CrashDetails>&
         new_crashes_by_signature,
-    CrashSummary& crash_summary, StopCondition& stop_condition) {
-  FUZZTEST_CHECK_OK(RemoteMkdir(crashing_dir.c_str()));
-  FUZZTEST_CHECK_OK(RemoteMkdir(regression_dir.c_str()));
+    CrashSummary& crash_summary, StopCondition& stop_condition,
+    absl::Duration regression_ttl, absl::Clock& clock) {
+  RETURN_IF_NOT_OK(RemoteMkdir(crashing_dir.c_str()));
+  RETURN_IF_NOT_OK(RemoteMkdir(regression_dir.c_str()));
+  RETURN_IF_NOT_OK(RemoteMkdir(incubating_dir.c_str()));
 
-  // The corpus database layout assumes the crash input files are located
-  // directly in the crashing directory, so we don't list recursively.
-  std::vector<std::string> old_input_files =
-      ValueOrDie(RemoteListFiles(crashing_dir.c_str(), /*recursively=*/false));
-  size_t crash_input_count = old_input_files.size();
+  ASSIGN_OR_RETURN_IF_NOT_OK(auto existing_crashes,
+                             ReadExistingCrashes(crashing_dir));
+  ASSIGN_OR_RETURN_IF_NOT_OK(auto incubating_crashes,
+                             ReadIncubatingCrashes(incubating_dir));
+
   ScopedCentipedeCallbacks scoped_callbacks(callbacks_factory, env,
                                             stop_condition);
-  BatchResult batch_result;
+  RETURN_IF_NOT_OK(ReplayExistingCrashes(*scoped_callbacks.callbacks(), env,
+                                         existing_crashes));
+  RETURN_IF_NOT_OK(ReplayIncubatingCrashes(*scoped_callbacks.callbacks(), env,
+                                           incubating_crashes));
 
-  absl::flat_hash_map<std::string, CrashDetails> reproduced_crashes;
-  for (const std::string& old_input_file : old_input_files) {
-    ByteArray old_input;
-    FUZZTEST_CHECK_OK(RemoteFileGetContents(old_input_file, old_input));
-    const bool is_reproducible = !scoped_callbacks.callbacks()->Execute(
-                                     env.binary, {old_input}, batch_result) &&
-                                 batch_result.IsInputFailure();
-    auto input_file_components = ParseCrashingInputFilename(old_input_file);
-    FUZZTEST_LOG_IF(WARNING, !input_file_components.ok())
-        << "Failed to get input file components for " << old_input_file
-        << ". Status: " << input_file_components.status();
+  absl::flat_hash_map<std::string, CrashDetails> new_crashes = FindNewCrashes(
+      new_crashes_by_signature, incubating_crashes, existing_crashes);
 
-    if (is_reproducible) {
-      if (input_file_components.ok()) {
-        // Overwrite the old crash signature with the new one.
-        input_file_components->crash_signature =
-            batch_result.failure_signature();
-      } else {
-        // We'll rename the input file to the new format using the input
-        // signature as the bug ID.
-        const std::string input_signature = Hash(old_input);
-        input_file_components = InputFileComponents{
-            /*bug_id=*/input_signature,
-            /*crash_signature=*/batch_result.failure_signature(),
-            /*input_signature=*/input_signature,
-        };
-      }
+  std::vector<ExistingCrashAction> actions =
+      ComputeExistingCrashActions(existing_crashes, new_crashes);
 
-      std::string new_input_file_name = GetInputFileName(
-          input_file_components->bug_id, input_file_components->crash_signature,
-          input_file_components->input_signature);
-      std::string new_input_file = crashing_dir / new_input_file_name;
-      if (old_input_file == new_input_file) {
-        const auto status = RemotePathTouchExistingFile(new_input_file);
-        FUZZTEST_LOG_IF(ERROR, !status.ok())
-            << "Failed to touch file " << new_input_file
-            << ". Status: " << status;
-      } else {
-        const auto status = RemoteFileRename(old_input_file, new_input_file);
-        if (!status.ok()) {
-          FUZZTEST_LOG(ERROR)
-              << "Failed to rename file " << old_input_file << " to "
-              << new_input_file << ". Status: " << status;
-          new_input_file_name =
-              std::filesystem::path(old_input_file).filename();
-          new_input_file = old_input_file;
-        }
-      }
-      // In crash reports we report the full file name as the crash ID. This is
-      // what the user can use to replay or export the crash.
-      crash_summary.AddCrash({/*id=*/new_input_file_name,
-                              /*category=*/batch_result.failure_description(),
-                              batch_result.failure_signature(),
-                              batch_result.failure_description()});
-      reproduced_crashes.try_emplace(
-          batch_result.failure_signature(),
-          CrashDetails{
-              /*input_signature=*/input_file_components->input_signature,
-              /*description=*/batch_result.failure_description(),
-              /*input_path=*/new_input_file,
-          });
-      continue;
-    }
-    FUZZTEST_CHECK(!is_reproducible);
+  absl::flat_hash_map<std::string, CrashReport> new_crash_reports =
+      GenerateNewCrashReports(new_crashes, existing_crashes);
 
-    if (!input_file_components.ok()) {
-      // Irreproducible, no bug ID, and no crash signature. Nothing to do with
-      // this input but move it to the regression directory.
-      const std::string regression_input_file =
-          regression_dir / Hash(old_input);
-      const auto status =
-          RemoteFileRename(old_input_file, regression_input_file);
-      if (status.ok()) {
-        --crash_input_count;
-      } else {
-        FUZZTEST_LOG(ERROR)
-            << "Failed to rename file " << old_input_file << " to "
-            << regression_input_file << ". Status: " << status;
-      }
-      continue;
-    }
+  RETURN_IF_NOT_OK(ExecuteExistingCrashPreservationActions(
+      crashing_dir, actions, crash_summary));
 
-    auto crash_it =
-        reproduced_crashes.find(input_file_components->crash_signature);
-    auto new_crash_it = crash_it == reproduced_crashes.end()
-                            ? new_crashes_by_signature.find(
-                                  input_file_components->crash_signature)
-                            : new_crashes_by_signature.end();
-    if (crash_it != reproduced_crashes.end() ||
-        new_crash_it == new_crashes_by_signature.end()) {
-      const std::string regression_input_file =
-          regression_dir / input_file_components->input_signature;
-      const auto status = RemoteFileCopy(old_input_file, regression_input_file);
-      FUZZTEST_LOG_IF(ERROR, !status.ok())
-          << "Failed to copy file " << old_input_file << " to "
-          << regression_input_file << ". Status: " << status;
-      continue;
-    }
-    crash_it = reproduced_crashes.insert(*new_crash_it).first;
+  size_t num_new_allowed = kMaxCrashInputCount > existing_crashes.size()
+                               ? kMaxCrashInputCount - existing_crashes.size()
+                               : 0;
 
-    const std::string new_input_file_name = GetInputFileName(
-        input_file_components->bug_id, input_file_components->crash_signature,
-        crash_it->second.input_signature);
-    const std::string new_input_file = crashing_dir / new_input_file_name;
-    absl::Status replace_status;
-    if (new_input_file == old_input_file) {
-      // For some reason, the old input couldn't reproduce the crash during
-      // reproduction, but it was re-discovered during fuzzing, so it is
-      // flaky. We keep the input and don't store it as a regression.
-      replace_status = RemotePathTouchExistingFile(new_input_file);
-      FUZZTEST_LOG_IF(ERROR, !replace_status.ok())
-          << "Failed to touch file " << new_input_file
-          << ". Status: " << replace_status;
-    } else {
-      const std::string regression_input_file =
-          regression_dir / input_file_components->input_signature;
-      replace_status = RemoteFileRename(old_input_file, regression_input_file);
-      if (replace_status.ok()) {
-        --crash_input_count;
-        replace_status =
-            RemoteFileCopy(crash_it->second.input_path, new_input_file);
-        if (replace_status.ok()) {
-          ++crash_input_count;
-        } else {
-          FUZZTEST_LOG(ERROR)
-              << "Failed to copy file " << crash_it->second.input_path << " to "
-              << new_input_file << ". Status: " << replace_status;
-        }
-      } else {
-        FUZZTEST_LOG(ERROR)
-            << "Failed to rename file " << old_input_file << " to "
-            << regression_input_file << ". Status: " << replace_status;
-      }
-    }
-    if (replace_status.ok()) {
-      crash_summary.AddCrash({/*id=*/new_input_file_name,
-                              /*category=*/crash_it->second.description,
-                              input_file_components->crash_signature,
-                              crash_it->second.description});
-    } else {
-      reproduced_crashes.erase(crash_it);
-    }
-  }
+  RETURN_IF_NOT_OK(WriteNewCrashes(crashing_dir, new_crash_reports,
+                                   num_new_allowed, crash_summary));
 
-  static constexpr int kMaxCrashInputCount = 10;
-  for (auto& [crash_signature, details] : new_crashes_by_signature) {
-    if (reproduced_crashes.contains(crash_signature)) continue;
-    if (crash_input_count >= kMaxCrashInputCount) {
-      FUZZTEST_LOG(WARNING)
-          << "Reached the maximum number of crash inputs: "
-          << kMaxCrashInputCount << ". Not storing any new crashes.";
-      break;
-    }
-    const std::string bug_id = Hash(
-        absl::StrCat(absl::FormatTime(absl::Now()), details.input_signature));
-    const std::string new_input_file_name =
-        GetInputFileName(bug_id, crash_signature, details.input_signature);
-    const std::string new_input_file = crashing_dir / new_input_file_name;
-    const auto status = RemoteFileCopy(details.input_path, new_input_file);
-    if (!status.ok()) {
-      FUZZTEST_LOG(ERROR) << "Failed to copy file " << details.input_path
-                          << " to " << new_input_file << ". Status: " << status;
-      continue;
-    }
-    crash_summary.AddCrash({/*id=*/new_input_file_name,
-                            /*category=*/details.description, crash_signature,
-                            details.description});
-    reproduced_crashes.insert({std::move(crash_signature), std::move(details)});
-    ++crash_input_count;
-  }
+  RETURN_IF_NOT_OK(
+      ExecuteExistingCrashDestructiveActions(incubating_dir, actions));
+
+  RETURN_IF_NOT_OK(CleanUpIncubating(incubating_dir, incubating_crashes));
+
+  RETURN_IF_NOT_OK(MoveExpiredCrashesToRegression(crashing_dir, regression_dir,
+                                                  regression_ttl, clock));
+  RETURN_IF_NOT_OK(MoveExpiredCrashesToRegression(
+      incubating_dir, regression_dir, regression_ttl, clock));
+
+  return absl::OkStatus();
 }
 
 }  // namespace fuzztest::internal
diff --git a/centipede/crash_deduplication.h b/centipede/crash_deduplication.h
index 936d12a..0e10995 100644
--- a/centipede/crash_deduplication.h
+++ b/centipede/crash_deduplication.h
@@ -20,9 +20,12 @@
 #include <string>
 
 #include "absl/container/flat_hash_map.h"
+#include "absl/time/clock_interface.h"
+#include "absl/time/time.h"
 #include "./centipede/centipede_callbacks.h"
 #include "./centipede/crash_summary.h"
 #include "./centipede/environment.h"
+#include "./centipede/stop.h"
 #include "./centipede/workdir.h"
 
 namespace fuzztest::internal {
@@ -42,50 +45,50 @@
 // them, and stores new crashes from `new_crashes_by_signature` that are not
 // duplicates of existing ones.
 //
-// The input files in `crashing_dir` are uniquely identified by `bug_id`s. The
-// file names are in the format `<bug_id>-<crash_signature>-<input_signature>`
-// or `<input_signature>` (legacy format, in which case `<input_signature>` is
-// also considered to be the `bug_id`, and the crash signature is considered to
-// be missing).
+// The inputs are stored in three directories:
+// crashing/: stores crashing inputs in files of the form
+//   <bug_id>-<crash.signature>-<input.signature>.
+// incubating/: stores inputs that have not crashed recently. Files are of the
+// form
+//   <input.signature>
+// regression/: stores inputs that have not crashed for the specified ttl. Files
+// are of the form
+//   <input.signature>
 //
-// 1. Inputs from `crashing_dir` are re-executed:
-//   - If an input is reproducible and causes an input failure (i.e., not a
-//     setup failure or other special cases):
-//     - It is kept in `crashing_dir` and reported to `crash_summary`.
-//     - If its crash signature has changed, it is renamed to reflect the new
-//       signature: `<bug_id>-<new_crash_signature>-<input_signature>`.
-//     - The file's modification time is updated.
-//   - If an input is not reproducible or doesn't cause an input failure:
-//     - If its parsed crash signature is found in `new_crashes_by_signature`,
-//       this signature hasn't been seen in a reproducible crash yet, and no
-//       other input has been processed for this signature in the current call:
-//       - The `bug_id` is reused for bug continuity: If the input from
-//         `new_crashes_by_signature` has the same input signature as the
-//         irreproducible input (flaky crash), the file is kept in
-//         `crashing_dir` and its modification time is updated. Otherwise, the
-//         irreproducible input is moved to `regression_dir`, and the input
-//         from `new_crashes_by_signature` is copied to `crashing_dir` using
-//         file name `<bug_id>-<crash_signature>-<new_input_signature>`.
-//       - The crash is reported to `crash_summary`.
-//     - Otherwise, if the input file has a valid name, it is copied to
-//       `regression_dir`.
-//     - If input file has an invalid name, it is moved to `regression_dir`.
+// Input organization works as follows:
 //
-// 2. New crashes from `new_crashes_by_signature` are stored:
-//   - If a new crash has a signature that was not observed among crashes
-//     from step 1, it is stored in `crashing_dir` with a newly generated
-//     `bug_id`: `<new_bug_id>-<crash_signature>-<input_signature>`, and
-//     reported to `crash_summary`.
-//   - If the total number of inputs in `crashing_dir` reaches a predefined
-//     limit, no more new crashes will be stored (unless they replace old
-//     irreproducible inputs as described in step 1).
-void OrganizeCrashingInputs(
+// 1) Replay: Replay the inputs in crashing/ and incubating/. Merge with
+// new_crashes_by_signature to obtain `active_crashes` - a mapping of
+// crash signatures to crashing inputs.
+//
+// 2) Update crashing/: For each crash in crashing/:
+//   > if crash.input reproduces with crash.signature: touch the file
+//   > if crash.input crashes with a different signature and crash.signature is
+//   in active_crashes: replace the file with
+//   <crash.bug>-<crash.signature>-<active_input.signature> where active_input =
+//   active_crashes[crash.signature].
+//   > if crash.input does not crash at all and crash.signature is in
+//   active_crashes: replace the file with
+//   <crash.bug>-<crash.signature>-<active_input.signature> where active_input =
+//   active_crashes[crash.signature] and move crash.input to
+//   incubating/<crash.input.signature>
+//
+// 3) Record new crashes: Find all crash signatures in active_crashes that do
+// not have an entry in crashing/. For each such crash_signature, generate a
+// new_bug_id and add crashing/<new_bug_id>-<crash.signature>-<input.signature>
+// where input = active_crashes[crash_signature].
+//
+// 4) Regression: Move expired files in crashing/ and incubating/ to regression/
+absl::Status OrganizeCrashingInputs(
     const std::filesystem::path& regression_dir,
-    const std::filesystem::path& crashing_dir, const Environment& env,
+    const std::filesystem::path& crashing_dir,
+    const std::filesystem::path& incubating_dir, const Environment& env,
     CentipedeCallbacksFactory& callbacks_factory,
     const absl::flat_hash_map<std::string, CrashDetails>&
         new_crashes_by_signature,
-    CrashSummary& crash_summary, StopCondition& stop_condition);
+    CrashSummary& crash_summary, StopCondition& stop_condition,
+    absl::Duration regression_ttl,
+    absl::Clock& clock = absl::Clock::GetRealClock());
 
 }  // namespace fuzztest::internal
 
diff --git a/centipede/crash_deduplication_test.cc b/centipede/crash_deduplication_test.cc
index cad5d3f..12dd2f5 100644
--- a/centipede/crash_deduplication_test.cc
+++ b/centipede/crash_deduplication_test.cc
@@ -14,7 +14,6 @@
 
 #include "./centipede/crash_deduplication.h"
 
-#include <cstdlib>
 #include <filesystem>  // NOLINT
 #include <string>
 #include <string_view>
@@ -24,19 +23,19 @@
 #include "gmock/gmock.h"
 #include "gtest/gtest.h"
 #include "absl/container/flat_hash_map.h"
+#include "absl/status/status.h"
 #include "absl/strings/str_format.h"
 #include "absl/time/clock.h"
+#include "absl/time/clock_interface.h"
+#include "absl/time/simulated_clock.h"
 #include "absl/time/time.h"
-#include "absl/types/span.h"
 #include "./centipede/centipede_callbacks.h"
+#include "./centipede/crash_deduplication_test_util.h"
 #include "./centipede/crash_summary.h"
 #include "./centipede/environment.h"
-#include "./centipede/runner_result.h"
 #include "./centipede/stop.h"
 #include "./centipede/util.h"
 #include "./centipede/workdir.h"
-#include "./common/defs.h"
-#include "./common/hash.h"
 #include "./common/temp_dir.h"
 
 namespace fuzztest::internal {
@@ -44,10 +43,12 @@
 
 using ::testing::AllOf;
 using ::testing::AnyOf;
+using ::testing::ContainsRegex;
 using ::testing::EndsWith;
 using ::testing::FieldsAre;
 using ::testing::HasSubstr;
 using ::testing::IsEmpty;
+using ::testing::MatchesRegex;
 using ::testing::Pair;
 using ::testing::UnorderedElementsAre;
 
@@ -158,38 +159,6 @@
   unsetenv("FUZZTEST_FAIL_ON_EMPTY_CRASH_METADATA");
 }
 
-class FakeCentipedeCallbacks : public CentipedeCallbacks {
- public:
-  struct Crash {
-    std::string signature;
-    std::string description;
-  };
-
-  explicit FakeCentipedeCallbacks(
-      const Environment& env,
-      absl::flat_hash_map<std::string, Crash> crashing_inputs)
-      : CentipedeCallbacks(env, internal_stop_condition_),
-        crashing_inputs_(std::move(crashing_inputs)) {}
-
-  bool Execute(std::string_view binary, absl::Span<const ByteSpan> inputs,
-               BatchResult& batch_result) override {
-    batch_result.ClearAndResize(inputs.size());
-    for (ByteSpan input : inputs) {
-      auto it = crashing_inputs_.find(AsStringView(input));
-      if (it == crashing_inputs_.end()) continue;
-      batch_result.exit_code() = EXIT_FAILURE;
-      batch_result.failure_signature() = it->second.signature;
-      batch_result.failure_description() = it->second.description;
-      return false;
-    }
-    return true;
-  }
-
- private:
-  absl::flat_hash_map<std::string, Crash> crashing_inputs_;
-  StopCondition internal_stop_condition_;
-};
-
 struct FileAndContents {
   std::string basename;
   std::string contents;
@@ -216,9 +185,11 @@
   OrganizeCrashingInputsTest()
       : crashing_dir_(test_dir_.path() / "crashing"),
         regression_dir_(test_dir_.path() / "regression"),
+        incubating_dir_(test_dir_.path() / "incubating"),
         new_crashes_dir_(test_dir_.path() / "new_crashes") {
     std::filesystem::create_directories(crashing_dir_);
     std::filesystem::create_directories(regression_dir_);
+    std::filesystem::create_directories(incubating_dir_);
     std::filesystem::create_directories(new_crashes_dir_);
   }
 
@@ -226,17 +197,35 @@
   const std::filesystem::path& regression_dir() const {
     return regression_dir_;
   }
+  const std::filesystem::path& incubating_dir() const {
+    return incubating_dir_;
+  }
   const std::filesystem::path& new_crashes_dir() const {
     return new_crashes_dir_;
   }
   const Environment& env() const { return env_; }
   CrashSummary& crash_summary() { return crash_summary_; }
-  StopCondition& stop_condition() { return stop_condition_; }
+
+  absl::Status OrganizeCrashingInputs(
+      const std::filesystem::path& regression_dir,
+      const std::filesystem::path& crashing_dir, const Environment& env,
+      CentipedeCallbacksFactory& callbacks_factory,
+      const absl::flat_hash_map<std::string, CrashDetails>&
+          new_crashes_by_signature,
+      CrashSummary& crash_summary,
+      absl::Duration regression_ttl = absl::Hours(24 * 7),
+      absl::Clock& clock = absl::Clock::GetRealClock()) {
+    return ::fuzztest::internal::OrganizeCrashingInputs(
+        regression_dir, crashing_dir, incubating_dir_, env, callbacks_factory,
+        new_crashes_by_signature, crash_summary, stop_condition_,
+        regression_ttl, clock);
+  }
 
  private:
   TempDir test_dir_;
   std::filesystem::path crashing_dir_;
   std::filesystem::path regression_dir_;
+  std::filesystem::path incubating_dir_;
   std::filesystem::path new_crashes_dir_;
   Environment env_;
   CrashSummary crash_summary_{"binary_id", "fuzz_test"};
@@ -250,9 +239,10 @@
   FakeCentipedeCallbacks callbacks(env(), /*crashing_inputs=*/{});
   NonOwningCallbacksFactory factory(callbacks);
 
-  OrganizeCrashingInputs(regression_dir, crashing_dir, env(), factory,
-                         /*new_crashes_by_signature=*/{}, crash_summary(),
-                         stop_condition());
+  ASSERT_TRUE(OrganizeCrashingInputs(regression_dir, crashing_dir, env(),
+                                     factory, /*new_crashes_by_signature=*/{},
+                                     crash_summary())
+                  .ok());
 
   const std::filesystem::directory_entry crashing_dir_entry{crashing_dir};
   const std::filesystem::directory_entry regression_dir_entry{regression_dir};
@@ -261,27 +251,23 @@
       regression_dir_entry.exists() && regression_dir_entry.is_directory());
 }
 
-TEST_F(OrganizeCrashingInputsTest, RenamesOldStyleCrashFileToNewStyle) {
+TEST_F(OrganizeCrashingInputsTest, DeletesOldStyleCrashes) {
   SetContentsAndGetPath(crashing_dir(), "isig", "input");
   FakeCentipedeCallbacks callbacks(env(), /*crashing_inputs=*/{
                                        {"input", {"csig", "desc"}},
                                    });
   NonOwningCallbacksFactory factory(callbacks);
 
-  OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(), factory,
-                         /*new_crashes_by_signature=*/{}, crash_summary(),
-                         stop_condition());
+  ASSERT_TRUE(OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(),
+                                     factory, /*new_crashes_by_signature=*/{},
+                                     crash_summary())
+                  .ok());
   std::string crash_report;
   crash_summary().Report(&crash_report);
 
-  EXPECT_THAT(ReadFiles(crashing_dir()),
-              UnorderedElementsAre(FieldsAre("isig-csig-isig", "input")));
+  EXPECT_THAT(ReadFiles(crashing_dir()), IsEmpty());
   EXPECT_THAT(ReadFiles(regression_dir()), IsEmpty());
-  EXPECT_THAT(crash_report, AllOf(HasSubstr("Total crashes: 1"),
-                                  HasSubstr("Crash ID   : isig-csig-isig"),
-                                  HasSubstr("Category   : desc"),
-                                  HasSubstr("Signature  : csig"),
-                                  HasSubstr("Description: desc")));
+  EXPECT_THAT(crash_report, HasSubstr("Total crashes: 0"));
 }
 
 TEST_F(OrganizeCrashingInputsTest, KeepsNewStyleCrashFileIfSignatureUnchanged) {
@@ -291,15 +277,17 @@
                                    });
   NonOwningCallbacksFactory factory(callbacks);
 
-  OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(), factory,
-                         /*new_crashes_by_signature=*/{}, crash_summary(),
-                         stop_condition());
+  ASSERT_TRUE(OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(),
+                                     factory, /*new_crashes_by_signature=*/{},
+                                     crash_summary())
+                  .ok());
   std::string crash_report;
   crash_summary().Report(&crash_report);
 
   EXPECT_THAT(ReadFiles(crashing_dir()),
               UnorderedElementsAre(FieldsAre("bug-csig-isig", "input")));
   EXPECT_THAT(ReadFiles(regression_dir()), IsEmpty());
+  EXPECT_THAT(ReadFiles(incubating_dir()), IsEmpty());
   EXPECT_THAT(crash_report, AllOf(HasSubstr("Total crashes: 1"),
                                   HasSubstr("Crash ID   : bug-csig-isig"),
                                   HasSubstr("Category   : desc"),
@@ -307,27 +295,32 @@
                                   HasSubstr("Description: desc")));
 }
 
-TEST_F(OrganizeCrashingInputsTest, UpdatesCrashSignatureInFileNameIfChanged) {
+TEST_F(OrganizeCrashingInputsTest, AddsNewCrashIfCrashSignatureChanges) {
   SetContentsAndGetPath(crashing_dir(), "bug-csig_old-isig", "input");
   FakeCentipedeCallbacks callbacks(env(), /*crashing_inputs=*/{
                                        {"input", {"csig_new", "desc"}},
                                    });
   NonOwningCallbacksFactory factory(callbacks);
 
-  OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(), factory,
-                         /*new_crashes_by_signature=*/{}, crash_summary(),
-                         stop_condition());
+  ASSERT_TRUE(OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(),
+                                     factory, /*new_crashes_by_signature=*/{},
+                                     crash_summary())
+                  .ok());
   std::string crash_report;
   crash_summary().Report(&crash_report);
 
   EXPECT_THAT(ReadFiles(crashing_dir()),
-              UnorderedElementsAre(FieldsAre("bug-csig_new-isig", "input")));
+              UnorderedElementsAre(
+                  FieldsAre("bug-csig_old-isig", "input"),
+                  FieldsAre(MatchesRegex("[a-f0-9]+-csig_new-isig"), "input")));
   EXPECT_THAT(ReadFiles(regression_dir()), IsEmpty());
-  EXPECT_THAT(crash_report, AllOf(HasSubstr("Total crashes: 1"),
-                                  HasSubstr("Crash ID   : bug-csig_new-isig"),
-                                  HasSubstr("Category   : desc"),
-                                  HasSubstr("Signature  : csig_new"),
-                                  HasSubstr("Description: desc")));
+  EXPECT_THAT(ReadFiles(incubating_dir()), IsEmpty());
+  EXPECT_THAT(
+      crash_report,
+      AllOf(HasSubstr("Total crashes: 1"),
+            ContainsRegex("Crash ID   : [a-f0-9]+-csig_new-isig"),
+            HasSubstr("Category   : desc"), HasSubstr("Signature  : csig_new"),
+            HasSubstr("Description: desc")));
 }
 
 TEST_F(OrganizeCrashingInputsTest,
@@ -348,9 +341,10 @@
                                    });
   NonOwningCallbacksFactory factory(callbacks);
 
-  OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(), factory,
-                         /*new_crashes_by_signature=*/{}, crash_summary(),
-                         stop_condition());
+  ASSERT_TRUE(OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(),
+                                     factory, /*new_crashes_by_signature=*/{},
+                                     crash_summary())
+                  .ok());
 
   EXPECT_GT(std::filesystem::last_write_time(reproducible_input_path),
             reproducible_mtime_before);
@@ -359,7 +353,7 @@
 }
 
 TEST_F(OrganizeCrashingInputsTest,
-       KeepsReproducibleCrashesWithSameCrashSignature) {
+       KeepsOldFilesWhenDeduplicatingToSameSignature) {
   SetContentsAndGetPath(crashing_dir(), "bug1-csig1-isig1", "input1");
   SetContentsAndGetPath(crashing_dir(), "bug2-csig2-isig2", "input2");
   FakeCentipedeCallbacks callbacks(env(), /*crashing_inputs=*/{
@@ -368,22 +362,28 @@
                                    });
   NonOwningCallbacksFactory factory(callbacks);
 
-  OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(), factory,
-                         /*new_crashes_by_signature=*/{}, crash_summary(),
-                         stop_condition());
+  ASSERT_TRUE(OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(),
+                                     factory, /*new_crashes_by_signature=*/{},
+                                     crash_summary())
+                  .ok());
   std::string crash_report;
   crash_summary().Report(&crash_report);
 
-  EXPECT_THAT(ReadFiles(crashing_dir()),
-              UnorderedElementsAre(FieldsAre("bug1-csig-isig1", "input1"),
-                                   FieldsAre("bug2-csig-isig2", "input2")));
+  EXPECT_THAT(
+      ReadFiles(crashing_dir()),
+      AnyOf(UnorderedElementsAre(
+                FieldsAre("bug1-csig1-isig1", "input1"),
+                FieldsAre("bug2-csig2-isig2", "input2"),
+                FieldsAre(MatchesRegex("[a-f0-9]+-csig-isig1"), "input1")),
+            UnorderedElementsAre(
+                FieldsAre("bug1-csig1-isig1", "input1"),
+                FieldsAre("bug2-csig2-isig2", "input2"),
+                FieldsAre(MatchesRegex("[a-f0-9]+-csig-isig2"), "input2"))));
   EXPECT_THAT(ReadFiles(regression_dir()), IsEmpty());
-  EXPECT_THAT(crash_report, AllOf(HasSubstr("Total crashes: 2"),
-                                  HasSubstr("Crash ID   : bug1-csig-isig1"),
-                                  HasSubstr("Crash ID   : bug2-csig-isig2"),
-                                  HasSubstr("Category   : desc"),
-                                  HasSubstr("Signature  : csig"),
-                                  HasSubstr("Description: desc")));
+  EXPECT_THAT(
+      crash_report,
+      AllOf(HasSubstr("Total crashes: 1"), HasSubstr("Category   : desc"),
+            HasSubstr("Signature  : csig"), HasSubstr("Description: desc")));
 }
 
 TEST_F(OrganizeCrashingInputsTest, KeepsFlakyCrashAndUpdatesModificationTime) {
@@ -401,9 +401,10 @@
       SetContentsAndGetPath(new_crashes_dir(), "isig", "input");
   new_crashes_by_signature["csig"] = {"isig", "desc", new_input_path};
 
-  OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(), factory,
-                         new_crashes_by_signature, crash_summary(),
-                         stop_condition());
+  ASSERT_TRUE(OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(),
+                                     factory, new_crashes_by_signature,
+                                     crash_summary())
+                  .ok());
   std::string crash_report;
   crash_summary().Report(&crash_report);
 
@@ -418,27 +419,49 @@
   EXPECT_GT(std::filesystem::last_write_time(input_path), mtime_before);
 }
 
-TEST_F(OrganizeCrashingInputsTest,
-       KeepsIrreproducibleCrashAndCopiesToRegressionDir) {
+TEST_F(OrganizeCrashingInputsTest, KeepsIrreproducibleCrashIfTtlNotExpired) {
   SetContentsAndGetPath(crashing_dir(), "bug-csig-isig", "input");
   FakeCentipedeCallbacks callbacks(env(), /*crashing_inputs=*/{});
   NonOwningCallbacksFactory factory(callbacks);
 
-  OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(), factory,
-                         /*new_crashes_by_signature=*/{}, crash_summary(),
-                         stop_condition());
+  ASSERT_TRUE(OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(),
+                                     factory, /*new_crashes_by_signature=*/{},
+                                     crash_summary())
+                  .ok());
   std::string crash_report;
   crash_summary().Report(&crash_report);
 
   EXPECT_THAT(ReadFiles(crashing_dir()),
               UnorderedElementsAre(FieldsAre("bug-csig-isig", "input")));
+  EXPECT_THAT(ReadFiles(regression_dir()), IsEmpty());
+  EXPECT_THAT(ReadFiles(incubating_dir()), IsEmpty());
+  EXPECT_THAT(crash_report, HasSubstr("Total crashes: 0"));
+}
+
+TEST_F(OrganizeCrashingInputsTest,
+       MovesIrreproducibleCrashToRegressionIfTtlExpired) {
+  absl::SimulatedClock clock(absl::Now());
+  SetContentsAndGetPath(crashing_dir(), "bug-csig-isig", "input");
+  clock.AdvanceTime(absl::Hours(25));
+
+  FakeCentipedeCallbacks callbacks(env(), /*crashing_inputs=*/{});
+  NonOwningCallbacksFactory factory(callbacks);
+
+  ASSERT_TRUE(OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(),
+                                     factory, /*new_crashes_by_signature=*/{},
+                                     crash_summary(),
+                                     /*regression_ttl=*/absl::Hours(24), clock)
+                  .ok());
+  std::string crash_report;
+  crash_summary().Report(&crash_report);
+
+  EXPECT_THAT(ReadFiles(crashing_dir()), IsEmpty());
   EXPECT_THAT(ReadFiles(regression_dir()),
               UnorderedElementsAre(FieldsAre("isig", "input")));
   EXPECT_THAT(crash_report, HasSubstr("Total crashes: 0"));
 }
 
-TEST_F(OrganizeCrashingInputsTest,
-       KeepsSetupFailureCrashAndCopiesToRegressionDir) {
+TEST_F(OrganizeCrashingInputsTest, KeepsSetupFailureCrashIfTtlNotExpired) {
   SetContentsAndGetPath(crashing_dir(), "bug-csig-isig", "input");
   FakeCentipedeCallbacks callbacks(
       env(), /*crashing_inputs=*/{
@@ -446,34 +469,60 @@
       });
   NonOwningCallbacksFactory factory(callbacks);
 
-  OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(), factory,
-                         /*new_crashes_by_signature=*/{}, crash_summary(),
-                         stop_condition());
+  ASSERT_TRUE(OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(),
+                                     factory, /*new_crashes_by_signature=*/{},
+                                     crash_summary())
+                  .ok());
   std::string crash_report;
   crash_summary().Report(&crash_report);
 
   EXPECT_THAT(ReadFiles(crashing_dir()),
               UnorderedElementsAre(FieldsAre("bug-csig-isig", "input")));
+  EXPECT_THAT(ReadFiles(regression_dir()), IsEmpty());
+  EXPECT_THAT(crash_report, HasSubstr("Total crashes: 0"));
+}
+
+TEST_F(OrganizeCrashingInputsTest,
+       MovesSetupFailureCrashToRegressionIfTtlExpired) {
+  absl::SimulatedClock clock(absl::Now());
+  SetContentsAndGetPath(crashing_dir(), "bug-csig-isig", "input");
+  clock.AdvanceTime(absl::Hours(25));
+
+  FakeCentipedeCallbacks callbacks(
+      env(), /*crashing_inputs=*/{
+          {"input", {"csig", "SETUP FAILURE: desc"}},
+      });
+  NonOwningCallbacksFactory factory(callbacks);
+
+  ASSERT_TRUE(OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(),
+                                     factory, /*new_crashes_by_signature=*/{},
+                                     crash_summary(),
+                                     /*regression_ttl=*/absl::Hours(24), clock)
+                  .ok());
+  std::string crash_report;
+  crash_summary().Report(&crash_report);
+
+  EXPECT_THAT(ReadFiles(crashing_dir()), IsEmpty());
   EXPECT_THAT(ReadFiles(regression_dir()),
               UnorderedElementsAre(FieldsAre("isig", "input")));
   EXPECT_THAT(crash_report, HasSubstr("Total crashes: 0"));
 }
 
 TEST_F(OrganizeCrashingInputsTest,
-       MovesIrreproducibleCrashWithMalformedFileNameToRegressionDir) {
+       DeletesIrreproducibleCrashWithMalformedFileName) {
   SetContentsAndGetPath(crashing_dir(), "invalid-name", "input");
   FakeCentipedeCallbacks callbacks(env(), /*crashing_inputs=*/{});
   NonOwningCallbacksFactory factory(callbacks);
 
-  OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(), factory,
-                         /*new_crashes_by_signature=*/{}, crash_summary(),
-                         stop_condition());
+  ASSERT_TRUE(OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(),
+                                     factory, /*new_crashes_by_signature=*/{},
+                                     crash_summary())
+                  .ok());
   std::string crash_report;
   crash_summary().Report(&crash_report);
 
   EXPECT_THAT(ReadFiles(crashing_dir()), IsEmpty());
-  EXPECT_THAT(ReadFiles(regression_dir()),
-              UnorderedElementsAre(FieldsAre(Hash("input"), "input")));
+  EXPECT_THAT(ReadFiles(regression_dir()), IsEmpty());
   EXPECT_THAT(crash_report, HasSubstr("Total crashes: 0"));
 }
 
@@ -488,15 +537,17 @@
       SetContentsAndGetPath(new_crashes_dir(), "isig2", "input2");
   new_crashes_by_signature["csig"] = {"isig2", "desc2", input2_path};
 
-  OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(), factory,
-                         new_crashes_by_signature, crash_summary(),
-                         stop_condition());
+  ASSERT_TRUE(OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(),
+                                     factory, new_crashes_by_signature,
+                                     crash_summary())
+                  .ok());
   std::string crash_report;
   crash_summary().Report(&crash_report);
 
   EXPECT_THAT(ReadFiles(crashing_dir()),
               UnorderedElementsAre(FieldsAre("bug-csig-isig2", "input2")));
-  EXPECT_THAT(ReadFiles(regression_dir()),
+  EXPECT_THAT(ReadFiles(regression_dir()), IsEmpty());
+  EXPECT_THAT(ReadFiles(incubating_dir()),
               UnorderedElementsAre(FieldsAre("isig1", "input1")));
   EXPECT_THAT(crash_report, AllOf(HasSubstr("Total crashes: 1"),
                                   HasSubstr("Crash ID   : bug-csig-isig2"),
@@ -506,7 +557,7 @@
 }
 
 TEST_F(OrganizeCrashingInputsTest,
-       DoesNotReplaceIrreproducibleCrashIfReproducedByAnotherOldInput) {
+       ReplacesIrreproducibleCrashIfReproducedByAnotherOldInput) {
   SetContentsAndGetPath(crashing_dir(), "bug1-csig-isig1", "input1");
   SetContentsAndGetPath(crashing_dir(), "bug2-csig-isig2", "input2");
   FakeCentipedeCallbacks callbacks(env(), /*crashing_inputs=*/{
@@ -514,19 +565,20 @@
                                    });
   NonOwningCallbacksFactory factory(callbacks);
 
-  OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(), factory,
-                         /*new_crashes_by_signature=*/{}, crash_summary(),
-                         stop_condition());
+  ASSERT_TRUE(OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(),
+                                     factory, /*new_crashes_by_signature=*/{},
+                                     crash_summary())
+                  .ok());
   std::string crash_report;
   crash_summary().Report(&crash_report);
 
   EXPECT_THAT(ReadFiles(crashing_dir()),
               UnorderedElementsAre(FieldsAre("bug1-csig-isig1", "input1"),
-                                   FieldsAre("bug2-csig-isig2", "input2")));
-  EXPECT_THAT(ReadFiles(regression_dir()),
-              UnorderedElementsAre(FieldsAre("isig2", "input2")));
-  EXPECT_THAT(crash_report, AllOf(HasSubstr("Total crashes: 1"),
+                                   FieldsAre("bug2-csig-isig1", "input1")));
+  EXPECT_THAT(ReadFiles(regression_dir()), IsEmpty());
+  EXPECT_THAT(crash_report, AllOf(HasSubstr("Total crashes: 2"),
                                   HasSubstr("Crash ID   : bug1-csig-isig1"),
+                                  HasSubstr("Crash ID   : bug2-csig-isig1"),
                                   HasSubstr("Category   : desc1"),
                                   HasSubstr("Signature  : csig"),
                                   HasSubstr("Description: desc1")));
@@ -541,9 +593,10 @@
       SetContentsAndGetPath(new_crashes_dir(), "isig", "input");
   new_crashes_by_signature["csig"] = {"isig", "desc", input_path};
 
-  OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(), factory,
-                         new_crashes_by_signature, crash_summary(),
-                         stop_condition());
+  ASSERT_TRUE(OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(),
+                                     factory, new_crashes_by_signature,
+                                     crash_summary())
+                  .ok());
   std::string crash_report;
   crash_summary().Report(&crash_report);
 
@@ -571,9 +624,10 @@
       SetContentsAndGetPath(new_crashes_dir(), "isig2", "input2");
   new_crashes_by_signature["csig"] = {"isig2", "desc2", input2_path};
 
-  OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(), factory,
-                         new_crashes_by_signature, crash_summary(),
-                         stop_condition());
+  ASSERT_TRUE(OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(),
+                                     factory, new_crashes_by_signature,
+                                     crash_summary())
+                  .ok());
   std::string crash_report;
   crash_summary().Report(&crash_report);
 
@@ -593,9 +647,10 @@
       env(), /*crashing_inputs=*/{{"input", {"csig", "desc"}}});
   NonOwningCallbacksFactory factory(callbacks);
 
-  OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(), factory,
-                         /*new_crashes_by_signature=*/{}, crash_summary(),
-                         stop_condition());
+  ASSERT_TRUE(OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(),
+                                     factory, /*new_crashes_by_signature=*/{},
+                                     crash_summary())
+                  .ok());
   std::string crash_report;
   crash_summary().Report(&crash_report);
 
@@ -633,9 +688,10 @@
                                    });
   NonOwningCallbacksFactory factory(callbacks);
 
-  OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(), factory,
-                         new_crashes_by_signature, crash_summary(),
-                         stop_condition());
+  ASSERT_TRUE(OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(),
+                                     factory, new_crashes_by_signature,
+                                     crash_summary())
+                  .ok());
   std::string crash_report;
   crash_summary().Report(&crash_report);
 
@@ -652,11 +708,7 @@
                   FieldsAre("bug9-csig9-isig9", "irrepro9"),
                   AnyOf(FieldsAre(HasSubstr("-csig10-isig10"), "new10"),
                         FieldsAre(HasSubstr("-csig11-isig11"), "new11"))));
-  EXPECT_THAT(ReadFiles(regression_dir()),
-              UnorderedElementsAre(FieldsAre("isig6", "irrepro6"),
-                                   FieldsAre("isig7", "irrepro7"),
-                                   FieldsAre("isig8", "irrepro8"),
-                                   FieldsAre("isig9", "irrepro9")));
+  EXPECT_THAT(ReadFiles(regression_dir()), IsEmpty());
   EXPECT_THAT(crash_report, HasSubstr("Total crashes: 6"));
 }
 
@@ -690,9 +742,10 @@
                                    });
   NonOwningCallbacksFactory factory(callbacks);
 
-  OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(), factory,
-                         new_crashes_by_signature, crash_summary(),
-                         stop_condition());
+  ASSERT_TRUE(OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(),
+                                     factory, new_crashes_by_signature,
+                                     crash_summary())
+                  .ok());
   std::string crash_report;
   crash_summary().Report(&crash_report);
 
@@ -707,8 +760,7 @@
                                    FieldsAre("bug8-csig8-isig8", "repro8"),
                                    FieldsAre("bug9-csig9-isig9", "repro9"),
                                    FieldsAre("bug10-csig10-isig11", "new11")));
-  EXPECT_THAT(ReadFiles(regression_dir()),
-              UnorderedElementsAre(FieldsAre("isig10", "irrepro10")));
+  EXPECT_THAT(ReadFiles(regression_dir()), IsEmpty());
   EXPECT_THAT(crash_report, AllOf(HasSubstr("Total crashes: 10"),
                                   HasSubstr("Crash ID   : bug10-csig10-isig11"),
                                   HasSubstr("Category   : desc11"),
@@ -716,5 +768,141 @@
                                   HasSubstr("Description: desc11")));
 }
 
+TEST_F(OrganizeCrashingInputsTest,
+       MovesReproducingIncubatingCrashToCrashingDir) {
+  SetContentsAndGetPath(incubating_dir(), "isig1", "input1");
+
+  FakeCentipedeCallbacks callbacks(env(), /*crashing_inputs=*/{
+                                       {"input1", {"csig", "desc"}},
+                                   });
+  NonOwningCallbacksFactory factory(callbacks);
+
+  ASSERT_TRUE(OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(),
+                                     factory, /*new_crashes_by_signature=*/{},
+                                     crash_summary())
+                  .ok());
+  std::string crash_report;
+  crash_summary().Report(&crash_report);
+
+  EXPECT_THAT(ReadFiles(incubating_dir()), IsEmpty());
+  EXPECT_THAT(ReadFiles(crashing_dir()),
+              UnorderedElementsAre(
+                  FieldsAre(MatchesRegex("[a-f0-9]+-csig-isig1"), "input1")));
+  EXPECT_THAT(ReadFiles(regression_dir()), IsEmpty());
+  EXPECT_THAT(
+      crash_report,
+      AllOf(HasSubstr("Total crashes: 1"),
+            ContainsRegex("Crash ID   : [a-f0-9]+-csig-isig1"),
+            HasSubstr("Category   : desc"), HasSubstr("Signature  : csig"),
+            HasSubstr("Description: desc")));
+}
+
+TEST_F(OrganizeCrashingInputsTest,
+       KeepsIrreproducibleIncubatingCrashIfTtlNotExpired) {
+  SetContentsAndGetPath(incubating_dir(), "isig1", "input1");
+
+  FakeCentipedeCallbacks callbacks(env(), /*crashing_inputs=*/{});
+  NonOwningCallbacksFactory factory(callbacks);
+
+  ASSERT_TRUE(OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(),
+                                     factory, /*new_crashes_by_signature=*/{},
+                                     crash_summary(),
+                                     /*regression_ttl=*/absl::Hours(24))
+                  .ok());
+  std::string crash_report;
+  crash_summary().Report(&crash_report);
+
+  EXPECT_THAT(ReadFiles(incubating_dir()),
+              UnorderedElementsAre(FieldsAre("isig1", "input1")));
+  EXPECT_THAT(ReadFiles(crashing_dir()), IsEmpty());
+  EXPECT_THAT(ReadFiles(regression_dir()), IsEmpty());
+  EXPECT_THAT(crash_report, HasSubstr("Total crashes: 0"));
+}
+
+TEST_F(OrganizeCrashingInputsTest, MovesExpiredIncubatingCrashToRegressionDir) {
+  absl::SimulatedClock clock(absl::Now());
+  SetContentsAndGetPath(incubating_dir(), "isig1", "input1");
+  clock.AdvanceTime(absl::Hours(25));
+
+  FakeCentipedeCallbacks callbacks(env(), /*crashing_inputs=*/{});
+  NonOwningCallbacksFactory factory(callbacks);
+
+  ASSERT_TRUE(OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(),
+                                     factory, /*new_crashes_by_signature=*/{},
+                                     crash_summary(),
+                                     /*regression_ttl=*/absl::Hours(24), clock)
+                  .ok());
+  std::string crash_report;
+  crash_summary().Report(&crash_report);
+
+  EXPECT_THAT(ReadFiles(incubating_dir()), IsEmpty());
+  EXPECT_THAT(ReadFiles(crashing_dir()), IsEmpty());
+  EXPECT_THAT(ReadFiles(regression_dir()),
+              UnorderedElementsAre(FieldsAre("isig1", "input1")));
+  EXPECT_THAT(crash_report, HasSubstr("Total crashes: 0"));
+}
+
+TEST_F(OrganizeCrashingInputsTest,
+       ReplacesCrashInputIfSignatureChangesButNewCrashWithOldSignatureExists) {
+  SetContentsAndGetPath(crashing_dir(), "bug1-csig_old-isig1", "input1");
+
+  FakeCentipedeCallbacks callbacks(env(), /*crashing_inputs=*/{
+                                       {"input1", {"csig_new", "desc_new"}},
+                                   });
+  NonOwningCallbacksFactory factory(callbacks);
+
+  absl::flat_hash_map<std::string, CrashDetails> new_crashes_by_signature;
+  const auto input2_path =
+      SetContentsAndGetPath(new_crashes_dir(), "isig2", "input2");
+  new_crashes_by_signature["csig_old"] = {"isig2", "desc_old", input2_path};
+
+  ASSERT_TRUE(OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(),
+                                     factory, new_crashes_by_signature,
+                                     crash_summary())
+                  .ok());
+  std::string crash_report;
+  crash_summary().Report(&crash_report);
+
+  EXPECT_THAT(
+      ReadFiles(crashing_dir()),
+      UnorderedElementsAre(
+          FieldsAre("bug1-csig_old-isig2", "input2"),
+          FieldsAre(MatchesRegex("[a-f0-9]+-csig_new-isig1"), "input1")));
+  EXPECT_THAT(ReadFiles(regression_dir()), IsEmpty());
+  EXPECT_THAT(ReadFiles(incubating_dir()), IsEmpty());
+  EXPECT_THAT(crash_report,
+              AllOf(HasSubstr("Total crashes: 2"),
+                    HasSubstr("Crash ID   : bug1-csig_old-isig2"),
+                    ContainsRegex("Crash ID   : [a-f0-9]+-csig_new-isig1"),
+                    HasSubstr("Signature  : csig_old"),
+                    HasSubstr("Signature  : csig_new")));
+}
+
+TEST_F(OrganizeCrashingInputsTest, ReplacesInputWithWinnerAlreadyOnDisk) {
+  // Setup two existing crashes for the same bug/signature.
+  SetContentsAndGetPath(crashing_dir(), "bug1-sig1-isig1", "input1");
+  SetContentsAndGetPath(crashing_dir(), "bug1-sig1-isig2", "input2");
+
+  // input1 reproduces with sig1 (winner).
+  // input2 does not reproduce.
+  FakeCentipedeCallbacks callbacks(env(), /*crashing_inputs=*/{
+                                       {"input1", {"sig1", "desc1"}},
+                                   });
+  NonOwningCallbacksFactory factory(callbacks);
+
+  // This should not fail with "filesystem::copy() failed" (self-copy).
+  ASSERT_TRUE(OrganizeCrashingInputs(regression_dir(), crashing_dir(), env(),
+                                     factory, /*new_crashes_by_signature=*/{},
+                                     crash_summary())
+                  .ok());
+
+  // bug1-sig1-isig1 should be kept.
+  // bug1-sig1-isig2 should be demoted to incubating.
+  EXPECT_THAT(ReadFiles(crashing_dir()),
+              UnorderedElementsAre(FieldsAre("bug1-sig1-isig1", "input1")));
+  EXPECT_THAT(ReadFiles(incubating_dir()),
+              UnorderedElementsAre(FieldsAre("isig2", "input2")));
+}
+
 }  // namespace
 }  // namespace fuzztest::internal
diff --git a/centipede/crash_deduplication_test_util.cc b/centipede/crash_deduplication_test_util.cc
new file mode 100644
index 0000000..5b881e1
--- /dev/null
+++ b/centipede/crash_deduplication_test_util.cc
@@ -0,0 +1,42 @@
+// Copyright 2026 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.
+
+#include "./centipede/crash_deduplication_test_util.h"
+
+#include <cstdlib>
+#include <string_view>
+
+#include "absl/container/flat_hash_map.h"
+#include "absl/types/span.h"
+#include "./centipede/runner_result.h"
+#include "./common/defs.h"
+
+namespace fuzztest::internal {
+
+bool FakeCentipedeCallbacks::Execute(std::string_view binary,
+                                     absl::Span<const ByteSpan> inputs,
+                                     BatchResult& batch_result) {
+  batch_result.ClearAndResize(inputs.size());
+  for (ByteSpan input : inputs) {
+    auto it = crashing_inputs_.find(AsStringView(input));
+    if (it == crashing_inputs_.end()) continue;
+    batch_result.exit_code() = EXIT_FAILURE;
+    batch_result.failure_signature() = it->second.signature;
+    batch_result.failure_description() = it->second.description;
+    return false;
+  }
+  return true;
+}
+
+}  // namespace fuzztest::internal
diff --git a/centipede/crash_deduplication_test_util.h b/centipede/crash_deduplication_test_util.h
new file mode 100644
index 0000000..490c64a
--- /dev/null
+++ b/centipede/crash_deduplication_test_util.h
@@ -0,0 +1,55 @@
+// Copyright 2026 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.
+
+#ifndef FUZZTEST_CENTIPEDE_CRASH_DEDUPLICATION_TEST_UTIL_H_
+#define FUZZTEST_CENTIPEDE_CRASH_DEDUPLICATION_TEST_UTIL_H_
+
+#include <string>
+#include <string_view>
+#include <utility>
+
+#include "absl/container/flat_hash_map.h"
+#include "absl/types/span.h"
+#include "./centipede/centipede_callbacks.h"
+#include "./centipede/environment.h"
+#include "./centipede/runner_result.h"
+#include "./centipede/stop.h"
+#include "./common/defs.h"
+
+namespace fuzztest::internal {
+
+class FakeCentipedeCallbacks : public CentipedeCallbacks {
+ public:
+  struct Crash {
+    std::string signature;
+    std::string description;
+  };
+
+  explicit FakeCentipedeCallbacks(
+      const Environment& env,
+      absl::flat_hash_map<std::string, Crash> crashing_inputs)
+      : CentipedeCallbacks(env, internal_stop_condition_),
+        crashing_inputs_(std::move(crashing_inputs)) {}
+
+  bool Execute(std::string_view binary, absl::Span<const ByteSpan> inputs,
+               BatchResult& batch_result) override;
+
+ private:
+  absl::flat_hash_map<std::string, Crash> crashing_inputs_;
+  StopCondition internal_stop_condition_;
+};
+
+}  // namespace fuzztest::internal
+
+#endif  // FUZZTEST_CENTIPEDE_CRASH_DEDUPLICATION_TEST_UTIL_H_
diff --git a/common/BUILD b/common/BUILD
index d64c480..fc9c809 100644
--- a/common/BUILD
+++ b/common/BUILD
@@ -133,6 +133,7 @@
                "@abseil-cpp//absl/status",
                "@abseil-cpp//absl/status:statusor",
                "@abseil-cpp//absl/strings",
+               "@abseil-cpp//absl/time",
            ] + select({
                "//conditions:default": [":remote_file_oss"],
            }) +
@@ -164,6 +165,7 @@
         "@abseil-cpp//absl/status",
         "@abseil-cpp//absl/status:statusor",
         "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/time",
     ] + select({
         "@com_google_fuzztest//fuzztest:disable_riegeli": [],
         "//conditions:default": [
diff --git a/common/CMakeLists.txt b/common/CMakeLists.txt
index f451674..671195e 100644
--- a/common/CMakeLists.txt
+++ b/common/CMakeLists.txt
@@ -124,6 +124,7 @@
     absl::status
     absl::statusor
     absl::strings
+    absl::time
 )
 
 fuzztest_cc_library(
diff --git a/common/remote_file.h b/common/remote_file.h
index 0b70444..7a370f1 100644
--- a/common/remote_file.h
+++ b/common/remote_file.h
@@ -30,6 +30,7 @@
 #include "absl/base/nullability.h"
 #include "absl/status/status.h"
 #include "absl/status/statusor.h"
+#include "absl/time/time.h"
 #include "./common/defs.h"
 #ifndef CENTIPEDE_DISABLE_RIEGELI
 #include "riegeli/bytes/reader.h"
@@ -61,22 +62,22 @@
 
 // Opens a (potentially remote) file 'file_path' and returns a handle to it.
 // Supported modes: "r", "a", "w", same as in C FILE API.
-absl::StatusOr<RemoteFile *> RemoteFileOpen(std::string_view file_path,
-                                            const char *mode);
+absl::StatusOr<RemoteFile*> RemoteFileOpen(std::string_view file_path,
+                                           const char* mode);
 
 // Closes the file previously opened by RemoteFileOpen.
-absl::Status RemoteFileClose(RemoteFile *absl_nonnull f);
+absl::Status RemoteFileClose(RemoteFile* absl_nonnull f);
 
 // Adjusts the buffered I/O capacity for a file opened for writing. By default,
 // the internal buffer of size `BUFSIZ` is used. May only be used after opening
 // a file, but before performing any other operations on it. Violating this
 // requirement in general can cause undefined behavior.
-absl::Status RemoteFileSetWriteBufferSize(RemoteFile *absl_nonnull f,
+absl::Status RemoteFileSetWriteBufferSize(RemoteFile* absl_nonnull f,
                                           size_t size);
 
 // Appends characters from 'contents' to 'f'.
-absl::Status RemoteFileAppend(RemoteFile *absl_nonnull f,
-                              const std::string &contents);
+absl::Status RemoteFileAppend(RemoteFile* absl_nonnull f,
+                              const std::string& contents);
 
 // Appends bytes from 'contents' to 'f'.
 absl::Status RemoteFileAppend(RemoteFile* absl_nonnull f, ByteSpan contents);
@@ -85,13 +86,13 @@
 // pipeline are consumed by itself (e.g. shard cross-pollination) and can be
 // consumed by external processes (e.g. monitoring): for such files, call this
 // API after every write to ensure that they are in a valid state.
-absl::Status RemoteFileFlush(RemoteFile *absl_nonnull f);
+absl::Status RemoteFileFlush(RemoteFile* absl_nonnull f);
 
 // Reads all current contents of 'f' into 'ba'.
-absl::Status RemoteFileRead(RemoteFile *absl_nonnull f, ByteArray &ba);
+absl::Status RemoteFileRead(RemoteFile* absl_nonnull f, ByteArray& ba);
 
 // Reads all current contents of 'f' into 'contents'.
-absl::Status RemoteFileRead(RemoteFile *absl_nonnull f, std::string &contents);
+absl::Status RemoteFileRead(RemoteFile* absl_nonnull f, std::string& contents);
 
 // Creates a (potentially remote) directory 'dir_path', as well as any missing
 // parent directories. No-op if the directory already exists.
@@ -105,11 +106,11 @@
 absl::Status RemoteFileSetContents(std::string_view path, ByteSpan contents);
 
 // Reads the contents of the file at 'path' into 'contents'.
-absl::Status RemoteFileGetContents(std::string_view path, ByteArray &contents);
+absl::Status RemoteFileGetContents(std::string_view path, ByteArray& contents);
 
 // Reads the contents of the file at 'path' into 'contents'.
 absl::Status RemoteFileGetContents(std::string_view path,
-                                   std::string &contents);
+                                   std::string& contents);
 
 // Returns true if `path` exists.
 bool RemotePathExists(std::string_view path);
@@ -120,6 +121,10 @@
 // Returns the size of the file at `path` in bytes. The file must exist.
 absl::StatusOr<int64_t> RemoteFileGetSize(std::string_view path);
 
+// Returns the last modification time of the file at `path`.
+// The file must exist.
+absl::StatusOr<absl::Time> RemoteFileGetMTime(std::string_view path);
+
 // Finds all files matching `glob` and appends them to `matches`.
 //
 // Returns Ok when matches were found, or NotFound when no matches were found.
@@ -131,7 +136,7 @@
 // filtering based on some criterion.
 [[deprecated("Do not use in new code.")]]
 absl::Status RemoteGlobMatch(std::string_view glob,
-                             std::vector<std::string> &matches);
+                             std::vector<std::string>& matches);
 
 // Lists all files within `path`, recursively expanding subdirectories if
 // `recursively` is true. Does not return any directories. Returns an empty
diff --git a/common/remote_file_oss.cc b/common/remote_file_oss.cc
index 10299f6..d5ccbff 100644
--- a/common/remote_file_oss.cc
+++ b/common/remote_file_oss.cc
@@ -24,6 +24,9 @@
 #include <windows.h>
 #endif  // defined(_MSC_VER)
 
+#include <sys/stat.h>
+#include <sys/types.h>
+
 #include <cerrno>
 #include <cstdint>
 #include <cstdio>
@@ -40,6 +43,7 @@
 #include "absl/status/status.h"
 #include "absl/status/statusor.h"
 #include "absl/strings/str_cat.h"
+#include "absl/time/time.h"
 #include "./common/defs.h"
 #include "./common/logging.h"
 #include "./common/remote_file.h"
@@ -361,6 +365,16 @@
   return sz;
 }
 
+absl::StatusOr<absl::Time> RemoteFileGetMTime(std::string_view path) {
+  struct ::stat st;
+  if (::stat(std::string(path).c_str(), &st) != 0) {
+    return absl::UnknownError(
+        absl::StrCat("stat() failed, path: ", std::string(path),
+                     ", errno: ", std::strerror(errno)));
+  }
+  return absl::FromTimeT(st.st_mtime);
+}
+
 namespace {
 
 #if defined(FUZZTEST_HAS_OSS_GLOB)