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#[derive(Debug, Parser)]
18#[command(name = "crucible", about = "Solana smart contract fuzzing framework")]
19pub struct Cli {
20 #[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 Init {
31 program_name: String,
33 },
34 Run {
36 program_name: String,
38 test_name: String,
40 #[arg(long)]
42 binary_in: Option<PathBuf>,
43 #[arg(long)]
45 release: bool,
46 #[arg(long)]
48 coverage: bool,
49 #[arg(long)]
51 timeout: Option<u64>,
52 #[arg(long, short = 'j')]
54 cores: Option<usize>,
55 #[arg(long)]
57 corpus_in: Option<PathBuf>,
58 #[arg(long)]
60 corpus_out: Option<PathBuf>,
61 #[arg(long)]
63 crashes_out: Option<PathBuf>,
64 #[arg(long)]
66 crashes_meta_out: Option<PathBuf>,
67 #[arg(long)]
69 replay: Option<PathBuf>,
70 #[arg(long)]
72 dry_run: bool,
73 #[arg(long)]
75 seed: Option<u64>,
76 #[arg(long)]
78 stop_on_crash: bool,
79 #[arg(long)]
81 max_actions: Option<usize>,
82 #[arg(long)]
84 no_tracing: bool,
85 #[arg(long)]
87 stateful: bool,
88 #[arg(long)]
90 max_depth: Option<u32>,
91 #[arg(long)]
93 pool_size: Option<u64>,
94 #[arg(long)]
96 program_so: Option<PathBuf>,
97 #[arg(long)]
99 symbols: Option<PathBuf>,
100 #[arg(long)]
102 mode: Option<String>,
103 #[arg(long)]
105 lcov_out: Option<PathBuf>,
106 #[arg(long)]
108 stats: bool,
109 },
110 List {
112 program_name: Option<String>,
114 #[arg(short = 'C', long = "harness-dir")]
116 harness_dir: Option<PathBuf>,
117 },
118 Show {
120 program_name: String,
122 crash_file: Option<String>,
124 #[arg(long)]
126 replay: bool,
127 #[arg(long)]
129 regen: bool,
130 #[arg(long)]
132 crashes_dir: Option<PathBuf>,
133 #[arg(long)]
135 crash_meta_dir: Option<PathBuf>,
136 },
137 Tmin {
139 program_name: String,
141 test_name: String,
143 crash_file: Option<String>,
145 #[arg(long)]
147 all: bool,
148 #[arg(long)]
150 release: bool,
151 #[arg(long)]
153 crash_meta_dir: Option<PathBuf>,
154 },
155 Cmin {
157 program_name: String,
159 test_name: String,
161 corpus_dir: Option<PathBuf>,
163 #[arg(long)]
165 corpus_in: Option<PathBuf>,
166 #[arg(long)]
168 corpus_out: Option<PathBuf>,
169 #[arg(long)]
171 release: bool,
172 },
173}
174
175#[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
198pub 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
321fn fuzz_init(program_name: &str, harness_dir: &Option<PathBuf>) -> Result<()> {
326 let cwd = current_dir()?;
327
328 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 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
406fn 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 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 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 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 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 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 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 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 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 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
742fn 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 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
848fn 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 let mut crashes_with_meta: Vec<(String, String, CrashMetadata)> = Vec::new();
928 let mut raw_crashes: Vec<(String, String, u64)> = Vec::new(); 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 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 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 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 {
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 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 {
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 }
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 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 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 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 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 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 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 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 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 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
1318fn resolve_replay_directory(crash_name: &str, crashes_dir: &Path) -> Option<PathBuf> {
1321 let path = Path::new(crash_name);
1322
1323 if path.is_dir() {
1325 return Some(path.to_path_buf());
1326 }
1327
1328 let candidate = crashes_dir.join(crash_name);
1330 if candidate.is_dir() {
1331 return Some(candidate);
1332 }
1333
1334 None
1335}
1336
1337fn replay_crash_directory(fuzz_dir: &Path, dir: &Path) -> Result<()> {
1339 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 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
1411fn 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 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 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 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
1536fn 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 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
1571fn run_replay(binary_path: &Path, fuzz_dir: &Path, crash_path: &Path) -> Result<ExitStatus> {
1573 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
1596fn 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 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 let crash_files: Vec<(String, PathBuf)> = if all {
1661 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 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 if all {
1712 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 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 let binary_path =
1777 find_fuzz_binary(fuzz_dir, "release").or_else(|_| find_fuzz_binary(fuzz_dir, "debug"))?;
1778
1779 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 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 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
1867fn 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
1950fn resolve_fuzz_dir(root: &Path, program_name: &str) -> Result<PathBuf> {
1963 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 let fuzz_dir = root.join("fuzz").join(program_name);
1975 if fuzz_dir.exists() {
1976 return Ok(fuzz_dir);
1977 }
1978
1979 let direct = root.join(program_name);
1981 if direct.exists() && direct.join("Cargo.toml").exists() {
1982 return Ok(direct);
1983 }
1984
1985 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 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
2018fn 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
2029fn 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
2057fn 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
2064fn 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
2073fn 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 #[test]
2137 fn test_run_replay_canonicalizes_crash_path() {
2138 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 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 #[test]
2159 fn test_crashes_dir_env_no_output_fallback() {
2160 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 }
2172}