fuzztest-rust | Add support for listing crash IDs from the corpus database

Support listing crash IDs stored in the corpus database in fuzztest-rs and cargo-fuzztest.

- Add --list-crash-ids and --list-crash-ids-file options to fuzztest-options, requiring corpus-db and each other.
- Add ExecutionMode::ListCrashIds and forward --list_crash_ids=true and --list_crash_ids_file=<file> to Centipede.
- Support --list-crash-ids and --list-crash-ids-file in cargo-fuzztest CLI and runner.
- Update replay integration tests and cargo-fuzztest end-to-end tests to use the new options.

PiperOrigin-RevId: 966073502
diff --git a/rust/cargo_fuzztest/src/lib.rs b/rust/cargo_fuzztest/src/lib.rs
index 241bf1f..b7c57eb 100644
--- a/rust/cargo_fuzztest/src/lib.rs
+++ b/rust/cargo_fuzztest/src/lib.rs
@@ -18,8 +18,8 @@
 use anyhow::{Context, Result};
 use clap::Parser;
 pub use fuzztest_options::{
-    ExecutionMode, FuzzFor, FuzzOptions, FuzzTestOptions, ReplayCorpusOptions, ReplayCrashOptions,
-    TimeBudgetType,
+    ExecutionMode, FuzzFor, FuzzOptions, FuzzTestOptions, ListCrashIdsOptions, ReplayCorpusOptions,
+    ReplayCrashOptions, TimeBudgetType,
 };
 use std::env;
 use std::ffi::OsString;
@@ -85,7 +85,8 @@
             }
             ExecutionMode::ReplayCorpus(_)
             | ExecutionMode::ReplayAllCrashes
-            | ExecutionMode::ReplayCrash(_) => {
+            | ExecutionMode::ReplayCrash(_)
+            | ExecutionMode::ListCrashIds(_) => {
                 self.check_centipede_binary_path_is_set()?;
                 self.check_corpus_db_is_set()?;
                 mode
@@ -278,6 +279,14 @@
                 cmd.env("FUZZTEST_TIME_BUDGET_TYPE", time_budget_str);
             }
 
+            ExecutionMode::ListCrashIds(list_crash_ids_options) => {
+                cmd.env("FUZZTEST_LIST_CRASH_IDS", "true");
+                cmd.env(
+                    "FUZZTEST_LIST_CRASH_IDS_FILE",
+                    &list_crash_ids_options.list_crash_ids_file,
+                );
+            }
+
             ExecutionMode::SmokeTest => {
                 // nothing to be done
             }
@@ -687,4 +696,102 @@
             Some("/custom/centipede".to_string())
         )));
     }
