Report a summary of crashes at the end of an "update corpus database" session.

In a follow-up CL I'll add crash summaries at the end of other actions that
can expose crashes (replay crash, regular fuzz without updating the corpus
database).

PiperOrigin-RevId: 777596299
diff --git a/centipede/BUILD b/centipede/BUILD
index 6366036..c43809e 100644
--- a/centipede/BUILD
+++ b/centipede/BUILD
@@ -815,6 +815,7 @@
         ":centipede_lib",
         ":command",
         ":coverage",
+        ":crash_summary",
         ":distill",
         ":environment",
         ":minimize_crash",
@@ -952,6 +953,18 @@
 )
 
 cc_library(
+    name = "crash_summary",
+    srcs = ["crash_summary.cc"],
+    hdrs = ["crash_summary.h"],
+    deps = [
+        ":util",
+        "@abseil-cpp//absl/strings:str_format",
+        "@abseil-cpp//absl/types:span",
+        "@com_google_fuzztest//common:defs",
+    ],
+)
+
+cc_library(
     name = "weak_sancov_stubs",
     srcs = ["weak_sancov_stubs.cc"],
     visibility = ["//visibility:public"],
@@ -1840,6 +1853,15 @@
     ],
 )
 
+cc_test(
+    name = "crash_summary_test",
+    srcs = ["crash_summary_test.cc"],
+    deps = [
+        ":crash_summary",
+        "@googletest//:gtest_main",
+    ],
+)
+
 ################################################################################
 #                               Other tests
 ################################################################################
diff --git a/centipede/centipede_interface.cc b/centipede/centipede_interface.cc
index 77b7a2d..fbf90db 100644
--- a/centipede/centipede_interface.cc
+++ b/centipede/centipede_interface.cc
@@ -52,6 +52,7 @@
 #include "./centipede/centipede_callbacks.h"
 #include "./centipede/command.h"
 #include "./centipede/coverage.h"
+#include "./centipede/crash_summary.h"
 #include "./centipede/distill.h"
 #include "./centipede/environment.h"
 #include "./centipede/minimize_crash.h"
@@ -292,7 +293,7 @@
 // signatures of the remaining crashes.
 absl::flat_hash_set<std::string> PruneOldCrashesAndGetRemainingCrashSignatures(
     const std::filesystem::path &crashing_dir, const Environment &env,
-    CentipedeCallbacksFactory &callbacks_factory) {
+    CentipedeCallbacksFactory &callbacks_factory, CrashSummary &crash_summary) {
   const std::vector<std::string> crashing_input_files =
       // The corpus database layout assumes the crash input files are located
       // directly in the crashing subdirectory, so we don't list recursively.
@@ -313,6 +314,11 @@
     if (!is_reproducible || batch_result.IsSetupFailure() || is_duplicate) {
       CHECK_OK(RemotePathDelete(crashing_input_file, /*recursively=*/false));
     } else {
+      crash_summary.AddCrash(
+          {std::filesystem::path(crashing_input_file).filename(),
+           /*category=*/batch_result.failure_description(),
+           batch_result.failure_signature(),
+           batch_result.failure_description()});
       CHECK_OK(RemotePathTouchExistingFile(crashing_input_file));
     }
   }
