Skip to main content

crucible_fuzz_cli/
lib.rs

1mod templates;
2use templates::{generate_cargo_toml, generate_harness, generate_idl_readme};
3
4use std::env::current_dir;
5use std::fs::{self, create_dir_all};
6use std::path::{Path, PathBuf};
7use std::process::{Command, ExitStatus, Stdio};
8
9use anyhow::{bail, Context, Result};
10use clap::{Parser, Subcommand};
11use serde::{Deserialize, Serialize};
12
13// ============================================================================
14// CLI Definition
15// ============================================================================
16
17#[derive(Debug, Parser)]
18#[command(name = "crucible", about = "Solana smart contract fuzzing framework")]
19pub struct Cli {
20    /// Use this directory instead of ./fuzz/<program_name>/
21    #[arg(short = 'C', long = "harness-dir", global = true)]
22    pub harness_dir: Option<PathBuf>,
23    #[command(subcommand)]
24    pub command: FuzzCmd,
25}
26
27#[derive(Debug, Subcommand)]
28pub enum FuzzCmd {
29    /// Create a new fuzz harness for a program
30    Init {
31        /// Program name
32        program_name: String,
33    },
34    /// Run a fuzz test
35    Run {
36        /// Program name
37        program_name: String,
38        /// Test name (corresponds to a Cargo feature)
39        test_name: String,
40        /// Run a prebuilt harness binary directly
41        #[arg(long)]
42        binary_in: Option<PathBuf>,
43        /// Build in release mode
44        #[arg(long)]
45        release: bool,
46        /// Enable coverage reporting (single-core only)
47        #[arg(long)]
48        coverage: bool,
49        /// Stop after N seconds
50        #[arg(long)]
51        timeout: Option<u64>,
52        /// Run N parallel fuzzer workers
53        #[arg(long, short = 'j')]
54        cores: Option<usize>,
55        /// Load seed corpus from directory
56        #[arg(long)]
57        corpus_in: Option<PathBuf>,
58        /// Write corpus to directory
59        #[arg(long)]
60        corpus_out: Option<PathBuf>,
61        /// Custom crash output directory
62        #[arg(long)]
63        crashes_out: Option<PathBuf>,
64        /// Custom directory for .meta.json files (default: same as crashes_out)
65        #[arg(long)]
66        crashes_meta_out: Option<PathBuf>,
67        /// Replay a single crash/input file
68        #[arg(long)]
69        replay: Option<PathBuf>,
70        /// Validate setup without fuzzing
71        #[arg(long)]
72        dry_run: bool,
73        /// Random seed for reproducible fuzzing
74        #[arg(long)]
75        seed: Option<u64>,
76        /// Stop fuzzing on first crash
77        #[arg(long)]
78        stop_on_crash: bool,
79        /// Maximum number of actions per fuzzer iteration (default: 8 stateless, 100 stateful)
80        #[arg(long)]
81        max_actions: Option<usize>,
82        /// Disable SVM register tracing for higher throughput (no coverage guidance)
83        #[arg(long)]
84        no_tracing: bool,
85        /// Stateful fuzzing: single action per iteration with state pool
86        #[arg(long)]
87        stateful: bool,
88        /// Maximum state depth (action chain length) in stateful mode (default: 15)
89        #[arg(long)]
90        max_depth: Option<u32>,
91        /// State pool capacity in stateful mode (default: 256000)
92        #[arg(long)]
93        pool_size: Option<u64>,
94        /// Path to alternative program .so binary
95        #[arg(long)]
96        program_so: Option<PathBuf>,
97        /// Path to debug binary with DWARF symbols (for source-level coverage with --coverage)
98        #[arg(long)]
99        symbols: Option<PathBuf>,
100        /// Remote fuzzing operational mode (dry_run, explore, coverage, reproduce, corpus_merge)
101        #[arg(long)]
102        mode: Option<String>,
103        /// LCOV coverage output path
104        #[arg(long)]
105        lcov_out: Option<PathBuf>,
106        /// Show detailed performance stats (profiling, memory, pick/exec breakdown)
107        #[arg(long)]
108        stats: bool,
109    },
110    /// List available fuzz tests
111    List {
112        /// Program name (omit to list all)
113        program_name: Option<String>,
114        /// Use this directory instead of ./fuzz/<program_name>/
115        #[arg(short = 'C', long = "harness-dir")]
116        harness_dir: Option<PathBuf>,
117    },
118    /// View/replay crashes
119    Show {
120        /// Program name (use "." to auto-detect)
121        program_name: String,
122        /// Crash file to inspect
123        crash_file: Option<String>,
124        /// Actually replay the crash (requires compiled binary)
125        #[arg(long)]
126        replay: bool,
127        /// Batch-regenerate .meta.json for all crashes (requires --replay)
128        #[arg(long)]
129        regen: bool,
130        /// Custom crashes directory to read from
131        #[arg(long)]
132        crashes_dir: Option<PathBuf>,
133        /// Custom directory for .meta.json files (default: same as crashes_dir)
134        #[arg(long)]
135        crash_meta_dir: Option<PathBuf>,
136    },
137    /// Minimize a crash to smallest reproducing action sequence
138    Tmin {
139        /// Program name
140        program_name: String,
141        /// Test name
142        test_name: String,
143        /// Crash file to minimize (filename only, not full path)
144        crash_file: Option<String>,
145        /// Minimize all crashes for this test
146        #[arg(long)]
147        all: bool,
148        /// Build in release mode
149        #[arg(long)]
150        release: bool,
151        /// Custom directory for .meta.json files (default: same as crashes directory)
152        #[arg(long)]
153        crash_meta_dir: Option<PathBuf>,
154    },
155    /// Minimize corpus to smallest set preserving coverage
156    Cmin {
157        /// Program name
158        program_name: String,
159        /// Test name
160        test_name: String,
161        /// Input corpus directory (positional)
162        corpus_dir: Option<PathBuf>,
163        /// Input corpus directory (flag alternative)
164        #[arg(long)]
165        corpus_in: Option<PathBuf>,
166        /// Output directory (default: overwrite input)
167        #[arg(long)]
168        corpus_out: Option<PathBuf>,
169        /// Build in release mode
170        #[arg(long)]
171        release: bool,
172    },
173}
174
175// ============================================================================
176// Crash Metadata Types
177// ============================================================================
178
179#[derive(Clone, Debug, Serialize, Deserialize)]
180struct CrashMetadata {
181    test_name: String,
182    timestamp: String,
183    iteration: u64,
184    #[serde(skip_serializing_if = "Option::is_none")]
185    seed: Option<u64>,
186    actions: Vec<ActionRecord>,
187}
188
189#[derive(Clone, Debug, Serialize, Deserialize)]
190struct ActionRecord {
191    name: String,
192    params: serde_json::Value,
193    success: bool,
194    #[serde(skip_serializing_if = "Option::is_none")]
195    error_code: Option<u32>,
196}
197
198// ============================================================================
199// Main
200// ============================================================================
201
202pub fn run(cli: Cli) -> Result<()> {
203    match cli.command {
204        FuzzCmd::Init { program_name } => fuzz_init(&program_name, &cli.harness_dir),
205        FuzzCmd::Run {
206            program_name,
207            test_name,
208            binary_in,
209            release,
210            coverage,
211            timeout,
212            cores,
213            corpus_in,
214            corpus_out,
215            crashes_out,
216            crashes_meta_out,
217            replay,
218            dry_run,
219            seed,
220            stop_on_crash,
221            max_actions,
222            no_tracing,
223            stateful,
224            max_depth,
225            pool_size,
226            program_so,
227            symbols,
228            stats,
229            mode,
230            lcov_out,
231        } => fuzz_run(
232            &program_name,
233            &test_name,
234            binary_in,
235            &cli.harness_dir,
236            release,
237            coverage,
238            timeout,
239            corpus_in,
240            corpus_out,
241            crashes_out,
242            crashes_meta_out,
243            replay,
244            dry_run,
245            cores,
246            seed,
247            stop_on_crash,
248            max_actions,
249            no_tracing,
250            stateful,
251            max_depth,
252            pool_size,
253            program_so,
254            symbols,
255            stats,
256            mode,
257            lcov_out,
258        ),
259        FuzzCmd::List {
260            program_name,
261            harness_dir,
262        } => fuzz_list(program_name.as_deref(), &harness_dir.or(cli.harness_dir)),
263        FuzzCmd::Show {
264            program_name,
265            crash_file,
266            replay,
267            regen,
268            crashes_dir,
269            crash_meta_dir,
270        } => fuzz_show(
271            &program_name,
272            &cli.harness_dir,
273            crash_file.as_deref(),
274            replay,
275            regen,
276            crashes_dir.as_deref(),
277            crash_meta_dir.as_deref(),
278        ),
279        FuzzCmd::Tmin {
280            program_name,
281            test_name,
282            crash_file,
283            all,
284            release,
285            crash_meta_dir,
286        } => fuzz_tmin(
287            &program_name,
288            &test_name,
289            &cli.harness_dir,
290            crash_file.as_deref(),
291            all,
292            release,
293            crash_meta_dir.as_deref(),
294        ),
295        FuzzCmd::Cmin {
296            program_name,
297            test_name,
298            corpus_dir,
299            corpus_in,
300            corpus_out,
301            release,
302        } => {
303            let input = corpus_in.or(corpus_dir);
304            let input = input.as_deref().ok_or_else(|| {
305                anyhow::anyhow!(
306                    "Corpus directory required. Provide as positional arg or --corpus-in"
307                )
308            })?;
309            fuzz_cmin(
310                &program_name,
311                &test_name,
312                &cli.harness_dir,
313                input,
314                corpus_out.as_deref(),
315                release,
316            )
317        }
318    }
319}
320
321// ============================================================================
322// Init Command
323// ============================================================================
324
325fn fuzz_init(program_name: &str, harness_dir: &Option<PathBuf>) -> Result<()> {
326    let cwd = current_dir()?;
327
328    // If --harness-dir is given, use it directly as the harness root.
329    // Otherwise create ./fuzz/<program_name>/.
330    let fuzz_program_path = if let Some(dir) = harness_dir {
331        let dir = if dir.is_absolute() {
332            dir.clone()
333        } else {
334            cwd.join(dir)
335        };
336        dir
337    } else {
338        let fuzz_dir = cwd.join("fuzz");
339        // Create fuzz/ directory and .gitignore
340        if !fuzz_dir.exists() {
341            create_dir_all(&fuzz_dir)?;
342            fs::write(
343                fuzz_dir.join(".gitignore"),
344                "*/target/\n*/crashes/\n.fuzz-cache/\n",
345            )
346            .context("Failed to create .gitignore")?;
347        }
348        fuzz_dir.join(program_name)
349    };
350
351    if fuzz_program_path.exists() {
352        bail!("{} already exists", fuzz_program_path.display());
353    }
354
355    let src_dir = fuzz_program_path.join("src");
356    create_dir_all(&src_dir)?;
357
358    let idls_dir = fuzz_program_path.join("idls");
359    create_dir_all(&idls_dir)?;
360
361    fs::write(
362        fuzz_program_path.join("Cargo.toml"),
363        generate_cargo_toml(program_name),
364    )
365    .context("Failed to write Cargo.toml")?;
366
367    fs::write(
368        fuzz_program_path.join("rust-toolchain.toml"),
369        "[toolchain]\nchannel = \"stable\"\n",
370    )
371    .context("Failed to write rust-toolchain.toml")?;
372
373    fs::write(src_dir.join("main.rs"), generate_harness(program_name))
374        .context("Failed to write harness")?;
375
376    fs::write(
377        idls_dir.join("README.md"),
378        generate_idl_readme(program_name),
379    )
380    .context("Failed to write IDL README")?;
381
382    let harness_display = fuzz_program_path.display();
383    println!("\nCreated fuzz harness at: {}/", harness_display);
384    println!("\nNext steps:");
385    println!(
386        "  1. Copy your IDL to: {}/idls/{}.json",
387        harness_display, program_name
388    );
389    println!("     (Run `anchor idl convert` if using legacy IDL format)");
390    println!(
391        "  2. Ensure program binary exists at: target/deploy/{}.so",
392        program_name
393    );
394    println!(
395        "  3. Implement action_* methods in: {}/src/main.rs",
396        harness_display
397    );
398    println!(
399        "  4. Run: crucible run {} invariant_test --release",
400        program_name
401    );
402
403    Ok(())
404}
405
406// ============================================================================
407// Run Command
408// ============================================================================
409
410fn find_first_file_in_dir(dir: &str) -> Option<PathBuf> {
411    let path = Path::new(dir);
412    if !path.is_dir() {
413        return None;
414    }
415    let mut entries: Vec<_> = fs::read_dir(path)
416        .ok()?
417        .filter_map(|e| e.ok())
418        .filter(|e| e.path().is_file())
419        .collect();
420    entries.sort_by_key(|e| e.file_name());
421    entries.first().map(|e| e.path())
422}
423
424fn fuzz_run(
425    program_name: &str,
426    test_name: &str,
427    binary_in: Option<PathBuf>,
428    harness_dir: &Option<PathBuf>,
429    release: bool,
430    coverage: bool,
431    timeout: Option<u64>,
432    corpus_in: Option<PathBuf>,
433    corpus_out: Option<PathBuf>,
434    crashes_out: Option<PathBuf>,
435    meta_out: Option<PathBuf>,
436    replay: Option<PathBuf>,
437    dry_run: bool,
438    cores: Option<usize>,
439    seed: Option<u64>,
440    stop_on_crash: bool,
441    max_actions: Option<usize>,
442    no_tracing: bool,
443    stateful: bool,
444    max_depth: Option<u32>,
445    pool_size: Option<u64>,
446    program_so: Option<PathBuf>,
447    symbols: Option<PathBuf>,
448    stats: bool,
449    mode: Option<String>,
450    lcov_out: Option<PathBuf>,
451) -> Result<()> {
452    let remote = mode.is_some();
453
454    // Translate --mode into equivalent flags.
455    // All path defaults are relative to cwd (the remote driver's working directory).
456    let mut coverage = coverage;
457    let mut corpus_in = corpus_in;
458    let mut corpus_out = corpus_out;
459    let mut crashes_out = crashes_out;
460    let mut replay = replay;
461    let mut dry_run = dry_run;
462    let mut stop_on_crash = stop_on_crash;
463
464    if let Some(ref mode_str) = mode {
465        match mode_str.as_str() {
466            "dry_run" => {
467                dry_run = true;
468            }
469            "explore" => {
470                if corpus_out.is_none() {
471                    corpus_out = Some("./corpus".into());
472                }
473                if corpus_in.is_none() {
474                    corpus_in = Some("./corpus".into());
475                }
476                if crashes_out.is_none() {
477                    crashes_out = Some("./output".into());
478                }
479                stop_on_crash = true;
480            }
481            "reproduce" => {
482                if replay.is_none() {
483                    replay = find_first_file_in_dir("./input");
484                }
485            }
486            "coverage" => {
487                coverage = true;
488                if corpus_in.is_none() {
489                    corpus_in = Some("./corpus".into());
490                }
491            }
492            "corpus_merge" => {
493                // Read from ./corpus, write minimized corpus to ./output via FUZZ_CMIN
494                if corpus_in.is_none() {
495                    corpus_in = Some("./corpus".into());
496                }
497                if corpus_out.is_none() {
498                    corpus_out = Some("./output".into());
499                }
500            }
501            other => {
502                bail!("Unknown mode: {}", other);
503            }
504        }
505    }
506
507    if coverage && cores.is_some() {
508        bail!(
509            "--coverage cannot be combined with -j/--cores. LCOV collection is single-core only \
510             (the multi-core launcher path uses a shared-memory server that bypasses LCOV output, \
511             and on macOS the served-shmem unix socket bind is blocked by the app sandbox). \
512             Re-run without -j to collect coverage."
513        );
514    }
515
516    let cwd = current_dir()?;
517    let fuzz_dir = resolve_fuzz_dir(&harness_dir.clone().unwrap_or(cwd.clone()), program_name)?;
518
519    let mut cmd = if let Some(binary_path) = binary_in {
520        let binary_path = resolve_path(&cwd, &binary_path);
521        if !binary_path.exists() {
522            bail!("Harness binary not found: {}", binary_path.display());
523        }
524        println!("[FUZZ] Using prebuilt harness: {}", binary_path.display());
525        let mut cmd = Command::new(binary_path);
526        cmd.current_dir(&fuzz_dir);
527        cmd
528    } else {
529        try_cargo_build(&fuzz_dir, release, test_name)?;
530        let profile = if release { "release" } else { "debug" };
531        let binary_path = find_fuzz_binary(&fuzz_dir, profile)?;
532        let mut cmd = Command::new(&binary_path);
533        cmd.current_dir(&fuzz_dir);
534        cmd
535    };
536
537    println!(
538        r#"
539  ______
540  ╲    ╱   ___ ___ _   _  ___ ___ ___ _    ___
541   ╲╱╲╱   / __| _ \ | | |/ __|_ _| _ ) |  | __|
542   ╱╲╱╲  | (__|   / |_| | (__ | || _ \ |__| _|
543  ╱    ╲  \___|_|_\\___/ \___|___|___/____|___|
544  ▔▔▔▔▔▔
545"#
546    );
547
548    if let Some(timeout_secs) = timeout {
549        cmd.env("FUZZ_TIMEOUT_SECS", timeout_secs.to_string());
550        println!("[FUZZ] Running with {}s timeout", timeout_secs);
551    }
552
553    // corpus_in: always pass if non-empty; in remote mode also pass if dir doesn't exist yet
554    // (the remote driver may populate it after the CLI check).
555    if let Some(ref corpus_in_path) = corpus_in {
556        let abs_path = resolve_path(&cwd, corpus_in_path);
557        let has_inputs = abs_path.exists()
558            && fs::read_dir(&abs_path)
559                .map(|mut d| d.any(|e| e.is_ok()))
560                .unwrap_or(false);
561        if has_inputs {
562            cmd.env("FUZZ_CORPUS_IN", &abs_path);
563            println!("[FUZZ] Loading corpus from: {}", corpus_in_path.display());
564        } else if remote {
565            cmd.env("FUZZ_CORPUS_IN", &abs_path);
566            println!(
567                "[FUZZ] Corpus directory passed to harness: {}",
568                abs_path.display()
569            );
570        } else if abs_path.exists() {
571            println!(
572                "[FUZZ] Corpus directory is empty, skipping: {}",
573                corpus_in_path.display()
574            );
575        } else {
576            println!(
577                "[FUZZ] Corpus directory does not exist, skipping: {}",
578                corpus_in_path.display()
579            );
580        }
581    }
582
583    if let Some(ref corpus_out_path) = corpus_out {
584        let abs_path = resolve_path(&cwd, corpus_out_path);
585        cmd.env("FUZZ_CORPUS_OUT", &abs_path);
586        println!("[FUZZ] Writing corpus to: {}", corpus_out_path.display());
587    }
588
589    // crashes_out defaults:
590    //   remote mode → ./output  (already set above per-mode)
591    //   local       → ./crashes/<test_name>
592    let crashes_abs_path = if let Some(ref crashes_path) = crashes_out {
593        resolve_path(&cwd, crashes_path)
594    } else if remote {
595        fuzz_dir.join("crashes")
596    } else {
597        fuzz_dir.join("crashes").join(test_name)
598    };
599    cmd.env("FUZZ_CRASHES_DIR", &crashes_abs_path);
600    println!("[FUZZ] Crashes directory: {}", crashes_abs_path.display());
601    if let Some(ref meta_path) = meta_out {
602        let meta_abs_path = resolve_path(&cwd, meta_path);
603        cmd.env("FUZZ_META_DIR", &meta_abs_path);
604        println!("[FUZZ] Meta directory: {}", meta_abs_path.display());
605    }
606
607    if let Some(ref replay_path) = replay {
608        let abs_path = resolve_path(&cwd, replay_path);
609        let resolved_path = if abs_path.exists() {
610            abs_path
611        } else {
612            // Search crashes directory for this crash name
613            match find_crash_file(
614                &crashes_abs_path,
615                program_name,
616                &replay_path.to_string_lossy(),
617            ) {
618                Ok((found_path, _)) => found_path,
619                Err(_) => abs_path,
620            }
621        };
622        cmd.env("FUZZ_INPUT_FILE", &resolved_path);
623        println!("[FUZZ] Replaying input: {}", resolved_path.display());
624    }
625
626    if dry_run {
627        cmd.env("FUZZ_DRY_RUN", "1");
628        println!("[FUZZ] Dry-run mode: validating setup");
629    }
630
631    if let Some(num_cores) = cores {
632        cmd.env("FUZZ_CORES", num_cores.to_string());
633        println!("[FUZZ] Multi-core mode: {} parallel workers", num_cores);
634    }
635
636    if let Some(seed_val) = seed {
637        cmd.env("FUZZ_SEED", seed_val.to_string());
638        println!("[FUZZ] Using seed: {}", seed_val);
639    }
640
641    if stop_on_crash {
642        cmd.env("FUZZ_STOP_ON_CRASH", "1");
643        println!("[FUZZ] Stop-on-crash enabled");
644    }
645
646    if no_tracing {
647        cmd.env("FUZZ_NO_TRACING", "1");
648        println!("[FUZZ] Tracing disabled: no coverage guidance, maximum throughput");
649    }
650
651    if stats {
652        cmd.env("FUZZ_STATS", "1");
653    }
654
655    if stateful {
656        cmd.env("FUZZ_STATEFUL", "1");
657        println!("[FUZZ] Stateful mode: single action per iteration with state pool");
658    }
659
660    if let Some(depth) = max_depth {
661        cmd.env("FUZZ_MAX_DEPTH", depth.to_string());
662        println!("[FUZZ] Max state depth: {}", depth);
663    }
664
665    if let Some(size) = pool_size {
666        cmd.env("FUZZ_STATE_POOL_SIZE", size.to_string());
667        println!("[FUZZ] State pool size: {}", size);
668    }
669
670    if let Some(ref program_so_path) = program_so {
671        let abs_path = resolve_path(&cwd, program_so_path);
672        cmd.env("FUZZ_PROGRAM_SO", &abs_path);
673        println!("[FUZZ] Program binary override: {}", abs_path.display());
674    }
675
676    if let Some(ref symbols_path) = symbols {
677        let abs_path = resolve_path(&cwd, symbols_path);
678        let canonical = abs_path
679            .canonicalize()
680            .context("Debug symbols file not found")?;
681        cmd.env("FUZZ_SYMBOLS", &canonical);
682        println!("[FUZZ] Debug symbols: {}", canonical.display());
683    }
684
685    // Default: 8 for stateless, 100 for stateful.
686    let max_actions = max_actions.unwrap_or(if stateful { 100 } else { 8 });
687    cmd.env("FUZZ_MAX_ACTIONS", max_actions.to_string());
688    println!("[FUZZ] Max actions per iteration: {}", max_actions);
689
690    if mode.as_deref() == Some("corpus_merge") {
691        cmd.env("FUZZ_CMIN", "1");
692        println!("[FUZZ] Corpus merge mode: minimizing corpus");
693    } else if coverage {
694        let corpus_in_abs = corpus_in
695            .as_ref()
696            .map(|p| resolve_path(&cwd, p))
697            .ok_or_else(|| anyhow::anyhow!("--coverage requires --corpus-in"))?;
698        cmd.env("FUZZ_COVERAGE", "1");
699        cmd.env("FUZZ_CORPUS_IN", &corpus_in_abs);
700        // coverage-only (no fuzzing) when no timeout/dry-run/replay override
701        if timeout.is_none() && !dry_run && replay.is_none() {
702            cmd.env("FUZZ_COVERAGE_ONLY", "1");
703            println!("[FUZZ] Coverage-only mode: generating coverage from corpus");
704        }
705    }
706
707    // LCOV output path:
708    //   explicit --lcov-out  → that path
709    //   remote coverage mode → ./output/coverage.lcov
710    //   local coverage       → ./coverage/coverage.lcov
711    if coverage || lcov_out.is_some() {
712        let lcov_path = if let Some(ref p) = lcov_out {
713            resolve_path(&cwd, p)
714        } else if remote {
715            let dir = resolve_path(&cwd, &PathBuf::from("./output"));
716            let _ = fs::create_dir_all(&dir);
717            dir.join("coverage.lcov")
718        } else {
719            let dir = resolve_path(&cwd, &PathBuf::from("./coverage"));
720            let _ = fs::create_dir_all(&dir);
721            dir.join("coverage.lcov")
722        };
723        cmd.env("FUZZ_COVERAGE_OUT", &lcov_path);
724        println!("[FUZZ] LCOV output: {}", lcov_path.display());
725    }
726
727    let status = cmd.status().context("Failed to run fuzz binary")?;
728    if !status.success() {
729        // In reproduce mode, exit code 1 means crash reproduced (not a failure).
730        if mode.as_deref() == Some("reproduce") {
731            std::process::exit(status.code().unwrap_or(1));
732        }
733        match status.code() {
734            Some(code) => bail!("Fuzz command failed with exit code {}", code),
735            None => bail!("Fuzz command killed by signal"),
736        }
737    }
738
739    Ok(())
740}
741
742// ============================================================================
743// List Command
744// ============================================================================
745
746fn fuzz_list(program_name: Option<&str>, harness_dir: &Option<PathBuf>) -> Result<()> {
747    let cwd = current_dir()?;
748
749    match program_name {
750        Some(name) => {
751            let fuzz_dir = resolve_fuzz_dir(&harness_dir.clone().unwrap_or(cwd.clone()), name)?;
752            list_program_tests(&fuzz_dir, name)?;
753        }
754        None => {
755            // If a harness_dir is given, list just that one harness.
756            if let Some(dir) = harness_dir {
757                let dir = if dir.is_absolute() {
758                    dir.clone()
759                } else {
760                    cwd.join(dir)
761                };
762                let name = dir
763                    .file_name()
764                    .and_then(|n| n.to_str())
765                    .unwrap_or("unknown");
766                return list_program_tests(&dir, name);
767            }
768
769            let fuzz_root = cwd.join("fuzz");
770            if !fuzz_root.exists() {
771                println!("No fuzz/ directory found. Run `crucible init <program>` to create one.");
772                return Ok(());
773            }
774
775            let mut found = false;
776            for entry in fs::read_dir(&fuzz_root)? {
777                let entry = entry?;
778                let path = entry.path();
779                if path.is_dir() && path.join("Cargo.toml").exists() {
780                    let name = path
781                        .file_name()
782                        .and_then(|n| n.to_str())
783                        .unwrap_or("unknown");
784                    found = true;
785                    list_program_tests(&path, name)?;
786                }
787            }
788
789            if !found {
790                println!("No fuzz harnesses found in fuzz/ directory.");
791            }
792        }
793    }
794
795    Ok(())
796}
797
798fn list_program_tests(fuzz_dir: &Path, program_name: &str) -> Result<()> {
799    let cargo_toml_path = fuzz_dir.join("Cargo.toml");
800    if !cargo_toml_path.exists() {
801        bail!("Cargo.toml not found at {}", cargo_toml_path.display());
802    }
803
804    let content = fs::read_to_string(&cargo_toml_path).context("Failed to read Cargo.toml")?;
805
806    let mut in_features = false;
807    let mut tests = Vec::new();
808
809    for line in content.lines() {
810        let trimmed = line.trim();
811        if trimmed == "[features]" {
812            in_features = true;
813            continue;
814        }
815        if in_features && trimmed.starts_with('[') && !trimmed.starts_with("[features") {
816            break;
817        }
818        if in_features && !trimmed.is_empty() && !trimmed.starts_with('#') {
819            if let Some(eq_pos) = trimmed.find('=') {
820                let feature_name = trimmed[..eq_pos].trim();
821                if !feature_name.is_empty()
822                    && feature_name != "default"
823                    && feature_name != "fuzz_single"
824                {
825                    tests.push(feature_name.to_string());
826                }
827            }
828        }
829    }
830
831    println!("\n=== {} ===", program_name);
832    if tests.is_empty() {
833        println!("  No fuzz tests found (add features to Cargo.toml)");
834    } else {
835        for test in &tests {
836            println!("  - {}", test);
837        }
838        println!();
839        println!(
840            "Run with: crucible run {} <test_name> --release",
841            program_name
842        );
843    }
844
845    Ok(())
846}
847
848// ============================================================================
849// Show Command
850// ============================================================================
851
852fn fuzz_show(
853    program_name: &str,
854    harness_dir: &Option<PathBuf>,
855    crash_file: Option<&str>,
856    replay: bool,
857    regen: bool,
858    custom_crashes_dir: Option<&Path>,
859    custom_meta_dir: Option<&Path>,
860) -> Result<()> {
861    if regen && !replay {
862        bail!(
863            "--regen requires --replay. Usage: crucible show {} --replay --regen",
864            program_name
865        );
866    }
867
868    let cwd = current_dir()?;
869
870    let (fuzz_dir, display_name) = if program_name == "." {
871        if cwd.join("Cargo.toml").exists() && cwd.join("src").exists() {
872            let name = cwd
873                .file_name()
874                .and_then(|n| n.to_str())
875                .unwrap_or("unknown")
876                .to_string();
877            (cwd.clone(), name)
878        } else {
879            bail!("Cannot auto-detect fuzz harness. Run from within fuzz harness directory or specify program name.");
880        }
881    } else {
882        let fuzz_dir = resolve_fuzz_dir(&harness_dir.clone().unwrap_or(cwd.clone()), program_name)?;
883        let display_name = fuzz_dir
884            .file_name()
885            .and_then(|n| n.to_str())
886            .unwrap_or(program_name)
887            .to_string();
888        (fuzz_dir, display_name)
889    };
890
891    match crash_file {
892        None if replay && regen => regen_crashes(&fuzz_dir, custom_crashes_dir, custom_meta_dir),
893        None => list_crashes(
894            &fuzz_dir,
895            &display_name,
896            custom_crashes_dir,
897            custom_meta_dir,
898        ),
899        Some(crash_name) if !replay => show_crash_metadata(
900            &fuzz_dir,
901            &display_name,
902            crash_name,
903            custom_crashes_dir,
904            custom_meta_dir,
905        ),
906        Some(crash_name) => replay_crash(&fuzz_dir, &display_name, crash_name, custom_crashes_dir),
907    }
908}
909
910fn list_crashes(
911    fuzz_dir: &Path,
912    program_name: &str,
913    custom_crashes_dir: Option<&Path>,
914    custom_meta_dir: Option<&Path>,
915) -> Result<()> {
916    let crashes_dir = resolve_crashes_dir(fuzz_dir, custom_crashes_dir);
917    let meta_dir = custom_meta_dir
918        .map(|p| p.to_path_buf())
919        .unwrap_or_else(|| crashes_dir.clone());
920    if !crashes_dir.exists() {
921        println!("No crashes directory found at: {}", crashes_dir.display());
922        println!("Run the fuzzer first to generate crashes.");
923        return Ok(());
924    }
925
926    // Collect crashes with metadata (.meta.json) and raw crash files (LibAFL-only)
927    let mut crashes_with_meta: Vec<(String, String, CrashMetadata)> = Vec::new();
928    let mut raw_crashes: Vec<(String, String, u64)> = Vec::new(); // (crash_id, test_name, file_size)
929
930    for entry in fs::read_dir(&crashes_dir)? {
931        let entry = entry?;
932        let test_dir = entry.path();
933        if !test_dir.is_dir() {
934            continue;
935        }
936
937        let test_name = test_dir
938            .file_name()
939            .and_then(|n| n.to_str())
940            .unwrap_or("unknown")
941            .to_string();
942
943        // First pass: collect .meta.json crash IDs
944        let meta_test_dir = meta_dir.join(&test_name);
945        let mut meta_ids = std::collections::HashSet::new();
946        for file_entry in fs::read_dir(if meta_test_dir.is_dir() {
947            &meta_test_dir
948        } else {
949            &test_dir
950        })? {
951            let file_entry = file_entry?;
952            let file_path = file_entry.path();
953            let filename = file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
954            if filename.ends_with(".meta.json") {
955                if let Ok(content) = fs::read_to_string(&file_path) {
956                    if let Ok(meta) = serde_json::from_str::<CrashMetadata>(&content) {
957                        let crash_id = filename
958                            .strip_suffix(".meta.json")
959                            .unwrap_or(filename)
960                            .to_string();
961                        meta_ids.insert(crash_id.clone());
962                        crashes_with_meta.push((crash_id, test_name.clone(), meta));
963                    }
964                }
965            }
966        }
967
968        // Second pass: collect raw crash files without .meta.json
969        for file_entry in fs::read_dir(&test_dir)? {
970            let file_entry = file_entry?;
971            let file_path = file_entry.path();
972            let filename = file_path
973                .file_name()
974                .and_then(|n| n.to_str())
975                .unwrap_or("")
976                .to_string();
977            if filename.starts_with('.')
978                || filename.ends_with(".metadata")
979                || filename.ends_with(".meta.json")
980            {
981                continue;
982            }
983            if !file_path.is_file() {
984                continue;
985            }
986            // Skip if we already have metadata for this crash
987            if meta_ids.contains(&filename) {
988                continue;
989            }
990            let file_size = file_path.metadata().map(|m| m.len()).unwrap_or(0);
991            raw_crashes.push((filename, test_name.clone(), file_size));
992        }
993    }
994
995    // Also scan flat layout: files directly in crashes_dir (no test subdirectory)
996    {
997        let mut flat_meta_ids = std::collections::HashSet::new();
998        for file_entry in fs::read_dir(&meta_dir)? {
999            let file_entry = file_entry?;
1000            let file_path = file_entry.path();
1001            if !file_path.is_file() {
1002                continue;
1003            }
1004            let filename = file_path
1005                .file_name()
1006                .and_then(|n| n.to_str())
1007                .unwrap_or("")
1008                .to_string();
1009            if filename.ends_with(".meta.json") {
1010                if let Ok(content) = fs::read_to_string(&file_path) {
1011                    if let Ok(meta) = serde_json::from_str::<CrashMetadata>(&content) {
1012                        let crash_id = filename
1013                            .strip_suffix(".meta.json")
1014                            .unwrap_or(&filename)
1015                            .to_string();
1016                        flat_meta_ids.insert(crash_id.clone());
1017                        crashes_with_meta.push((crash_id, "(flat)".to_string(), meta));
1018                    }
1019                }
1020            }
1021        }
1022        for file_entry in fs::read_dir(&crashes_dir)? {
1023            let file_entry = file_entry?;
1024            let file_path = file_entry.path();
1025            if !file_path.is_file() {
1026                continue;
1027            }
1028            let filename = file_path
1029                .file_name()
1030                .and_then(|n| n.to_str())
1031                .unwrap_or("")
1032                .to_string();
1033            if filename.starts_with('.')
1034                || filename.ends_with(".metadata")
1035                || filename.ends_with(".meta.json")
1036            {
1037                continue;
1038            }
1039            if file_path.is_dir() {
1040                continue;
1041            }
1042            if flat_meta_ids.contains(&filename) {
1043                continue;
1044            }
1045            // Skip if it's a subdirectory name we already scanned
1046            if crashes_dir.join(&filename).is_dir() {
1047                continue;
1048            }
1049            let file_size = file_path.metadata().map(|m| m.len()).unwrap_or(0);
1050            raw_crashes.push((filename, "(flat)".to_string(), file_size));
1051        }
1052    }
1053
1054    // Deduplicate: remove raw crashes whose content matches a crash with metadata
1055    {
1056        use std::collections::HashSet;
1057        use std::hash::{Hash, Hasher};
1058
1059        let mut meta_content_hashes: HashSet<u64> = HashSet::new();
1060        for (crash_id, test_name, _meta) in &crashes_with_meta {
1061            let crash_path = crashes_dir.join(test_name).join(crash_id);
1062            if let Ok(bytes) = fs::read(&crash_path) {
1063                let mut hasher = std::collections::hash_map::DefaultHasher::new();
1064                bytes.hash(&mut hasher);
1065                meta_content_hashes.insert(hasher.finish());
1066            }
1067        }
1068
1069        raw_crashes.retain(|(filename, test_name, _size)| {
1070            let crash_path = crashes_dir.join(test_name).join(filename);
1071            if let Ok(bytes) = fs::read(&crash_path) {
1072                let mut hasher = std::collections::hash_map::DefaultHasher::new();
1073                bytes.hash(&mut hasher);
1074                !meta_content_hashes.contains(&hasher.finish())
1075            } else {
1076                true // keep if we can't read it
1077            }
1078        });
1079    }
1080
1081    if crashes_with_meta.is_empty() && raw_crashes.is_empty() {
1082        println!(
1083            "No crashes found for {} in: {}",
1084            program_name,
1085            crashes_dir.display()
1086        );
1087        return Ok(());
1088    }
1089
1090    let total = crashes_with_meta.len() + raw_crashes.len();
1091    println!("\n=== Crashes for {} ({} total) ===\n", program_name, total);
1092
1093    // Show crashes with metadata first
1094    if !crashes_with_meta.is_empty() {
1095        crashes_with_meta.sort_by(|a, b| b.2.timestamp.cmp(&a.2.timestamp));
1096        for (i, (crash_id, test_name, meta)) in crashes_with_meta.iter().enumerate() {
1097            println!(
1098                "  {}. {} ({}, test: {}, {} actions)",
1099                i + 1,
1100                crash_id,
1101                meta.timestamp,
1102                test_name,
1103                meta.actions.len()
1104            );
1105        }
1106    }
1107
1108    // Show raw crashes (no metadata)
1109    if !raw_crashes.is_empty() {
1110        raw_crashes.sort_by(|a, b| a.0.cmp(&b.0));
1111        let offset = crashes_with_meta.len();
1112        for (i, (crash_id, test_name, size)) in raw_crashes.iter().enumerate() {
1113            println!(
1114                "  {}. {} (test: {}, {} bytes, no metadata)",
1115                offset + i + 1,
1116                crash_id,
1117                test_name,
1118                size
1119            );
1120        }
1121    }
1122
1123    println!();
1124    println!("To view a crash: crucible show {} <crash_id>", program_name);
1125    println!(
1126        "To replay a crash: crucible show {} <crash_id> --replay",
1127        program_name
1128    );
1129
1130    Ok(())
1131}
1132
1133fn show_crash_metadata(
1134    fuzz_dir: &Path,
1135    program_name: &str,
1136    crash_name: &str,
1137    custom_crashes_dir: Option<&Path>,
1138    custom_meta_dir: Option<&Path>,
1139) -> Result<()> {
1140    let crashes_dir = resolve_crashes_dir(fuzz_dir, custom_crashes_dir);
1141    let meta_dir = custom_meta_dir
1142        .map(|p| p.to_path_buf())
1143        .unwrap_or_else(|| crashes_dir.clone());
1144
1145    // Search for .meta.json and/or raw crash file
1146    let mut meta_path = None;
1147    let mut crash_binary_path = None;
1148    if let Ok(entries) = fs::read_dir(&crashes_dir) {
1149        for entry in entries.flatten() {
1150            let test_dir = entry.path();
1151            if !test_dir.is_dir() {
1152                continue;
1153            }
1154            let test_name = test_dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
1155            let meta_candidate = meta_dir
1156                .join(test_name)
1157                .join(format!("{}.meta.json", crash_name));
1158            if meta_candidate.exists() {
1159                meta_path = Some(meta_candidate);
1160            }
1161            let binary_candidate = test_dir.join(crash_name);
1162            if binary_candidate.exists() && binary_candidate.is_file() {
1163                crash_binary_path = Some(binary_candidate);
1164            }
1165        }
1166    }
1167
1168    // Also check flat layout: files directly in crashes_dir / meta_dir
1169    if meta_path.is_none() {
1170        let flat_meta = meta_dir.join(format!("{}.meta.json", crash_name));
1171        if flat_meta.exists() {
1172            meta_path = Some(flat_meta);
1173        }
1174    }
1175    if crash_binary_path.is_none() {
1176        let flat_binary = crashes_dir.join(crash_name);
1177        if flat_binary.exists() && flat_binary.is_file() {
1178            crash_binary_path = Some(flat_binary);
1179        }
1180    }
1181
1182    // If we have metadata, show the rich view
1183    if let Some(meta_path) = meta_path {
1184        let content = fs::read_to_string(&meta_path).context("Failed to read crash metadata")?;
1185        let meta: CrashMetadata =
1186            serde_json::from_str(&content).context("Failed to parse crash metadata")?;
1187
1188        println!("\n=== Crash: {} ===", crash_name);
1189        println!("Test: {}", meta.test_name);
1190        println!("Timestamp: {}", meta.timestamp);
1191        println!("Iteration: {}", meta.iteration);
1192        if let Some(seed) = meta.seed {
1193            println!("Seed: {}", seed);
1194        }
1195
1196        println!("\n=== Action Sequence ({} actions) ===", meta.actions.len());
1197        for (i, action) in meta.actions.iter().enumerate() {
1198            let params_str = if let serde_json::Value::Object(map) = &action.params {
1199                map.iter()
1200                    .map(|(k, v)| format!("{}={}", k, format_json_compact(v)))
1201                    .collect::<Vec<_>>()
1202                    .join(", ")
1203            } else {
1204                String::new()
1205            };
1206
1207            let status = if action.success {
1208                "OK".to_string()
1209            } else if let Some(code) = action.error_code {
1210                format!("FAIL({})", code)
1211            } else {
1212                "FAIL".to_string()
1213            };
1214
1215            if params_str.is_empty() {
1216                println!("  {}. {} -> {}", i + 1, action.name, status);
1217            } else {
1218                println!("  {}. {}({}) -> {}", i + 1, action.name, params_str, status);
1219            }
1220        }
1221        println!("================================\n");
1222        println!(
1223            "To replay this crash: crucible show {} {} --replay",
1224            program_name, crash_name
1225        );
1226
1227        return Ok(());
1228    }
1229
1230    // No metadata — show raw crash info if binary exists
1231    if let Some(binary_path) = crash_binary_path {
1232        let bytes = fs::read(&binary_path).context("Failed to read crash file")?;
1233        println!("\n=== Crash: {} (no metadata) ===", crash_name);
1234        println!("Size: {} bytes", bytes.len());
1235        println!("Path: {}", binary_path.display());
1236
1237        // Show hex preview
1238        let preview_len = bytes.len().min(64);
1239        let hex: String = bytes[..preview_len]
1240            .iter()
1241            .map(|b| format!("{:02x}", b))
1242            .collect::<Vec<_>>()
1243            .join(" ");
1244        println!(
1245            "\nHex preview: {}{}",
1246            hex,
1247            if bytes.len() > 64 { " ..." } else { "" }
1248        );
1249
1250        println!("\nNote: No .meta.json found. This crash was likely from a panic,");
1251        println!("not an invariant violation. Use --replay to reproduce it.");
1252        println!(
1253            "\nTo replay: crucible show {} {} --replay",
1254            program_name, crash_name
1255        );
1256
1257        return Ok(());
1258    }
1259
1260    bail!(
1261        "Crash not found: {}\n\
1262         Looking in: {}/crashes/*/\n\
1263         Use `crucible show {}` to list available crashes.",
1264        crash_name,
1265        fuzz_dir.display(),
1266        program_name
1267    )
1268}
1269
1270fn format_json_compact(v: &serde_json::Value) -> String {
1271    match v {
1272        serde_json::Value::Null => "null".to_string(),
1273        serde_json::Value::Bool(b) => b.to_string(),
1274        serde_json::Value::Number(n) => n.to_string(),
1275        serde_json::Value::String(s) => format!("\"{}\"", s),
1276        serde_json::Value::Array(arr) => {
1277            let items: Vec<String> = arr.iter().map(format_json_compact).collect();
1278            format!("[{}]", items.join(", "))
1279        }
1280        serde_json::Value::Object(obj) => {
1281            let items: Vec<String> = obj
1282                .iter()
1283                .map(|(k, v)| format!("{}: {}", k, format_json_compact(v)))
1284                .collect();
1285            format!("{{{}}}", items.join(", "))
1286        }
1287    }
1288}
1289
1290fn replay_crash(
1291    fuzz_dir: &Path,
1292    program_name: &str,
1293    crash_name: &str,
1294    custom_crashes_dir: Option<&Path>,
1295) -> Result<()> {
1296    let crashes_dir = resolve_crashes_dir(fuzz_dir, custom_crashes_dir);
1297
1298    // Check if crash_name is a directory — replay all crashes in it
1299    let replay_dir = resolve_replay_directory(crash_name, &crashes_dir);
1300    if let Some(dir) = replay_dir {
1301        return replay_crash_directory(fuzz_dir, &dir);
1302    }
1303
1304    // Single crash replay
1305    let (crash_path, test_name) = find_crash_file(&crashes_dir, program_name, crash_name)?;
1306
1307    let binary_path = build_and_find_replay_binary(fuzz_dir, test_name.as_deref())?;
1308
1309    println!("Replaying crash: {}", crash_path.display());
1310    println!("Using binary: {}\n", binary_path.display());
1311
1312    let status = run_replay(&binary_path, fuzz_dir, &crash_path)?;
1313    print_replay_result(&status);
1314
1315    Ok(())
1316}
1317
1318/// Resolve crash_name to a directory if it is one.
1319/// Checks: absolute/relative path, crashes/<name>, crashes/*/<name>
1320fn resolve_replay_directory(crash_name: &str, crashes_dir: &Path) -> Option<PathBuf> {
1321    let path = Path::new(crash_name);
1322
1323    // Direct path
1324    if path.is_dir() {
1325        return Some(path.to_path_buf());
1326    }
1327
1328    // crashes/<name> (test subdirectory)
1329    let candidate = crashes_dir.join(crash_name);
1330    if candidate.is_dir() {
1331        return Some(candidate);
1332    }
1333
1334    None
1335}
1336
1337/// Replay all crash files in a directory.
1338fn replay_crash_directory(fuzz_dir: &Path, dir: &Path) -> Result<()> {
1339    // Collect crash files (skip metadata/hidden)
1340    let mut crash_files: Vec<(String, PathBuf)> = Vec::new();
1341    for entry in fs::read_dir(dir)? {
1342        let entry = entry?;
1343        let path = entry.path();
1344        if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
1345            if name.starts_with('.') || name.ends_with(".metadata") || name.ends_with(".meta.json")
1346            {
1347                continue;
1348            }
1349            if path.is_file() {
1350                crash_files.push((name.to_string(), path));
1351            }
1352        }
1353    }
1354
1355    if crash_files.is_empty() {
1356        bail!("No crash files found in: {}", dir.display());
1357    }
1358
1359    crash_files.sort_by(|a, b| a.0.cmp(&b.0));
1360
1361    // Infer test name from the directory name (crashes are stored in crashes/<test_name>/)
1362    let test_name = dir
1363        .file_name()
1364        .and_then(|n| n.to_str())
1365        .map(|s| s.to_string());
1366
1367    let binary_path = build_and_find_replay_binary(fuzz_dir, test_name.as_deref())?;
1368
1369    println!(
1370        "[REPLAY] Replaying {} crash(es) from: {}\n",
1371        crash_files.len(),
1372        dir.display()
1373    );
1374
1375    let mut reproduced = 0usize;
1376    let mut not_reproduced = 0usize;
1377    let mut errors = 0usize;
1378
1379    for (i, (crash_id, crash_path)) in crash_files.iter().enumerate() {
1380        println!("--- [{}/{}] {} ---", i + 1, crash_files.len(), crash_id);
1381
1382        match run_replay(&binary_path, fuzz_dir, crash_path) {
1383            Ok(status) => {
1384                if !status.success() && status.code() == Some(1) {
1385                    reproduced += 1;
1386                    println!("  -> Reproduced\n");
1387                } else {
1388                    not_reproduced += 1;
1389                    println!("  -> Not reproduced\n");
1390                }
1391            }
1392            Err(e) => {
1393                errors += 1;
1394                println!("  -> Error: {}\n", e);
1395            }
1396        }
1397    }
1398
1399    println!("=== Replay Summary ===");
1400    println!(
1401        "  {} reproduced, {} not reproduced, {} errors (of {} total)",
1402        reproduced,
1403        not_reproduced,
1404        errors,
1405        crash_files.len()
1406    );
1407
1408    Ok(())
1409}
1410
1411/// Find a single crash file by name in the crashes directory tree.
1412fn find_crash_file(
1413    crashes_dir: &Path,
1414    program_name: &str,
1415    crash_name: &str,
1416) -> Result<(PathBuf, Option<String>)> {
1417    let mut crash_path = None;
1418    let mut test_name: Option<String> = None;
1419    let mut found_metadata_only = false;
1420    let mut available_inputs: Vec<String> = Vec::new();
1421
1422    if let Ok(entries) = fs::read_dir(crashes_dir) {
1423        for entry in entries.flatten() {
1424            let test_dir = entry.path();
1425            if !test_dir.is_dir() {
1426                continue;
1427            }
1428
1429            let dir_name = test_dir
1430                .file_name()
1431                .and_then(|n| n.to_str())
1432                .unwrap_or("")
1433                .to_string();
1434
1435            let candidate = test_dir.join(crash_name);
1436            if candidate.exists() && candidate.is_file() {
1437                crash_path = Some(candidate);
1438                test_name = Some(dir_name);
1439                break;
1440            }
1441
1442            for ext in &["", ".bin"] {
1443                let candidate = test_dir.join(format!("{}{}", crash_name, ext));
1444                if candidate.exists() && candidate.is_file() {
1445                    crash_path = Some(candidate);
1446                    test_name = Some(dir_name.clone());
1447                    break;
1448                }
1449            }
1450            if crash_path.is_some() {
1451                break;
1452            }
1453
1454            let meta_path = test_dir.join(format!("{}.meta.json", crash_name));
1455            if meta_path.exists() && crash_path.is_none() {
1456                found_metadata_only = true;
1457                if let Ok(dir_entries) = fs::read_dir(&test_dir) {
1458                    for dir_entry in dir_entries.flatten() {
1459                        let path = dir_entry.path();
1460                        if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
1461                            if !name.starts_with('.')
1462                                && !name.ends_with(".meta.json")
1463                                && !name.ends_with(".metadata")
1464                                && path.is_file()
1465                            {
1466                                available_inputs.push(name.to_string());
1467                            }
1468                        }
1469                    }
1470                }
1471            }
1472        }
1473    }
1474
1475    // Also check flat layout (files directly in crashes_dir)
1476    if crash_path.is_none() {
1477        let candidate = crashes_dir.join(crash_name);
1478        if candidate.exists() && candidate.is_file() {
1479            crash_path = Some(candidate);
1480        }
1481        // Try with extensions
1482        if crash_path.is_none() {
1483            for ext in &[".bin"] {
1484                let candidate = crashes_dir.join(format!("{}{}", crash_name, ext));
1485                if candidate.exists() && candidate.is_file() {
1486                    crash_path = Some(candidate);
1487                    break;
1488                }
1489            }
1490        }
1491        // Check flat metadata
1492        if crash_path.is_none() {
1493            let flat_meta = crashes_dir.join(format!("{}.meta.json", crash_name));
1494            if flat_meta.exists() {
1495                found_metadata_only = true;
1496            }
1497        }
1498    }
1499
1500    let crash_path = crash_path.ok_or_else(|| {
1501        if found_metadata_only {
1502            let mut msg = format!(
1503                "Crash metadata found for '{}', but input bytes file is missing.\n\
1504                 This crash was created before input bytes were saved alongside metadata.\n\n",
1505                crash_name
1506            );
1507            if !available_inputs.is_empty() {
1508                msg.push_str("Available crash input files that can be replayed:\n");
1509                for (i, input) in available_inputs.iter().take(5).enumerate() {
1510                    msg.push_str(&format!("  {}. {}\n", i + 1, input));
1511                }
1512                if available_inputs.len() > 5 {
1513                    msg.push_str(&format!("  ... and {} more\n", available_inputs.len() - 5));
1514                }
1515                msg.push_str(&format!(
1516                    "\nTry: crucible show {} <input_name> --replay",
1517                    program_name
1518                ));
1519            }
1520            anyhow::anyhow!(msg)
1521        } else {
1522            anyhow::anyhow!(
1523                "Crash file not found: {}\n\
1524                 Looking in: {}/crashes/*/\n\
1525                 Use `crucible show {}` to list available crashes.",
1526                crash_name,
1527                crashes_dir.display(),
1528                program_name
1529            )
1530        }
1531    })?;
1532
1533    Ok((crash_path, test_name))
1534}
1535
1536/// Build the replay binary and return its path.
1537fn build_and_find_replay_binary(fuzz_dir: &Path, test_feature: Option<&str>) -> Result<PathBuf> {
1538    if has_cargo() {
1539        if let Some(feature) = test_feature {
1540            if !feature.is_empty() {
1541                println!("[REPLAY] Building with --features {} ...", feature);
1542                let build_status = Command::new("cargo")
1543                    .current_dir(fuzz_dir)
1544                    .env("RUSTUP_TOOLCHAIN", "stable")
1545                    .args(["build", "--release", "--features", feature])
1546                    .status()
1547                    .context("Failed to build fuzz harness for replay")?;
1548
1549                if !build_status.success() {
1550                    // Try debug build
1551                    let build_status = Command::new("cargo")
1552                        .current_dir(fuzz_dir)
1553                        .env("RUSTUP_TOOLCHAIN", "stable")
1554                        .args(["build", "--features", feature])
1555                        .status()
1556                        .context("Failed to build fuzz harness for replay")?;
1557
1558                    if !build_status.success() {
1559                        bail!("Failed to build fuzz harness with --features {}", feature);
1560                    }
1561                }
1562            }
1563        }
1564    } else {
1565        println!("[REPLAY] cargo not found, looking for pre-built binary...");
1566    }
1567
1568    find_fuzz_binary(fuzz_dir, "release").or_else(|_| find_fuzz_binary(fuzz_dir, "debug"))
1569}
1570
1571/// Run the replay binary for a single crash file.
1572fn run_replay(binary_path: &Path, fuzz_dir: &Path, crash_path: &Path) -> Result<ExitStatus> {
1573    // Canonicalize crash path so it works regardless of the binary's working directory
1574    let abs_crash = crash_path.canonicalize().context("Crash file not found")?;
1575    Command::new(binary_path)
1576        .current_dir(fuzz_dir)
1577        .env("FUZZ_INPUT_FILE", &abs_crash)
1578        .stdout(Stdio::inherit())
1579        .stderr(Stdio::inherit())
1580        .status()
1581        .context("Failed to run replay")
1582}
1583
1584fn print_replay_result(status: &ExitStatus) {
1585    if !status.success() {
1586        if status.code() == Some(1) {
1587            println!("[REPLAY] SUCCESS: Crash reproduced!");
1588        } else {
1589            eprintln!("\nReplay failed with exit code: {:?}", status.code());
1590        }
1591    } else {
1592        println!("[REPLAY] Replay completed without crash.");
1593    }
1594}
1595
1596// ============================================================================
1597// Tmin Command
1598// ============================================================================
1599
1600fn fuzz_tmin(
1601    program_name: &str,
1602    test_name: &str,
1603    harness_dir: &Option<PathBuf>,
1604    crash_file: Option<&str>,
1605    all: bool,
1606    release: bool,
1607    crash_meta_dir: Option<&Path>,
1608) -> Result<()> {
1609    let cwd = current_dir()?;
1610    let fuzz_dir = resolve_fuzz_dir(&harness_dir.clone().unwrap_or(cwd), program_name)?;
1611
1612    // Auto-detect: if test_name looks like a crash ID and no crash_file was given,
1613    // search all test directories for it. Allows: crucible tmin stake crash_abc123
1614    let (test_name, crash_file) = if crash_file.is_none() && !all && test_name.starts_with("crash_")
1615    {
1616        let crashes_root = fuzz_dir.join("crashes");
1617        let mut found_test = None;
1618        if crashes_root.is_dir() {
1619            if let Ok(entries) = fs::read_dir(&crashes_root) {
1620                for entry in entries.flatten() {
1621                    if entry.path().is_dir() {
1622                        if entry.path().join(test_name).exists() {
1623                            found_test = Some(entry.file_name().to_string_lossy().to_string());
1624                            break;
1625                        }
1626                    }
1627                }
1628            }
1629        }
1630        match found_test {
1631            Some(t) => (t, Some(test_name.to_string())),
1632            None => bail!(
1633                "Could not find crash '{}' in any test directory under: {}\n\
1634                 Usage: crucible tmin {} <test_name> {} --release",
1635                test_name,
1636                crashes_root.display(),
1637                program_name,
1638                test_name
1639            ),
1640        }
1641    } else {
1642        (test_name.to_string(), crash_file.map(|s| s.to_string()))
1643    };
1644    let test_name = &test_name;
1645    let crash_file = crash_file.as_deref();
1646
1647    let crashes_dir = fuzz_dir.join("crashes").join(test_name);
1648    let meta_dir = crash_meta_dir
1649        .map(|p| p.to_path_buf())
1650        .unwrap_or_else(|| crashes_dir.clone());
1651
1652    if !crashes_dir.exists() {
1653        bail!(
1654            "No crashes directory found at: {}\nRun the fuzzer first to generate crashes.",
1655            crashes_dir.display()
1656        );
1657    }
1658
1659    // Collect crash files to minimize
1660    let crash_files: Vec<(String, PathBuf)> = if all {
1661        // Find all crash binary files (those with a corresponding .meta.json)
1662        let mut files = Vec::new();
1663        for entry in fs::read_dir(&meta_dir)? {
1664            let entry = entry?;
1665            let path = entry.path();
1666            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
1667                if let Some(crash_id) = name.strip_suffix(".meta.json") {
1668                    let binary_path = crashes_dir.join(crash_id);
1669                    if binary_path.exists() && binary_path.is_file() {
1670                        files.push((crash_id.to_string(), binary_path));
1671                    }
1672                }
1673            }
1674        }
1675        if files.is_empty() {
1676            bail!("No crash files found in: {}", crashes_dir.display());
1677        }
1678        files.sort_by(|a, b| a.0.cmp(&b.0));
1679        println!("[TMIN] Found {} crash(es) to minimize", files.len());
1680        files
1681    } else {
1682        let crash_name = crash_file.ok_or_else(|| {
1683            anyhow::anyhow!(
1684                "Provide a crash file or use --all to minimize all crashes.\n\
1685                 Usage: crucible tmin {} {} <crash_id> --release\n\
1686                 Usage: crucible tmin {} {} --all --release",
1687                program_name,
1688                test_name,
1689                program_name,
1690                test_name
1691            )
1692        })?;
1693        let binary_path = crashes_dir.join(crash_name);
1694        if !binary_path.exists() {
1695            bail!(
1696                "Crash file not found: {}\nLooking in: {}\nUse `crucible show {}` to list available crashes.",
1697                crash_name,
1698                crashes_dir.display(),
1699                program_name
1700            );
1701        }
1702        vec![(crash_name.to_string(), binary_path)]
1703    };
1704
1705    // Build the binary (skipped if cargo not available)
1706    let profile = if release { "release" } else { "debug" };
1707    try_cargo_build(&fuzz_dir, release, test_name)?;
1708    let binary_path = find_fuzz_binary(&fuzz_dir, profile)?;
1709
1710    // Minimize crash(es)
1711    if all {
1712        // --all mode: single process invocation, binary iterates all crashes internally
1713        // This avoids re-running setup() for each crash (major perf win)
1714        println!(
1715            "[TMIN] Minimizing all {} crashes in a single process...",
1716            crash_files.len()
1717        );
1718        let status = Command::new(&binary_path)
1719            .current_dir(&fuzz_dir)
1720            .env("FUZZ_TMIN_ALL_DIR", &crashes_dir)
1721            .env("FUZZ_CRASHES_DIR", &crashes_dir)
1722            .env("FUZZ_META_DIR", &meta_dir)
1723            .stdout(Stdio::inherit())
1724            .stderr(Stdio::inherit())
1725            .status()
1726            .context("Failed to run crash minimization")?;
1727
1728        if !status.success() {
1729            bail!("Crash minimization failed (exit code: {:?})", status.code());
1730        }
1731    } else {
1732        // Single crash mode
1733        let (crash_id, crash_path) = &crash_files[0];
1734        println!("\n[TMIN] Minimizing: {}", crash_id);
1735
1736        let status = Command::new(&binary_path)
1737            .current_dir(&fuzz_dir)
1738            .env("FUZZ_TMIN_FILE", crash_path)
1739            .env("FUZZ_TMIN_CRASH_ID", crash_id)
1740            .env("FUZZ_CRASHES_DIR", &crashes_dir)
1741            .env("FUZZ_META_DIR", &meta_dir)
1742            .stdout(Stdio::inherit())
1743            .stderr(Stdio::inherit())
1744            .status()
1745            .context("Failed to run crash minimization")?;
1746
1747        if !status.success() {
1748            eprintln!(
1749                "[TMIN] Warning: minimization failed for {} (exit code: {:?})",
1750                crash_id,
1751                status.code()
1752            );
1753        }
1754    }
1755
1756    Ok(())
1757}
1758
1759fn regen_crashes(
1760    fuzz_dir: &Path,
1761    custom_crashes_dir: Option<&Path>,
1762    custom_meta_dir: Option<&Path>,
1763) -> Result<()> {
1764    let crashes_dir = resolve_crashes_dir(fuzz_dir, custom_crashes_dir);
1765    let meta_dir = custom_meta_dir
1766        .map(|p| p.to_path_buf())
1767        .unwrap_or_else(|| crashes_dir.clone());
1768    if !crashes_dir.exists() {
1769        bail!(
1770            "No crashes directory found at: {}\nRun the fuzzer first to generate crashes.",
1771            crashes_dir.display()
1772        );
1773    }
1774
1775    // Find the binary (try release then debug)
1776    let binary_path =
1777        find_fuzz_binary(fuzz_dir, "release").or_else(|_| find_fuzz_binary(fuzz_dir, "debug"))?;
1778
1779    // Iterate all test subdirectories in crashes/
1780    let mut total_ok = 0usize;
1781    let mut total_fail = 0usize;
1782    let mut total_count = 0usize;
1783
1784    let mut test_dirs: Vec<_> = fs::read_dir(&crashes_dir)?
1785        .filter_map(|e| e.ok())
1786        .filter(|e| e.path().is_dir())
1787        .collect();
1788    test_dirs.sort_by_key(|e| e.file_name());
1789
1790    for test_entry in &test_dirs {
1791        let test_dir = test_entry.path();
1792        let test_name = test_dir
1793            .file_name()
1794            .and_then(|n| n.to_str())
1795            .unwrap_or("unknown");
1796
1797        // Collect crash binary files (skip metadata/hidden)
1798        let mut crash_files: Vec<(String, PathBuf)> = Vec::new();
1799        for entry in fs::read_dir(&test_dir)? {
1800            let entry = entry?;
1801            let path = entry.path();
1802            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
1803                if name.starts_with('.')
1804                    || name.ends_with(".metadata")
1805                    || name.ends_with(".meta.json")
1806                {
1807                    continue;
1808                }
1809                if path.is_file() {
1810                    crash_files.push((name.to_string(), path));
1811                }
1812            }
1813        }
1814
1815        if crash_files.is_empty() {
1816            continue;
1817        }
1818
1819        crash_files.sort_by(|a, b| a.0.cmp(&b.0));
1820        println!("\n[REGEN] {} — {} crash(es)", test_name, crash_files.len());
1821
1822        for (idx, (crash_id, crash_path)) in crash_files.iter().enumerate() {
1823            total_count += 1;
1824            print!("[REGEN] {}/{} {}... ", idx + 1, crash_files.len(), crash_id);
1825
1826            let meta_test_dir = meta_dir.join(test_name);
1827            let status = Command::new(&binary_path)
1828                .current_dir(fuzz_dir)
1829                .env("FUZZ_INPUT_FILE", crash_path)
1830                .env("FUZZ_CRASHES_DIR", &test_dir)
1831                .env("FUZZ_META_DIR", &meta_test_dir)
1832                .stdout(Stdio::null())
1833                .stderr(Stdio::null())
1834                .status();
1835
1836            match status {
1837                Ok(s) => {
1838                    // Exit code 1 = crash reproduced (expected), 0 = no crash (still OK, metadata updated)
1839                    if s.success() || s.code() == Some(1) {
1840                        println!("OK");
1841                        total_ok += 1;
1842                    } else {
1843                        println!("FAIL (exit {})", s.code().unwrap_or(-1));
1844                        total_fail += 1;
1845                    }
1846                }
1847                Err(e) => {
1848                    println!("FAIL ({})", e);
1849                    total_fail += 1;
1850                }
1851            }
1852        }
1853    }
1854
1855    if total_count == 0 {
1856        println!("No crash files found in: {}", crashes_dir.display());
1857    } else {
1858        println!(
1859            "\n[REGEN] Done. {} OK, {} failed out of {} total",
1860            total_ok, total_fail, total_count
1861        );
1862    }
1863
1864    Ok(())
1865}
1866
1867// ============================================================================
1868// Cmin Command
1869// ============================================================================
1870
1871fn fuzz_cmin(
1872    program_name: &str,
1873    test_name: &str,
1874    harness_dir: &Option<PathBuf>,
1875    corpus_in: &Path,
1876    corpus_out: Option<&Path>,
1877    release: bool,
1878) -> Result<()> {
1879    let cwd = current_dir()?;
1880    let fuzz_dir = resolve_fuzz_dir(&harness_dir.clone().unwrap_or(cwd.clone()), program_name)?;
1881
1882    let corpus_in_abs = resolve_path(&cwd, corpus_in);
1883    if !corpus_in_abs.exists() {
1884        bail!(
1885            "Corpus directory does not exist: {}",
1886            corpus_in_abs.display()
1887        );
1888    }
1889
1890    let input_count = fs::read_dir(&corpus_in_abs)?
1891        .filter_map(|e| e.ok())
1892        .filter(|e| {
1893            let path = e.path();
1894            if !path.is_file() {
1895                return false;
1896            }
1897            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1898            !name.starts_with('.') && !name.ends_with(".metadata") && !name.ends_with(".meta.json")
1899        })
1900        .count();
1901
1902    if input_count == 0 {
1903        bail!("No corpus inputs found in: {}", corpus_in_abs.display());
1904    }
1905
1906    println!(
1907        "[CMIN] Minimizing corpus: {} ({} inputs)",
1908        corpus_in_abs.display(),
1909        input_count
1910    );
1911
1912    let corpus_out_abs = corpus_out
1913        .map(|p| resolve_path(&cwd, p))
1914        .unwrap_or_else(|| {
1915            println!("[CMIN] Merging corpus in place!");
1916            corpus_in_abs.clone()
1917        });
1918
1919    if corpus_out_abs != corpus_in_abs {
1920        fs::create_dir_all(&corpus_out_abs)?;
1921        println!("[CMIN] Output directory: {}", corpus_out_abs.display());
1922    }
1923
1924    let profile = if release { "release" } else { "debug" };
1925    try_cargo_build(&fuzz_dir, release, test_name)?;
1926    let binary_path = find_fuzz_binary(&fuzz_dir, profile)?;
1927
1928    println!("[CMIN] Running corpus minimization...");
1929
1930    let status = Command::new(&binary_path)
1931        .current_dir(&fuzz_dir)
1932        .env("FUZZ_CMIN", "1")
1933        .env("FUZZ_CORPUS_IN", &corpus_in_abs)
1934        .env("FUZZ_CORPUS_OUT", &corpus_out_abs)
1935        .stdout(Stdio::inherit())
1936        .stderr(Stdio::inherit())
1937        .status()
1938        .context("Failed to run corpus minimization")?;
1939
1940    if !status.success() {
1941        bail!(
1942            "Corpus minimization failed with exit code: {:?}",
1943            status.code()
1944        );
1945    }
1946
1947    Ok(())
1948}
1949
1950// ============================================================================
1951// Utilities
1952// ============================================================================
1953
1954/// Resolve the fuzz harness directory for a program name.
1955///
1956/// `root` is either:
1957/// - the project root (normal case, no --harness-dir): looks under root/fuzz/<program>/
1958/// - a custom harness root (--harness-dir): treats root itself as the harness dir if it
1959///   contains a Cargo.toml, otherwise looks for root/<program>/ inside it.
1960///
1961/// Also tries hyphen/underscore conversion and auto-detects if only one harness exists.
1962fn resolve_fuzz_dir(root: &Path, program_name: &str) -> Result<PathBuf> {
1963    // If root itself is a harness dir (has [package], not a bare virtual workspace), use it directly.
1964    if root.join("Cargo.toml").exists() && root.join("src").exists() {
1965        let content = fs::read_to_string(root.join("Cargo.toml")).unwrap_or_default();
1966        let is_virtual_workspace =
1967            content.contains("[workspace]") && !content.contains("[package]");
1968        if !is_virtual_workspace {
1969            return Ok(root.to_path_buf());
1970        }
1971    }
1972
1973    // Try root/fuzz/<program>/ (normal project-root case)
1974    let fuzz_dir = root.join("fuzz").join(program_name);
1975    if fuzz_dir.exists() {
1976        return Ok(fuzz_dir);
1977    }
1978
1979    // Try root/<program>/ (--harness-dir points to fuzz/ itself)
1980    let direct = root.join(program_name);
1981    if direct.exists() && direct.join("Cargo.toml").exists() {
1982        return Ok(direct);
1983    }
1984
1985    // Try hyphen <-> underscore conversion in both locations
1986    let alt_name = if program_name.contains('-') {
1987        program_name.replace('-', "_")
1988    } else {
1989        program_name.replace('_', "-")
1990    };
1991    for candidate in [root.join("fuzz").join(&alt_name), root.join(&alt_name)] {
1992        if candidate.exists() && candidate.join("Cargo.toml").exists() {
1993            return Ok(candidate);
1994        }
1995    }
1996
1997    // Auto-detect: if fuzz/ has exactly one harness, use it
1998    let fuzz_root = root.join("fuzz");
1999    if fuzz_root.exists() {
2000        if let Ok(entries) = fs::read_dir(&fuzz_root) {
2001            let harnesses: Vec<_> = entries
2002                .filter_map(|e| e.ok())
2003                .filter(|e| e.path().is_dir() && e.path().join("Cargo.toml").exists())
2004                .collect();
2005            if harnesses.len() == 1 {
2006                return Ok(harnesses[0].path());
2007            }
2008        }
2009    }
2010
2011    bail!(
2012        "Fuzz directory for {} does not exist. Run `crucible init {}` first.",
2013        program_name,
2014        program_name
2015    );
2016}
2017
2018/// Check if cargo is available in PATH.
2019fn has_cargo() -> bool {
2020    Command::new("cargo")
2021        .arg("--version")
2022        .stdout(Stdio::null())
2023        .stderr(Stdio::null())
2024        .status()
2025        .map(|s| s.success())
2026        .unwrap_or(false)
2027}
2028
2029/// Try to build the fuzz harness with cargo. Returns Ok(true) if built, Ok(false) if cargo
2030/// is not available (so caller should look for a pre-built binary), or Err on build failure.
2031fn try_cargo_build(fuzz_dir: &Path, release: bool, feature: &str) -> Result<bool> {
2032    if !has_cargo() {
2033        println!("[FUZZ] cargo not found, looking for pre-built binary...");
2034        return Ok(false);
2035    }
2036
2037    let mut args = vec!["build".to_string()];
2038    if release {
2039        args.push("--release".to_string());
2040    }
2041    args.extend(["--features".to_string(), feature.to_string()]);
2042
2043    let status = Command::new("cargo")
2044        .current_dir(fuzz_dir)
2045        .env("RUSTUP_TOOLCHAIN", "stable")
2046        .args(&args)
2047        .status()
2048        .context("Failed to run cargo build")?;
2049
2050    if !status.success() {
2051        bail!("Build failed");
2052    }
2053
2054    Ok(true)
2055}
2056
2057/// Resolve the crashes directory: use the custom path if provided, else default to `<fuzz_dir>/crashes`.
2058fn resolve_crashes_dir(fuzz_dir: &Path, custom: Option<&Path>) -> PathBuf {
2059    custom
2060        .map(|p| p.to_path_buf())
2061        .unwrap_or_else(|| fuzz_dir.join("crashes"))
2062}
2063
2064/// Convert a potentially relative path to absolute, relative to cwd.
2065fn resolve_path(cwd: &Path, path: &Path) -> PathBuf {
2066    if path.is_absolute() {
2067        path.to_path_buf()
2068    } else {
2069        cwd.join(path)
2070    }
2071}
2072
2073/// Find the fuzz harness binary. The generated Cargo.toml always names the binary
2074/// `invariant_test` via `[[bin]]`, so the path is predictable.
2075fn find_fuzz_binary(fuzz_dir: &Path, profile: &str) -> Result<PathBuf> {
2076    let path = fuzz_dir.join("target").join(profile).join("invariant_test");
2077    if path.exists() {
2078        return Ok(path);
2079    }
2080    bail!(
2081        "Fuzz binary not found at: {}\nBuild it first with: crucible run <program> <test_name> --release",
2082        path.display()
2083    )
2084}
2085
2086#[cfg(test)]
2087mod tests {
2088    use super::*;
2089
2090    #[test]
2091    fn test_find_fuzz_binary_finds_invariant_test() {
2092        let temp = tempfile::tempdir().unwrap();
2093        let target_dir = temp.path().join("target/release");
2094        fs::create_dir_all(&target_dir).unwrap();
2095        fs::write(target_dir.join("invariant_test"), b"").unwrap();
2096
2097        let result = find_fuzz_binary(temp.path(), "release");
2098        assert!(result.is_ok(), "should find invariant_test: {:?}", result);
2099        assert!(result.unwrap().ends_with("invariant_test"));
2100    }
2101
2102    #[test]
2103    fn test_find_fuzz_binary_fails_when_missing() {
2104        let temp = tempfile::tempdir().unwrap();
2105        let result = find_fuzz_binary(temp.path(), "release");
2106        assert!(result.is_err());
2107    }
2108
2109    #[test]
2110    fn test_try_cargo_build_returns_true_when_cargo_available() {
2111        assert!(has_cargo(), "cargo should be available in test env");
2112    }
2113
2114    #[test]
2115    fn test_run_accepts_binary_in() {
2116        let cli = Cli::try_parse_from([
2117            "crucible",
2118            "run",
2119            "stake",
2120            "invariant_test",
2121            "--binary-in",
2122            "/tmp/stake_fuzz",
2123        ])
2124        .unwrap();
2125
2126        match cli.command {
2127            FuzzCmd::Run { binary_in, .. } => {
2128                assert_eq!(binary_in, Some(PathBuf::from("/tmp/stake_fuzz")));
2129            }
2130            _ => panic!("expected run command"),
2131        }
2132    }
2133
2134    // === Regression: crash replay path must be canonicalized ===
2135
2136    #[test]
2137    fn test_run_replay_canonicalizes_crash_path() {
2138        // run_replay passes crash_path as FUZZ_INPUT_FILE env var.
2139        // If crash_path is relative to cwd but binary runs from fuzz_dir,
2140        // it must be canonicalized to an absolute path.
2141        // Non-existent path returns Err (caller gets "Crash file not found").
2142        let crash = Path::new("crashes/invariant_test/crash_abc123");
2143        assert!(
2144            crash.canonicalize().is_err(),
2145            "non-existent crash path should fail canonicalize"
2146        );
2147
2148        // Existing path canonicalizes to an absolute path.
2149        let tmp = std::env::temp_dir().join("crucible_test_crash_replay");
2150        std::fs::write(&tmp, b"test").unwrap();
2151        let abs = tmp.canonicalize().unwrap();
2152        assert!(abs.is_absolute());
2153        std::fs::remove_file(&tmp).unwrap();
2154    }
2155
2156    // === Regression: crash dir must not fallback to ./output ===
2157
2158    #[test]
2159    fn test_crashes_dir_env_no_output_fallback() {
2160        // Previously, if ./output existed, FUZZ_CRASHES_DIR would default to it.
2161        // This caused crashes to go to output/ instead of crashes/, breaking crucible show.
2162        // The fix: only use FUZZ_CRASHES_DIR if explicitly set via env var.
2163        // We verify the generated code doesn't have the ./output fallback by checking
2164        // that crashes_dir_env reads ONLY from the env var.
2165        std::env::remove_var("FUZZ_CRASHES_DIR");
2166        let val: Option<String> = std::env::var("FUZZ_CRASHES_DIR").ok();
2167        assert!(val.is_none(), "FUZZ_CRASHES_DIR should not be set");
2168        // The old code would check: .or_else(|| if Path::new("./output").is_dir() { Some(...) })
2169        // The new code just uses: std::env::var("FUZZ_CRASHES_DIR").ok()
2170        // This test documents the expected behavior.
2171    }
2172}