+
+    #[gtest]
+    fn test_cli_option_parsing_list_crash_ids_success() {
+        let parsed = CargoFuzzTestOptions::try_parse_from([
+            "cargo-fuzztest",
+            "--list-crash-ids",
+            "--list-crash-ids-file",
+            "/tmp/crashes.txt",
+            "--corpus-db",
+            "/tmp/corpus_db",
+            "--centipede-binary-path",
+            "/custom/centipede",
+        ])
+        .expect("valid list-crash-ids options should parse successfully");
+
+        assert!(parsed.fuzztest_options.list_crash_ids);
+        assert_eq!(
+            parsed.fuzztest_options.list_crash_ids_file.as_deref(),
+            Some("/tmp/crashes.txt")
+        );
+        assert_eq!(parsed.fuzztest_options.corpus_db.as_deref(), Some("/tmp/corpus_db"));
+
+        let mode = parsed.execution_mode().expect("valid execution mode");
+        assert_eq!(
+            mode,
+            ExecutionMode::ListCrashIds(ListCrashIdsOptions {
+                list_crash_ids_file: "/tmp/crashes.txt".to_string(),
+            })
+        );
+    }
+
+    #[gtest]
+    fn test_execution_mode_list_crash_ids_missing_corpus_db_errors() {
+        let options = CargoFuzzTestOptions {
+            fuzztest_options: FuzzTestOptions {
+                list_crash_ids: true,
+                list_crash_ids_file: Some("/tmp/crashes.txt".to_string()),
+                ..Default::default()
+            },
+            centipede_binary_path: Some("/custom/centipede".to_string()),
+            ..Default::default()
+        };
+        let err = options.execution_mode().expect_err("missing corpus-db should cause error");
+        assert!(err.to_string().contains("`--corpus-db` needs to be specified"));
+    }
+
+    #[gtest]
+    fn test_execution_mode_list_crash_ids_missing_centipede_binary_path_errors() {
+        let options = CargoFuzzTestOptions {
+            fuzztest_options: FuzzTestOptions {
+                list_crash_ids: true,
+                list_crash_ids_file: Some("/tmp/crashes.txt".to_string()),
+                corpus_db: Some("/tmp/corpus_db".to_string()),
+                ..Default::default()
+            },
+            ..Default::default()
+        };
+        let err =
+            options.execution_mode().expect_err("missing centipede-binary-path should cause error");
+        assert!(err.to_string().contains("`--centipede-binary-path` needs to be specified"));
+    }
+
+    #[gtest]
+    fn test_build_run_command_list_crash_ids() {
+        let options = CargoFuzzTestOptions {
+            fuzztest_options: FuzzTestOptions {
+                list_crash_ids: true,
+                list_crash_ids_file: Some("/tmp/crashes.txt".to_string()),
+                corpus_db: Some("/tmp/corpus_db".to_string()),
+                ..Default::default()
+            },
+            centipede_binary_path: Some("/custom/centipede".to_string()),
+            ..Default::default()
+        };
+        let runner = FuzztestRunner::new("x86_64-unknown-linux-gnu".to_string(), options);
+        let cmd =
+            runner.build_run_command(Path::new("/tmp/test_bin")).expect("should build run command");
+
+        let envs: Vec<(String, Option<String>)> = cmd
+            .get_envs()
+            .map(|(k, v)| {
+                (k.to_string_lossy().to_string(), v.map(|s| s.to_string_lossy().to_string()))
+            })
+            .collect();
+
+        assert!(envs.contains(&("FUZZTEST_LIST_CRASH_IDS".to_string(), Some("true".to_string()))));
+        assert!(envs.contains(&(
+            "FUZZTEST_LIST_CRASH_IDS_FILE".to_string(),
+            Some("/tmp/crashes.txt".to_string())
+        )));
+        assert!(
+            envs.contains(&("FUZZTEST_CORPUS_DB".to_string(), Some("/tmp/corpus_db".to_string())))
+        );
+        assert!(envs.contains(&(
+            "FUZZTEST_CENTIPEDE_BINARY_PATH".to_string(),
+            Some("/custom/centipede".to_string())
+        )));
+    }
 }