@@ -322,7 +328,8 @@
 // TODO(b/405382531): Add unit tests once the function is unit-testable.
 void DeduplicateAndStoreNewCrashes(
     const std::filesystem::path &crashing_dir, const WorkDir &workdir,
-    size_t total_shards, absl::flat_hash_set<std::string> crash_signatures) {
+    size_t total_shards, absl::flat_hash_set<std::string> crash_signatures,
+    CrashSummary &crash_summary) {
   for (size_t shard_idx = 0; shard_idx < total_shards; ++shard_idx) {
     const std::vector<std::string> new_crashing_input_files =
         // The crash reproducer directory may contain subdirectories with
@@ -352,6 +359,24 @@
       const bool is_duplicate =
           !crash_signatures.insert(new_crash_signature).second;
       if (is_duplicate) continue;
+
+      const std::string crash_description_path =
+          crash_metadata_dir / absl::StrCat(crashing_input_file_name, ".desc");
+      std::string new_crash_description;
+      const absl::Status description_status =
+          RemoteFileGetContents(crash_description_path, new_crash_description);
+      if (!description_status.ok()) {
+        LOG(WARNING)
+            << "Failed to read crash description for "
+            << crashing_input_file_name
+            << ". Will use the crash signature as the description. Status: "
+            << description_status;
+        new_crash_description = new_crash_signature;
+      }
+      crash_summary.AddCrash({crashing_input_file_name,
+                              /*category=*/new_crash_description,
+                              std::move(new_crash_signature),
+                              new_crash_description});
       CHECK_OK(
           RemoteFileRename(crashing_input_file,
                            (crashing_dir / crashing_input_file_name).c_str()));
@@ -666,12 +691,15 @@
     }
 
     // Deduplicate and update the crashing inputs.
+    CrashSummary crash_summary{fuzztest_config.binary_identifier,
+                               fuzz_tests_to_run[i]};
     const std::filesystem::path crashing_dir = fuzztest_db_path / "crashing";
     absl::flat_hash_set<std::string> crash_signatures =
-        PruneOldCrashesAndGetRemainingCrashSignatures(crashing_dir, env,
-                                                      callbacks_factory);
+        PruneOldCrashesAndGetRemainingCrashSignatures(
+            crashing_dir, env, callbacks_factory, crash_summary);
     DeduplicateAndStoreNewCrashes(crashing_dir, workdir, env.total_shards,
-                                  std::move(crash_signatures));
+                                  std::move(crash_signatures), crash_summary);
+    crash_summary.Report(&std::cerr);
   }
 
   return EXIT_SUCCESS;