diff --git a/rust/cargo_fuzztest/tests/e2e_cli_test.rs b/rust/cargo_fuzztest/tests/e2e_cli_test.rs
index fa73f99..b77256f 100644
--- a/rust/cargo_fuzztest/tests/e2e_cli_test.rs
+++ b/rust/cargo_fuzztest/tests/e2e_cli_test.rs
@@ -144,9 +144,6 @@
 
 #[gtest]
 fn test_cargo_fuzztest_e2e_replay_by_id() {
-    let centipede_bin = env::var("FUZZTEST_CENTIPEDE_BINARY_PATH")
-        .expect("FUZZTEST_CENTIPEDE_BINARY_PATH needs to be set for the test");
-
     let sample_crate_path = get_sample_crate_path("another_sample_fuzz_crate");
 
     let temp_target_dir = TempDir::new().expect("Failed to create temporary target directory");
@@ -159,30 +156,12 @@
         TempDir::new().expect("Failed to create temporary workdir_root directory");
     fs::create_dir_all(&workdir_root_dir).expect("Failed to workdir_root directory");
 
+    let centipede_bin = env::var("FUZZTEST_CENTIPEDE_BINARY_PATH")
+        .expect("FUZZTEST_CENTIPEDE_BINARY_PATH needs to be set for the test");
+
     let test_target = "__fuzztest_mod__crashing_fuzztest_target::crashing_fuzztest_target";
-    let normalized_test_name = test_target.replace("::", ".");
 
-    // 1. Retrieve the target binary path.
-    let host_triple = cargo_fuzztest::get_host_target_triple()
-        .expect("Failed to get host target triple for compilation");
-    let runner = cargo_fuzztest::FuzztestRunner::new(
-        host_triple,
-        cargo_fuzztest::CargoFuzzTestOptions::default(),
-    );
-    let mut compile_cmd = runner.build_compile_command();
-    compile_cmd.current_dir(&sample_crate_path).env("CARGO_TARGET_DIR", temp_target_dir.path());
-
-    let compile_output = compile_cmd.output().expect("Failed to execute cargo compilation command");
-    assert!(compile_output.status.success());
-    let json_stdout = String::from_utf8(compile_output.stdout)
-        .expect("Cargo compilation stdout must be valid UTF-8");
-    let target_binary_path = cargo_fuzztest::FuzztestRunner::parse_compiler_messages(&json_stdout)
-        .expect("Failed to parse target binary path from cargo JSON output");
-
-    let target_binary_str = target_binary_path.to_str().expect("Valid binary path string");
-    let binary_id = target_binary_str.strip_prefix('/').unwrap_or(target_binary_str);
-
-    // 2. Run Centipede to fuzz the target and populate the corpus database.
+    // 1. Run Centipede via cargo-fuzztest to fuzz the target and populate the corpus database.
     let mut cmd = setup_cargo_fuzztest_command(&sample_crate_path, temp_target_dir.path());
     cmd.arg(test_target)
         .arg("--fuzz-for=5s")
@@ -197,20 +176,22 @@
     let output = cmd.output().expect("Failed to run cargo-fuzztest to fuzz target");
     assert!(output.status.success());
 
-    // 3. Get list of crash ids
+    // 2. Get list of crash ids via cargo-fuzztest --list-crash-ids
     let list_temp_dir = TempDir::new().expect("Failed to create temporary list directory");
     let crash_ids_file = list_temp_dir.path().join("crash_ids.txt");
 
-    let list_args = [
-        format!("--binary={}", target_binary_path.display()),
-        format!("--fuzztest_binary_identifier={}", binary_id),
-        format!("--test_name={}", normalized_test_name),
-        format!("--fuzztest_corpus_database={}", temp_db_dir.path().display()),
-        "--list_crash_ids=1".to_string(),
-        format!("--list_crash_ids_file={}", crash_ids_file.display()),
-    ];
-    let list_args_refs: Vec<&str> = list_args.iter().map(|s| s.as_str()).collect();
-    run_centipede_with_args_expect_termination(&centipede_bin, &list_args_refs);
+    let mut cmd = setup_cargo_fuzztest_command(&sample_crate_path, temp_target_dir.path());
+    cmd.arg(test_target)
+        .arg("--list-crash-ids")
+        .arg("--list-crash-ids-file")
+        .arg(&crash_ids_file)
+        .arg("--corpus-db")
+        .arg(temp_db_dir.path())
+        .arg("--centipede-binary-path")
+        .arg(&centipede_bin);
+
+    let output = cmd.output().expect("Failed to run cargo-fuzztest to list crash IDs");
+    assert!(output.status.success());
 
     let crash_ids_contents =
         fs::read_to_string(&crash_ids_file).expect("Failed to read crash IDs file");
@@ -220,7 +201,7 @@
     expect_true!(!crash_ids.is_empty());
     let crash_id = crash_ids[0];
 
-    // 4. Run cargo-fuzztest CLI with --replay-id <crash_id> to verify it replays the crash.
+    // 3. Run cargo-fuzztest CLI with --replay-id <crash_id> to verify it replays the crash.
     let mut cmd = setup_cargo_fuzztest_command(&sample_crate_path, temp_target_dir.path());
     cmd.arg(test_target)
         .arg("--replay-id")
@@ -405,32 +386,62 @@
     );
 }
 
-fn run_centipede_with_args_expect_termination(centipede_bin: &str, args: &[&str]) -> String {
-    // Disable interference from Bazel environment variables.
-    let env_diff = [
-        "-TEST_DIAGNOSTICS_OUTPUT_DIR",
-        "-TEST_INFRASTRUCTURE_FAILURE_FILE",
-        "-TEST_LOGSPLITTER_OUTPUT_FILE",
-        "-TEST_PREMATURE_EXIT_FILE",
-        "-TEST_RANDOM_SEED",
-        "-TEST_RUN_NUMBER",
-        "-TEST_SHARD_INDEX",
-        "-TEST_SHARD_STATUS_FILE",
-        "-TEST_TOTAL_SHARDS",
-        "-TEST_UNDECLARED_OUTPUTS_ANNOTATIONS_DIR",
-        "-TEST_UNDECLARED_OUTPUTS_DIR",
-        "-TEST_WARNINGS_OUTPUT_FILE",
-        "-GTEST_OUTPUT",
-        "-XML_OUTPUT_FILE",
-    ];
-    let process = Command::new(centipede_bin)
-        .arg("--populate_binary_info=0")
-        .arg("--fork_server=0")
-        .arg("--persistent_mode=0")
-        .arg(format!("--env_diff_for_binaries={}", env_diff.join(",")))
-        .args(args)
-        .output()
-        .expect("Centipede should have executed");
+#[gtest]
+fn test_cargo_fuzztest_e2e_list_crash_ids() {
+    let centipede_bin = env::var("FUZZTEST_CENTIPEDE_BINARY_PATH")
+        .expect("FUZZTEST_CENTIPEDE_BINARY_PATH needs to be provided");
 
-    String::from_utf8_lossy(&process.stderr).to_string()
+    let sample_crate_path = get_sample_crate_path("another_sample_fuzz_crate");
+
+    let temp_target_dir = TempDir::new().expect("Failed to create temporary target directory");
+    fs::create_dir_all(&temp_target_dir).expect("Failed to create target directory");
+
+    let temp_db_dir = TempDir::new().expect("Failed to create temporary corpus db directory");
+    fs::create_dir_all(&temp_db_dir).expect("Failed to create corpus db directory");
+
+    let workdir_root_dir =
+        TempDir::new().expect("Failed to create temporary workdir_root directory");
+    fs::create_dir_all(&workdir_root_dir).expect("Failed to create workdir_root directory");
+
+    let test_target = "__fuzztest_mod__crashing_fuzztest_target::crashing_fuzztest_target";
+
+    // 1. Run Centipede via cargo-fuzztest to fuzz the target and populate the corpus database.
+    let mut cmd = setup_cargo_fuzztest_command(&sample_crate_path, temp_target_dir.path());
+    cmd.arg(test_target)
+        .arg("--fuzz-for=5s")
+        .env_remove("CENTIPEDE_BINARY_PATH")
+        .arg("--centipede-binary-path")
+        .arg(&centipede_bin)
+        .arg("--corpus-db")
+        .arg(temp_db_dir.path())
+        .arg("--workdir-root")
+        .arg(workdir_root_dir.path());
+    let output = cmd.output().expect("Failed to run cargo-fuzztest to fuzz target");
+    assert!(output.status.success());
+
+    // 2. Run cargo-fuzztest CLI with --list-crash-ids and --list-crash-ids-file to list crashes from corpus db.
+    let list_temp_dir = TempDir::new().expect("Failed to create temporary list directory");
+    let crash_ids_file = list_temp_dir.path().join("crash_ids.txt");
+
+    let mut cmd = setup_cargo_fuzztest_command(&sample_crate_path, temp_target_dir.path());
+    cmd.arg(test_target)
+        .arg("--list-crash-ids")
+        .arg("--list-crash-ids-file")
+        .arg(&crash_ids_file)
+        .arg("--corpus-db")
+        .arg(temp_db_dir.path())
+        .arg("--centipede-binary-path")
+        .arg(&centipede_bin);
+
+    let output = cmd.output().expect("Failed to run cargo-fuzztest in list-crash-ids mode");
+    assert!(output.status.success());
+
+    let crash_ids_contents =
+        fs::read_to_string(&crash_ids_file).expect("Failed to read crash IDs file");
+    let crash_ids: Vec<&str> =
+        crash_ids_contents.lines().filter(|line| !line.trim().is_empty()).collect();
+
+    // DISCUSS: We probably should be checking for something more specific, but what would be a
+    // good check here?
+    expect_true!(!crash_ids.is_empty());
 }
diff --git a/rust/cargo_fuzztest/tests/runner_test.rs b/rust/cargo_fuzztest/tests/runner_test.rs
index 57aaba8..b865b1d 100644
--- a/rust/cargo_fuzztest/tests/runner_test.rs
+++ b/rust/cargo_fuzztest/tests/runner_test.rs
@@ -317,3 +317,72 @@
     let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().to_string()).collect();
     expect_eq!(args, &["__fuzztest_mod__", "--list"]);
 }