diff --git a/centipede/crash_summary.cc b/centipede/crash_summary.cc
new file mode 100644
index 0000000..8dbf313
--- /dev/null
+++ b/centipede/crash_summary.cc
@@ -0,0 +1,46 @@
+// Copyright 2025 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_summary.h"
+
+#include <utility>
+
+#include "absl/strings/str_format.h"
+#include "./centipede/util.h"
+#include "./common/defs.h"
+
+namespace fuzztest::internal {
+
+void CrashSummary::AddCrash(Crash crash) {
+  crashes_.push_back(std::move(crash));
+}
+
+void CrashSummary::Report(absl::FormatRawSink sink) const {
+  absl::Format(sink, "=== Summary of detected crashes ===\n\n");
+  absl::Format(sink, "Binary ID    : %s\n", binary_id());
+  absl::Format(sink, "Fuzz test    : %s\n", fuzz_test());
+  absl::Format(sink, "Total crashes: %d\n\n", crashes().size());
+  int i = 0;
+  for (const Crash& crash : crashes()) {
+    absl::Format(sink, "Crash #%d:\n", ++i);
+    absl::Format(sink, "  Crash ID   : %s\n", crash.id);
+    absl::Format(sink, "  Category   : %s\n", crash.category);
+    absl::Format(sink, "  Signature  : %s\n",
+                 AsPrintableString(AsByteSpan(crash.signature), 32));
+    absl::Format(sink, "  Description: %s\n\n", crash.description);
+  }
+  absl::Format(sink, "=== End of summary of detected crashes ===\n\n");
+}
+
+}  // namespace fuzztest::internal
diff --git a/centipede/crash_summary.h b/centipede/crash_summary.h
new file mode 100644
index 0000000..b5c631d
--- /dev/null
+++ b/centipede/crash_summary.h
@@ -0,0 +1,76 @@
+// Copyright 2025 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_SUMMARY_H_
+#define FUZZTEST_CENTIPEDE_CRASH_SUMMARY_H_
+
+#include <string>
+#include <string_view>
+#include <vector>
+
+#include "absl/strings/str_format.h"
+#include "absl/types/span.h"
+
+namespace fuzztest::internal {
+
+// Accumulates crashes for a single fuzz test and provides a method to report a
+// summary of the crashes.
+class CrashSummary {
+ public:
+  struct Crash {
+    std::string id;
+    std::string category;
+    std::string signature;
+    std::string description;
+
+    friend bool operator==(const Crash& lhs, const Crash& rhs) {
+      return lhs.id == rhs.id && lhs.category == rhs.category &&
+             lhs.signature == rhs.signature &&
+             lhs.description == rhs.description;
+    }
+  };
+
+  explicit CrashSummary(std::string_view binary_id, std::string_view fuzz_test)
+      : binary_id_(std::string(binary_id)),
+        fuzz_test_(std::string(fuzz_test)) {}
+
+  CrashSummary(const CrashSummary&) = default;
+  CrashSummary& operator=(const CrashSummary&) = default;
+  CrashSummary(CrashSummary&&) = default;
+  CrashSummary& operator=(CrashSummary&&) = default;
+
+  // Adds a crash to the summary.
+  void AddCrash(Crash crash);
+
+  // Reports a summary of the crashes to `sink`.
+  void Report(absl::FormatRawSink sink) const;
+
+  std::string_view binary_id() const { return binary_id_; }
+  std::string_view fuzz_test() const { return fuzz_test_; }
+  absl::Span<const Crash> crashes() const { return crashes_; }
+
+  friend bool operator==(const CrashSummary& lhs, const CrashSummary& rhs) {
+    return lhs.binary_id_ == rhs.binary_id_ &&
+           lhs.fuzz_test_ == rhs.fuzz_test_ && lhs.crashes_ == rhs.crashes_;
+  }
+
+ private:
+  std::string binary_id_;
+  std::string fuzz_test_;
+  std::vector<Crash> crashes_;
+};
+
+}  // namespace fuzztest::internal
+
+#endif  // FUZZTEST_CENTIPEDE_CRASH_SUMMARY_H_
diff --git a/centipede/crash_summary_test.cc b/centipede/crash_summary_test.cc
new file mode 100644
index 0000000..1656a42
--- /dev/null
+++ b/centipede/crash_summary_test.cc
@@ -0,0 +1,54 @@
+// Copyright 2025 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_summary.h"
+
+#include <string>
+#include <string_view>
+
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+namespace fuzztest::internal {
+namespace {
+
+using ::testing::AllOf;
+using ::testing::HasSubstr;
+
+TEST(CrashSummaryTest, ReportPrintsSummary) {
+  CrashSummary summary("binary_id", "fuzz_test");
+  summary.AddCrash({"id1", "category1", "signature1", "description1"});
+  summary.AddCrash({"id2", "category2",
+                    "Unprintable (\xbe\xef) and very long signature",
+                    "description2"});
+  std::string output;
+  summary.Report(&output);
+
+  EXPECT_THAT(
+      output,
+      AllOf(HasSubstr("Binary ID    : binary_id"),
+            HasSubstr("Fuzz test    : fuzz_test"),
+            HasSubstr("Total crashes: 2"),  //
+            HasSubstr("Crash ID   : id1"),  //
+            HasSubstr("Category   : category1"),
+            HasSubstr("Signature  : signature1"),
+            HasSubstr("Description: description1"),
+            HasSubstr("Crash ID   : id2"),  //
+            HasSubstr("Category   : category2"),
+            HasSubstr("Signature  : Unprintable (\\xBE\\xEF) and very long s"),
+            HasSubstr("Description: description2")));
+}
+
+}  // namespace
+}  // namespace fuzztest::internal
diff --git a/e2e_tests/corpus_database_test.cc b/e2e_tests/corpus_database_test.cc
index 98422cd..6a2ed8d 100644
--- a/e2e_tests/corpus_database_test.cc
+++ b/e2e_tests/corpus_database_test.cc
@@ -195,6 +195,20 @@
       Contains(HasSubstr("FuzzTest.FailsInTwoWays/crashing/")).Times(2));
 }
 
+TEST_P(UpdateCorpusDatabaseTest, ReportsCrashSummary) {
+  EXPECT_THAT(GetUpdateCorpusDatabaseStdErr(),
+              AllOf(ContainsRegex(
+                        R"re((?s)=== Summary of detected crashes ===
+.*?Fuzz test    : FuzzTest.FailsInTwoWays
+.*?Total crashes: 2
+.*?=== End of summary of detected crashes ===)re"),
+                    ContainsRegex(
+                        R"re((?s)=== Summary of detected crashes ===
+.*?Fuzz test    : FuzzTest.FailsWithStackOverflow
+.*?Total crashes: 1
+.*?=== End of summary of detected crashes ===)re")));
+}
+
 TEST_P(UpdateCorpusDatabaseTest, StartsNewFuzzTestRunsWithoutExecutionIds) {
   TempDir corpus_database;