+
+#[gtest]
+fn test_runner_build_run_command_with_list_crash_ids() {
+    let binary_path = get_sample_test_bin_path("sample_fuzz_crate");
+    let fuzztest_options = FuzzTestOptions {
+        list_crash_ids: true,
+        list_crash_ids_file: Some("/custom/path/to/crash_ids.txt".to_string()),
+        corpus_db: Some("/custom/path/to/corpus_db".to_string()),
+        ..Default::default()
+    };
+    let options = CargoFuzzTestOptions {
+        fuzztest_options,
+        centipede_binary_path: Some("/custom/path/to/centipede".to_string()),
+        ..Default::default()
+    };
+    let runner = FuzztestRunner::new("x86_64-unknown-linux-gnu".to_string(), options);
+    let cmd = runner.build_run_command(&binary_path).expect("valid run command");
+
+    let envs: Vec<(String, Option<String>)> = cmd
+        .get_envs()
+        .map(|(k, v)| (k.to_string_lossy().to_string(), v.map(|s| s.to_string_lossy().to_string())))
+        .collect();
+    expect_true!(envs.contains(&("FUZZTEST_LIST_CRASH_IDS".to_string(), Some("true".to_string()))));
+    expect_true!(envs.contains(&(
+        "FUZZTEST_LIST_CRASH_IDS_FILE".to_string(),
+        Some("/custom/path/to/crash_ids.txt".to_string())
+    )));
+    expect_true!(envs.contains(&(
+        "FUZZTEST_CORPUS_DB".to_string(),
+        Some("/custom/path/to/corpus_db".to_string())
+    )));
+    expect_true!(envs.contains(&(
+        "FUZZTEST_CENTIPEDE_BINARY_PATH".to_string(),
+        Some("/custom/path/to/centipede".to_string())
+    )));
+}
+
+#[gtest]
+fn test_execution_mode_list_crash_ids_missing_corpus_db_errors() {
+    let fuzztest_options = FuzzTestOptions {
+        list_crash_ids: true,
+        list_crash_ids_file: Some("/custom/path/to/crash_ids.txt".to_string()),
+        ..Default::default()
+    };
+    let options = CargoFuzzTestOptions {
+        fuzztest_options,
+        centipede_binary_path: Some("/custom/path/to/centipede".to_string()),
+        ..Default::default()
+    };
+    let result = options.execution_mode();
+    expect_true!(result.is_err());
+    let err_msg = result.unwrap_err().to_string();
+    expect_true!(err_msg.contains("`--corpus-db` needs to be specified"));
+}
+
+#[gtest]
+fn test_execution_mode_list_crash_ids_missing_centipede_binary_path_errors() {
+    let fuzztest_options = FuzzTestOptions {
+        list_crash_ids: true,
+        list_crash_ids_file: Some("/custom/path/to/crash_ids.txt".to_string()),
+        corpus_db: Some("/custom/path/to/corpus_db".to_string()),
+        ..Default::default()
+    };
+    let options = CargoFuzzTestOptions { fuzztest_options, ..Default::default() };
+    let result = options.execution_mode();
+    expect_true!(result.is_err());
+    let err_msg = result.unwrap_err().to_string();
+    expect_true!(err_msg.contains("`--centipede-binary-path` needs to be specified"));
+}
diff --git a/rust/e2e_tests/replay_test.rs b/rust/e2e_tests/replay_test.rs
index 767038a..d14d772 100644
--- a/rust/e2e_tests/replay_test.rs
+++ b/rust/e2e_tests/replay_test.rs
@@ -33,11 +33,8 @@
 #[gtest]
 fn replay_by_id_reproduces_panic(fixture: &EnvVars) {
     let test_name = "__fuzztest_mod__find_two_bugs_fuzz_test::find_two_bugs_fuzz_test";
-    let normalized_test_name = test_name.replace("::", ".");
 
     let target_binary_path = get_target_binary_path(fixture);
-    let target_binary_str = target_binary_path.to_str().expect("valid path string");
-    let binary_id = target_binary_str.strip_prefix('/').unwrap_or(target_binary_str);
 
     let db_dir = fixture.tmp_dir_path.join("replay_by_id_reproduces_panic").join("corpus_db");
     fs::create_dir_all(&db_dir).expect("Failed to create db directory");
@@ -57,19 +54,19 @@
         .status()
         .expect("Failed to spawn binary");
 
-    // 2. Retrieve the crash IDs from the corpus database using Centipede's --list_crash_ids flag.
+    // 2. Retrieve the crash IDs from the corpus database using FUZZTEST_LIST_CRASH_IDS.
     let crash_ids_file =
         fixture.tmp_dir_path.join("replay_by_id_reproduces_panic").join("crash_ids.txt");
-    let list_args = [
-        format!("--binary={}", target_binary_path.display()),
-        format!("--fuzztest_binary_identifier={}", binary_id),
-        format!("--test_name={}", normalized_test_name),
-        format!("--fuzztest_corpus_database={}", db_dir.display()),
-        "--list_crash_ids=1".to_string(),
-        format!("--list_crash_ids_file={}", crash_ids_file.display()),
-    ];
-    let list_args_refs: Vec<&str> = list_args.iter().map(|s| s.as_str()).collect();
-    test_utils::run_centipede_with_args_expect_termination(fixture, &list_args_refs);
+    let status = Command::new(&target_binary_path)
+        .arg(test_name)
+        .arg("--exact")
+        .env("FUZZTEST_LIST_CRASH_IDS", "true")
+        .env("FUZZTEST_LIST_CRASH_IDS_FILE", &crash_ids_file)
+        .env("FUZZTEST_CORPUS_DB", &db_dir)
+        .env("FUZZTEST_CENTIPEDE_BINARY_PATH", &fixture.centipede_path)
+        .status()
+        .expect("Failed to execute target binary to list crash IDs");
+    expect_true!(status.success());
 
     let crash_ids_contents =
         fs::read_to_string(&crash_ids_file).expect("Failed to read crash IDs file");
diff --git a/rust/options/src/lib.rs b/rust/options/src/lib.rs
index d072cef..070b00d 100644
--- a/rust/options/src/lib.rs
+++ b/rust/options/src/lib.rs
@@ -63,6 +63,24 @@
     #[arg(env = "FUZZTEST_JOBS", long)]
     pub jobs: Option<usize>,
 
+    /// List all crash IDs stored in the corpus database.
+    #[arg(
+        env = "FUZZTEST_LIST_CRASH_IDS",
+        long,
+        requires = "corpus_db",
+        requires = "list_crash_ids_file"
+    )]
+    pub list_crash_ids: bool,
+
+    /// The output file path where listed crash IDs will be written.
+    #[arg(
+        env = "FUZZTEST_LIST_CRASH_IDS_FILE",
+        long,
+        requires = "corpus_db",
+        requires = "list_crash_ids"
+    )]
+    pub list_crash_ids_file: Option<String>,
+
     /// The crash ID to be replayed from the corpus database.
     ///
     /// If set, `corpus_db` must also be specified. This mode retrieves the crashing input
@@ -110,6 +128,9 @@
     /// Replay corpus inputs for a specified duration.
     ReplayCorpus(ReplayCorpusOptions),
 
+    /// List crash IDs stored in the corpus database.
+    ListCrashIds(ListCrashIdsOptions),
+
     /// List all discovered fuzz tests without running them.
     /// Currently only supported via `cargo-fuzztest`.
     ListFuzzTests,
@@ -120,6 +141,16 @@
     ///
     /// This decouples raw environment/CLI option parsing from execution mode validation.
     pub fn from_fuzztest_options(options: &FuzzTestOptions) -> ExecutionMode {
+        if options.list_crash_ids {
+            return ExecutionMode::ListCrashIds(ListCrashIdsOptions {
+                // TODO(the-shank): If a crash-ids file is not provided, write to stdout.
+                list_crash_ids_file: options
+                    .list_crash_ids_file
+                    .clone()
+                    .expect("list_crash_ids_file should be set when list_crash_ids"),
+            });
+        }
+
         if let Some(replay_corpus_for) = options.replay_corpus_for {
             return ExecutionMode::ReplayCorpus(ReplayCorpusOptions {
                 replay_corpus_for,
@@ -184,6 +215,12 @@
     pub jobs: Option<usize>,
 }
 
+/// Mode-specific options for listing crash IDs from the database.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ListCrashIdsOptions {
+    pub list_crash_ids_file: String,
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -413,4 +450,101 @@
         expect_that!(options.corpus_db.as_deref(), eq(Some("/tmp/corpus_db")));
         expect_that!(options.time_budget_type, eq(TimeBudgetType::Total));
     }
+
+    #[gtest]
+    fn test_list_crash_ids_requires_corpus_db() {
+        // SAFETY: Testing environment parsing in single-threaded context.
+        unsafe {
+            std::env::set_var("FUZZTEST_LIST_CRASH_IDS", "true");
+            std::env::set_var("FUZZTEST_LIST_CRASH_IDS_FILE", "/tmp/crashes.txt");
+            std::env::remove_var("FUZZTEST_CORPUS_DB");
+        }
+
+        let result = FuzzTestOptions::try_parse_from(std::iter::empty::<OsString>());
+
+        // SAFETY: Cleaning up environment variables.
+        unsafe {
+            std::env::remove_var("FUZZTEST_LIST_CRASH_IDS");
+            std::env::remove_var("FUZZTEST_LIST_CRASH_IDS_FILE");
+        }
+
+        let err = result.expect_err("parsing should fail when corpus_db is missing");
+        expect_that!(err.kind(), eq(clap::error::ErrorKind::MissingRequiredArgument));
+    }
+
+    #[gtest]
+    fn test_list_crash_ids_requires_list_crash_ids_file() {
+        // SAFETY: Testing environment parsing in single-threaded context.
+        unsafe {
+            std::env::set_var("FUZZTEST_LIST_CRASH_IDS", "true");
+            std::env::set_var("FUZZTEST_CORPUS_DB", "/tmp/corpus_db");
+            std::env::remove_var("FUZZTEST_LIST_CRASH_IDS_FILE");
+        }
+
+        let result = FuzzTestOptions::try_parse_from(std::iter::empty::<OsString>());
+
+        // SAFETY: Cleaning up environment variables.
+        unsafe {
+            std::env::remove_var("FUZZTEST_LIST_CRASH_IDS");
+            std::env::remove_var("FUZZTEST_CORPUS_DB");
+        }
+
+        let err = result.expect_err("parsing should fail when list_crash_ids_file is missing");
+        expect_that!(err.kind(), eq(clap::error::ErrorKind::MissingRequiredArgument));
+    }
+
+    #[gtest]
+    fn test_list_crash_ids_file_requires_list_crash_ids() {
+        // SAFETY: Testing environment parsing in single-threaded context.
+        unsafe {
+            std::env::set_var("FUZZTEST_LIST_CRASH_IDS_FILE", "/tmp/crashes.txt");
+            std::env::set_var("FUZZTEST_CORPUS_DB", "/tmp/corpus_db");
+            std::env::remove_var("FUZZTEST_LIST_CRASH_IDS");
+        }
+
+        let result = FuzzTestOptions::try_parse_from(std::iter::empty::<OsString>());
+
+        // SAFETY: Cleaning up environment variables.
+        unsafe {
+            std::env::remove_var("FUZZTEST_LIST_CRASH_IDS_FILE");
+            std::env::remove_var("FUZZTEST_CORPUS_DB");
+        }
+
+        let err = result.expect_err("parsing should fail when list_crash_ids is missing");
+        expect_that!(err.kind(), eq(clap::error::ErrorKind::MissingRequiredArgument));
+    }
+
+    #[gtest]
+    fn test_list_crash_ids_with_corpus_db_and_file_succeeds() {
+        // SAFETY: Testing environment parsing in single-threaded context.
+        unsafe {
+            std::env::set_var("FUZZTEST_LIST_CRASH_IDS", "true");
+            std::env::set_var("FUZZTEST_LIST_CRASH_IDS_FILE", "/tmp/crashes.txt");
+            std::env::set_var("FUZZTEST_CORPUS_DB", "/tmp/corpus_db");
+        }
+
+        let result = FuzzTestOptions::try_parse_from(std::iter::empty::<OsString>());
+
+        // SAFETY: Cleaning up environment variables.
+        unsafe {
+            std::env::remove_var("FUZZTEST_LIST_CRASH_IDS");
+            std::env::remove_var("FUZZTEST_LIST_CRASH_IDS_FILE");
+            std::env::remove_var("FUZZTEST_CORPUS_DB");
+        }
+
+        let options = result.expect(
+            "parsing should succeed when list_crash_ids, list_crash_ids_file, and corpus_db are present",
+        );
+        expect_that!(options.list_crash_ids, eq(true));
+        expect_that!(options.list_crash_ids_file.as_deref(), eq(Some("/tmp/crashes.txt")));
+        expect_that!(options.corpus_db.as_deref(), eq(Some("/tmp/corpus_db")));
+
+        let mode = ExecutionMode::from_fuzztest_options(&options);
+        expect_that!(
+            mode,
+            eq(&ExecutionMode::ListCrashIds(ListCrashIdsOptions {
+                list_crash_ids_file: "/tmp/crashes.txt".to_string()
+            }))
+        );
+    }
 }
diff --git a/rust/src/options.rs b/rust/src/options.rs
index 2c8cbcf..0d79843 100644
--- a/rust/src/options.rs
+++ b/rust/src/options.rs
@@ -23,8 +23,8 @@
 use tempfile::{NamedTempFile, TempDir};
 
 pub use fuzztest_options::{
-    ExecutionMode, FuzzFor, FuzzOptions, FuzzTestOptions, ReplayCorpusOptions, ReplayCrashOptions,
-    TimeBudgetType,
+    ExecutionMode, FuzzFor, FuzzOptions, FuzzTestOptions, ListCrashIdsOptions, ReplayCorpusOptions,
+    ReplayCrashOptions, TimeBudgetType,
 };
 
 /// Returns a lazily-initialized static reference to the global `FuzzTestOptions`.
@@ -59,7 +59,8 @@
             ExecutionMode::SmokeTest => Ok(ExecutionAction::SmokeTest),
             ExecutionMode::Fuzz(_)
             | ExecutionMode::ReplayCrash(_)
-            | ExecutionMode::ReplayCorpus(_) => {
+            | ExecutionMode::ReplayCorpus(_)
+            | ExecutionMode::ListCrashIds(_) => {
                 let centipede_args =
                     CentipedeArgs::from_execution_mode(self, options, current_test_name, None)?
                         .context(
@@ -259,6 +260,10 @@
                     add_arg(format!("--j={jobs}"))?;
                 }
             }
+            ExecutionMode::ListCrashIds(list_opts) => {
+                add_arg("--list_crash_ids=true".to_string())?;
+                add_arg(format!("--list_crash_ids_file={}", list_opts.list_crash_ids_file))?;
+            }
             ExecutionMode::SmokeTest => unreachable!(),
             ExecutionMode::ListFuzzTests => unreachable!(),
         }
@@ -793,4 +798,28 @@
         expect_true!(args_str.contains(&"--fuzztest_corpus_database=/tmp/corpus_db"));
         expect_false!(args_str.iter().any(|s| s.starts_with("--stop_after=")));
     }
+
+    #[gtest]
+    fn test_determine_execution_action_list_crash_ids() {
+        let options = FuzzTestOptions {
+            list_crash_ids: true,
+            list_crash_ids_file: Some("/tmp/custom_crashes.txt".to_string()),
+            corpus_db: Some("/tmp/corpus_db".to_string()),
+            ..Default::default()
+        };
+        let action = determine_execution_action_internal(&options, "my_mod::my_test");
+
+        let ExecutionAction::Standalone(args) = action else {
+            panic!("Expected Standalone action for ListCrashIds");
+        };
+
+        let args_str: Vec<&str> =
+            args._c_strings.iter().map(|s| s.to_str().expect("invalid utf8")).collect();
+
+        expect_true!(args_str.contains(&"--list_crash_ids=true"));
+        expect_true!(args_str.contains(&"--list_crash_ids_file=/tmp/custom_crashes.txt"));
+        expect_true!(args_str.contains(&"--fuzztest_corpus_database=/tmp/corpus_db"));
+        expect_true!(args_str.iter().any(|s| s.starts_with("--workdir=")));
+        expect_true!(args_str.contains(&"--test_name=my_mod.my_test"));
+    }
 }