1use clap::{Parser, ValueEnum};
4use eyre::{Context, Result};
5use foundry_common::sh_println;
6use once_cell::sync::Lazy;
7use serde_json::json;
8#[cfg(unix)]
9use std::os::unix::process::CommandExt;
10#[cfg(unix)]
11use std::time::Duration;
12use std::{
13 collections::HashSet,
14 env,
15 ffi::{OsStr, OsString},
16 fs,
17 path::{Path, PathBuf},
18 process::{Child, Command, ExitStatus, Output, Stdio},
19 sync::Mutex,
20};
21
22const DEFAULT_SCFUZZBENCH_REPO: &str = "https://github.com/tempoxyz/scfuzzbench.git";
23const DEFAULT_SCFUZZBENCH_REF: &str = "main";
24const DEFAULT_FOUNDRY_REPO: &str = "https://github.com/foundry-rs/foundry.git";
25const OUTPUT_MARKER: &str = ".foundry-scfuzzbench-output";
26#[cfg(unix)]
27const PROCESS_GROUP_GRACE: Duration = Duration::from_secs(2);
28
29#[cfg(unix)]
30type ProcessGroupId = libc::pid_t;
31#[cfg(not(unix))]
32type ProcessGroupId = u32;
33
34static ACTIVE_PROCESS_GROUPS: Lazy<Mutex<HashSet<ProcessGroupId>>> =
35 Lazy::new(|| Mutex::new(HashSet::new()));
36
37const REQUIRED_DATA_ARTIFACTS: &[&str] = &[
38 "REPORT.md",
39 "events.csv",
40 "summary.csv",
41 "cumulative.csv",
42 "throughput_samples.csv",
43 "throughput_summary.csv",
44 "progress_metrics_samples.csv",
45 "progress_metrics_summary.csv",
46 "showmap_campaign_manifest.json",
47 "differential_coverage_relscores.csv",
48 "differential_coverage_relcov.csv",
49 "runner_resource_summary.csv",
50 "runner_resource_timeseries.csv",
51 "runner_resource_usage.md",
52 "broken_invariants.csv",
53 "broken_invariants.md",
54];
55
56#[derive(Parser, Debug)]
58#[clap(
59 name = "foundry-scfuzzbench",
60 about = "Run Foundry scfuzzbench campaigns and collect analysis artifacts"
61)]
62struct Cli {
63 #[clap(long, default_value = DEFAULT_SCFUZZBENCH_REPO)]
65 scfuzzbench_repo: String,
66
67 #[clap(long, default_value = DEFAULT_SCFUZZBENCH_REF)]
69 scfuzzbench_ref: String,
70
71 #[clap(long)]
73 target_repo: String,
74
75 #[clap(long)]
77 target_ref: String,
78
79 #[clap(long, value_enum)]
81 benchmark_type: BenchmarkType,
82
83 #[clap(long)]
85 timeout_seconds: u64,
86
87 #[clap(long)]
89 workers: Option<u64>,
90
91 #[clap(long)]
93 output_dir: PathBuf,
94
95 #[clap(long, conflicts_with = "foundry_ref")]
97 foundry_bin: Option<PathBuf>,
98
99 #[clap(long, conflicts_with = "foundry_bin")]
102 foundry_ref: Option<String>,
103
104 #[clap(long, default_value = DEFAULT_FOUNDRY_REPO)]
106 foundry_repo: String,
107
108 #[clap(long)]
110 foundry_test_args: Option<String>,
111
112 #[clap(long)]
115 properties_path: Option<PathBuf>,
116
117 #[clap(long)]
119 force: bool,
120}
121
122#[derive(Clone, Copy, Debug, ValueEnum)]
123enum BenchmarkType {
124 Property,
125 Optimization,
126}
127
128impl BenchmarkType {
129 const fn as_str(self) -> &'static str {
130 match self {
131 Self::Property => "property",
132 Self::Optimization => "optimization",
133 }
134 }
135}
136
137struct Dirs {
138 work: PathBuf,
139 raw: PathBuf,
140 data: PathBuf,
141 images: PathBuf,
142 artifacts: PathBuf,
143 home: PathBuf,
144 tools_bin: PathBuf,
145 scfuzzbench: PathBuf,
146 target_pin: PathBuf,
147 scfuzz_root: PathBuf,
148 scfuzz_work: PathBuf,
149 scfuzz_logs: PathBuf,
150 unzipped: PathBuf,
151 analysis_logs: PathBuf,
152}
153
154impl Dirs {
155 fn new(output: PathBuf) -> Self {
156 let work = output.join("work");
157 Self {
158 raw: output.join("raw"),
159 data: output.join("data"),
160 images: output.join("images"),
161 artifacts: output.join("artifacts"),
162 home: work.join("home"),
163 tools_bin: work.join("bin"),
164 scfuzzbench: work.join("scfuzzbench"),
165 target_pin: work.join("target-pin"),
166 scfuzz_root: work.join("scfuzz-root"),
167 scfuzz_work: work.join("scfuzz-work"),
168 scfuzz_logs: work.join("scfuzz-logs"),
169 unzipped: work.join("unzipped"),
170 analysis_logs: work.join("analysis-logs"),
171 work,
172 }
173 }
174
175 fn create(&self) -> Result<()> {
176 for dir in [
177 &self.work,
178 &self.raw,
179 &self.data,
180 &self.images,
181 &self.artifacts,
182 &self.home,
183 &self.tools_bin,
184 &self.scfuzz_root,
185 &self.scfuzz_work,
186 &self.scfuzz_logs,
187 &self.unzipped,
188 &self.analysis_logs,
189 ] {
190 fs::create_dir_all(dir)
191 .wrap_err_with(|| format!("failed to create {}", dir.display()))?;
192 }
193 Ok(())
194 }
195}
196
197struct RunEnv {
198 path: OsString,
199 home: PathBuf,
200}
201
202impl RunEnv {
203 fn apply(&self, command: &mut Command) {
204 command.env("PATH", &self.path).env("HOME", &self.home);
205 }
206}
207
208struct FoundrySelection {
209 mode: &'static str,
210 label: String,
211 bin: PathBuf,
212 repo: Option<String>,
213 ref_name: Option<String>,
214 commit: Option<String>,
215 version_output: String,
216 env: RunEnv,
217}
218
219struct RunMetadata<'a> {
220 scfuzzbench_commit: &'a str,
221 target_commit: &'a str,
222 run_id: &'a str,
223 campaign_exit_code: Option<i32>,
224}
225
226fn main() -> Result<()> {
227 color_eyre::install()?;
228 ensure_supported_platform()?;
229 install_termination_handler()?;
230 let cli = Cli::parse();
231
232 validate_options(&cli)?;
233 preflight(&cli)?;
234 prepare_output_dir(&cli.output_dir, cli.force)?;
235 let dirs = Dirs::new(cli.output_dir.clone());
236 dirs.create()?;
237 install_date_shim(&dirs.tools_bin)?;
238 install_timeout_shim(&dirs.tools_bin)?;
239 install_sed_shim(&dirs.tools_bin)?;
240
241 let _ = sh_println!("📦 Cloning scfuzzbench");
242 let scfuzzbench_commit =
243 clone_at(&cli.scfuzzbench_repo, &cli.scfuzzbench_ref, &dirs.scfuzzbench)
244 .wrap_err("failed to clone scfuzzbench")?;
245
246 let _ = sh_println!("📦 Resolving target repository pin");
247 let target_commit = clone_at(&cli.target_repo, &cli.target_ref, &dirs.target_pin)
248 .wrap_err("failed to clone target repository")?;
249
250 let foundry = select_foundry(&cli, &dirs).wrap_err("failed to select Foundry binary")?;
251 let _ = sh_println!("🔨 Foundry: {}", foundry.version_output.trim());
252
253 let run_id = format!("foundry-scfuzzbench-{}", chrono::Utc::now().format("%Y%m%d%H%M%S"));
254 let campaign_status = run_campaign(&cli, &dirs, &foundry, &target_commit, &run_id)
255 .wrap_err("failed to run scfuzzbench campaign")?;
256 ensure_campaign_success(&campaign_status)?;
257
258 validate_campaign_logs(&dirs)?;
259
260 run_analysis(&cli, &dirs, &foundry, &run_id).wrap_err("failed to analyze campaign logs")?;
261 validate_differential_coverage(&dirs)?;
262
263 let run_metadata = RunMetadata {
264 scfuzzbench_commit: &scfuzzbench_commit,
265 target_commit: &target_commit,
266 run_id: &run_id,
267 campaign_exit_code: campaign_status.code(),
268 };
269 let mut missing = collect_artifacts(&dirs).wrap_err("failed to collect artifacts")?;
270 let summary_path = write_llm_summary(&cli, &dirs, &foundry, &run_metadata, &missing)?;
271 let manifest_path = write_manifest(&cli, &dirs, &foundry, &run_metadata)?;
272 missing.retain(|path| path != "manifest.json" && path != "llm_summary.md");
273
274 if !missing.is_empty() {
275 eyre::bail!(
276 "missing required scfuzzbench artifacts in {}: {}",
277 dirs.artifacts.display(),
278 missing.join(", ")
279 );
280 }
281
282 let _ = sh_println!("✅ Artifacts written to {}", dirs.artifacts.display());
283 let _ = sh_println!(" manifest: {}", manifest_path.display());
284 let _ = sh_println!(" LLM summary: {}", summary_path.display());
285 Ok(())
286}
287
288#[cfg(unix)]
289const fn ensure_supported_platform() -> Result<()> {
290 Ok(())
291}
292
293#[cfg(not(unix))]
294fn ensure_supported_platform() -> Result<()> {
295 eyre::bail!("foundry-scfuzzbench requires a Unix-like platform with bash process groups");
296}
297
298fn ensure_campaign_success(status: &ExitStatus) -> Result<()> {
299 if status.success() {
300 return Ok(());
301 }
302 eyre::bail!(
303 "scfuzzbench campaign failed ({status}); refusing to analyze incomplete campaign logs"
304 );
305}
306
307fn validate_options(cli: &Cli) -> Result<()> {
308 if matches!(cli.benchmark_type, BenchmarkType::Optimization) && cli.properties_path.is_none() {
309 eyre::bail!("--properties-path is required for --benchmark-type optimization");
310 }
311 if let Some(properties_path) = &cli.properties_path {
312 if properties_path.is_absolute() {
313 eyre::bail!(
314 "--properties-path must be relative to the target repository: {}",
315 properties_path.display()
316 );
317 }
318 if properties_path
319 .components()
320 .any(|component| matches!(component, std::path::Component::ParentDir))
321 {
322 eyre::bail!(
323 "--properties-path must not escape the target repository: {}",
324 properties_path.display()
325 );
326 }
327 }
328 Ok(())
329}
330
331fn preflight(cli: &Cli) -> Result<()> {
332 for name in ["bash", "git", "make", "uv", "zip", "python3"] {
333 let status = Command::new("sh")
334 .arg("-c")
335 .arg(format!("command -v {name} >/dev/null 2>&1"))
336 .status()
337 .wrap_err_with(|| format!("failed to check for {name}"))?;
338 if !status.success() {
339 eyre::bail!("required command `{name}` was not found in PATH");
340 }
341 }
342 if cli.foundry_ref.is_some() && !command_exists("cargo")? {
343 eyre::bail!("required command `cargo` was not found in PATH");
344 }
345 Ok(())
346}
347
348fn prepare_output_dir(output_dir: &Path, force: bool) -> Result<()> {
349 if output_dir.exists() && fs::symlink_metadata(output_dir)?.file_type().is_symlink() {
350 eyre::bail!("refusing to use symlink output directory {}", output_dir.display());
351 }
352
353 if output_dir.exists() {
354 if !force && dir_has_entries(output_dir)? {
355 eyre::bail!(
356 "output directory {} already exists and is not empty; pass --force to remove it",
357 output_dir.display()
358 );
359 }
360 if force {
361 if output_dir.parent().is_none() || output_dir == Path::new("/") {
362 eyre::bail!("refusing to remove unsafe output directory {}", output_dir.display());
363 }
364 let marker = output_dir.join(OUTPUT_MARKER);
365 if dir_has_entries(output_dir)? && !marker.exists() {
366 eyre::bail!(
367 "refusing to remove {} because it is not marked as a foundry-scfuzzbench output directory",
368 output_dir.display()
369 );
370 }
371 fs::remove_dir_all(output_dir)
372 .wrap_err_with(|| format!("failed to remove {}", output_dir.display()))?;
373 }
374 }
375 fs::create_dir_all(output_dir)
376 .wrap_err_with(|| format!("failed to create {}", output_dir.display()))?;
377 fs::write(output_dir.join(OUTPUT_MARKER), "foundry-scfuzzbench\n")?;
378 Ok(())
379}
380
381fn command_exists(name: &str) -> Result<bool> {
382 let status = Command::new("sh")
383 .arg("-c")
384 .arg(format!("command -v {name} >/dev/null 2>&1"))
385 .status()
386 .wrap_err_with(|| format!("failed to check for {name}"))?;
387 Ok(status.success())
388}
389
390fn make_executable(path: &Path) -> Result<()> {
391 #[cfg(unix)]
392 {
393 use std::os::unix::fs::PermissionsExt;
394
395 let mut permissions = fs::metadata(path)?.permissions();
396 permissions.set_mode(0o755);
397 fs::set_permissions(path, permissions)
398 .wrap_err_with(|| format!("failed to chmod {}", path.display()))?;
399 }
400 #[cfg(not(unix))]
401 {
402 let _ = path;
403 }
404 Ok(())
405}
406
407fn install_termination_handler() -> Result<()> {
408 ctrlc::set_handler(|| {
409 terminate_active_process_groups();
410 std::process::exit(130);
411 })
412 .wrap_err("failed to install termination handler")
413}
414
415fn install_date_shim(tools_bin: &Path) -> Result<()> {
416 let native_supports_iso_seconds = Command::new("date")
417 .arg("-Is")
418 .stdout(Stdio::null())
419 .stderr(Stdio::null())
420 .status()
421 .wrap_err("failed to check native date -Is support")?
422 .success();
423 if native_supports_iso_seconds {
424 return Ok(());
425 }
426
427 fs::create_dir_all(tools_bin)
428 .wrap_err_with(|| format!("failed to create {}", tools_bin.display()))?;
429 let shim = tools_bin.join("date");
430 let content = r#"#!/usr/bin/env bash
431if [[ "$#" -eq 1 && ( "$1" == "-Is" || "$1" == "-Iseconds" ) ]]; then
432 exec python3 -c 'from datetime import datetime, timezone; print(datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"))'
433fi
434exec /bin/date "$@"
435"#;
436
437 fs::write(&shim, content).wrap_err_with(|| format!("failed to write {}", shim.display()))?;
438 make_executable(&shim)?;
439 Ok(())
440}
441
442fn install_timeout_shim(tools_bin: &Path) -> Result<()> {
443 if command_exists("timeout")? {
444 return Ok(());
445 }
446
447 fs::create_dir_all(tools_bin)
448 .wrap_err_with(|| format!("failed to create {}", tools_bin.display()))?;
449 let shim = tools_bin.join("timeout");
450
451 let content = if command_exists("gtimeout")? {
452 r#"#!/usr/bin/env bash
453exec gtimeout "$@"
454"#
455 .to_string()
456 } else {
457 r#"#!/usr/bin/env python3
458import os
459import signal
460import subprocess
461import sys
462import time
463
464
465def parse_seconds(value):
466 if not value.endswith("s"):
467 raise ValueError(f"unsupported duration {value!r}; expected seconds ending in 's'")
468 return float(value[:-1])
469
470
471def main(argv):
472 if len(argv) < 5:
473 print("timeout shim supports: timeout --signal=SIGINT --kill-after=<seconds>s <seconds>s <cmd...>", file=sys.stderr)
474 return 125
475
476 sigarg = argv[1]
477 killarg = argv[2]
478 duration_arg = argv[3]
479 command = argv[4:]
480
481 if sigarg != "--signal=SIGINT":
482 print(f"unsupported timeout signal option: {sigarg}", file=sys.stderr)
483 return 125
484 if not killarg.startswith("--kill-after="):
485 print(f"unsupported timeout kill-after option: {killarg}", file=sys.stderr)
486 return 125
487
488 try:
489 duration = parse_seconds(duration_arg)
490 grace = parse_seconds(killarg.split("=", 1)[1])
491 except ValueError as exc:
492 print(str(exc), file=sys.stderr)
493 return 125
494
495 proc = subprocess.Popen(command, start_new_session=True)
496 try:
497 return proc.wait(timeout=duration)
498 except subprocess.TimeoutExpired:
499 try:
500 os.killpg(proc.pid, signal.SIGINT)
501 except ProcessLookupError:
502 pass
503 except PermissionError:
504 proc.send_signal(signal.SIGINT)
505
506 deadline = time.monotonic() + grace
507 while time.monotonic() < deadline:
508 code = proc.poll()
509 if code is not None:
510 return 124
511 time.sleep(0.1)
512
513 try:
514 os.killpg(proc.pid, signal.SIGKILL)
515 except ProcessLookupError:
516 pass
517 except PermissionError:
518 proc.kill()
519 proc.wait()
520 return 124
521
522
523if __name__ == "__main__":
524 sys.exit(main(sys.argv))
525"#
526 .to_string()
527 };
528
529 fs::write(&shim, content).wrap_err_with(|| format!("failed to write {}", shim.display()))?;
530 make_executable(&shim)?;
531 Ok(())
532}
533
534fn install_sed_shim(tools_bin: &Path) -> Result<()> {
535 let native_supports_gnu_in_place = Command::new("sh")
536 .arg("-c")
537 .arg(
538 r#"tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/foundry-scfuzzbench-sed.XXXXXX") || exit 1
539trap 'rm -rf "$tmpdir"' EXIT
540tmp="${tmpdir}/input"
541printf 'foo\n' > "$tmp"
542sed -i 's/foo/bar/' "$tmp" >/dev/null 2>&1 && grep -qx bar "$tmp"
543"#,
544 )
545 .status()
546 .wrap_err("failed to check native sed -i support")?
547 .success();
548 if native_supports_gnu_in_place {
549 return Ok(());
550 }
551
552 fs::create_dir_all(tools_bin)
553 .wrap_err_with(|| format!("failed to create {}", tools_bin.display()))?;
554 let shim = tools_bin.join("sed");
555
556 let content = if command_exists("gsed")? {
557 r#"#!/usr/bin/env bash
558exec gsed "$@"
559"#
560 .to_string()
561 } else {
562 r#"#!/usr/bin/env bash
563native_sed=/usr/bin/sed
564if [[ ! -x "${native_sed}" ]]; then
565 native_sed=/bin/sed
566fi
567
568if "${native_sed}" --version >/dev/null 2>&1; then
569 exec "${native_sed}" "$@"
570fi
571
572if [[ "${1:-}" == "-i" ]]; then
573 shift
574 exec "${native_sed}" -i '' "$@"
575fi
576
577exec "${native_sed}" "$@"
578"#
579 .to_string()
580 };
581
582 fs::write(&shim, content).wrap_err_with(|| format!("failed to write {}", shim.display()))?;
583 make_executable(&shim)?;
584 Ok(())
585}
586
587fn clone_at(repo: &str, git_ref: &str, dest: &Path) -> Result<String> {
588 fs::create_dir_all(dest).wrap_err_with(|| format!("failed to create {}", dest.display()))?;
589
590 let mut init = Command::new("git");
591 init.arg("init").arg(dest);
592 run_required(&mut init)?;
593
594 let mut remote = Command::new("git");
595 remote.current_dir(dest).args(["remote", "add", "origin", repo]);
596 run_required(&mut remote)?;
597
598 let mut fetch_command = Command::new("git");
599 fetch_command.current_dir(dest).args(["fetch", "--depth", "1", "origin", git_ref]);
600 let fetch = run_status(&mut fetch_command)
601 .wrap_err_with(|| format!("failed to fetch {repo}@{git_ref}"))?;
602 if !fetch.success() {
603 let mut fetch_full = Command::new("git");
604 fetch_full.current_dir(dest).args(["fetch", "origin", git_ref]);
605 run_required(&mut fetch_full)?;
606 }
607
608 let mut checkout = Command::new("git");
609 checkout.current_dir(dest).args(["checkout", "--detach", "FETCH_HEAD"]);
610 run_required(&mut checkout)?;
611
612 let mut rev_parse = Command::new("git");
613 rev_parse.current_dir(dest).args(["rev-parse", "HEAD"]);
614 output_text(&mut rev_parse).map(|s| s.trim().to_string())
615}
616
617fn select_foundry(cli: &Cli, dirs: &Dirs) -> Result<FoundrySelection> {
618 if let Some(foundry_bin) = &cli.foundry_bin {
619 let bin = foundry_bin
620 .canonicalize()
621 .wrap_err_with(|| format!("failed to canonicalize {}", foundry_bin.display()))?;
622 if !bin.is_file() {
623 eyre::bail!("--foundry-bin must point to a file: {}", bin.display());
624 }
625 if bin.file_name() != Some(OsStr::new("forge")) {
626 eyre::bail!("--foundry-bin must point to a binary named `forge`: {}", bin.display());
627 }
628 let bin_dir = bin
629 .parent()
630 .ok_or_else(|| eyre::eyre!("{} has no parent directory", bin.display()))?
631 .to_path_buf();
632 let env = run_env(&dirs.tools_bin, Some(&bin_dir), &dirs.home)?;
633 validate_selected_forge(&bin, &env)?;
634 let version_output = forge_version(&env)?;
635 return Ok(FoundrySelection {
636 mode: "bin",
637 label: "foundry-bin".to_string(),
638 bin,
639 repo: None,
640 ref_name: None,
641 commit: None,
642 version_output,
643 env,
644 });
645 }
646
647 if let Some(foundry_ref) = &cli.foundry_ref {
648 let foundry_checkout = dirs.work.join("foundry");
649 let foundry_commit = clone_at(&cli.foundry_repo, foundry_ref, &foundry_checkout)?;
650
651 let mut build = Command::new("cargo");
652 build.current_dir(&foundry_checkout).args([
653 "build",
654 "--locked",
655 "--profile",
656 "dist",
657 "--bin",
658 "forge",
659 ]);
660 run_required(&mut build)?;
661
662 let bin = foundry_checkout.join("target/dist/forge");
663 let bin_dir = bin
664 .parent()
665 .ok_or_else(|| eyre::eyre!("{} has no parent directory", bin.display()))?
666 .to_path_buf();
667 let env = run_env(&dirs.tools_bin, Some(&bin_dir), &dirs.home)?;
668 validate_selected_forge(&bin, &env)?;
669 let version_output = forge_version(&env)?;
670 let label = format!(
671 "foundry-ref-{}-{}",
672 sanitize_label(foundry_ref),
673 foundry_commit.chars().take(12).collect::<String>()
674 );
675 return Ok(FoundrySelection {
676 mode: "ref",
677 label,
678 bin: bin.canonicalize().unwrap_or(bin),
679 repo: Some(cli.foundry_repo.clone()),
680 ref_name: Some(foundry_ref.clone()),
681 commit: Some(foundry_commit),
682 version_output,
683 env,
684 });
685 }
686
687 let env = run_env(&dirs.tools_bin, None, &dirs.home)?;
688 let mut which_forge = Command::new("sh");
689 which_forge.arg("-c").arg("command -v forge");
690 env.apply(&mut which_forge);
691 let forge_path = output_text(&mut which_forge)?;
692 let bin = PathBuf::from(forge_path.trim())
693 .canonicalize()
694 .wrap_err_with(|| format!("failed to canonicalize {}", forge_path.trim()))?;
695 validate_selected_forge(&bin, &env)?;
696 let version_output = forge_version(&env)?;
697 Ok(FoundrySelection {
698 mode: "path",
699 label: "foundry-path".to_string(),
700 bin,
701 repo: None,
702 ref_name: None,
703 commit: None,
704 version_output,
705 env,
706 })
707}
708
709fn run_env(tools_bin: &Path, bin_dir: Option<&Path>, home: &Path) -> Result<RunEnv> {
710 let mut paths = Vec::new();
711 paths.push(tools_bin.to_path_buf());
712 if let Some(bin_dir) = bin_dir {
713 paths.push(bin_dir.to_path_buf());
714 }
715 if let Some(existing) = env::var_os("PATH") {
716 paths.extend(env::split_paths(&existing));
717 }
718 Ok(RunEnv { path: env::join_paths(paths)?, home: home.to_path_buf() })
719}
720
721fn validate_selected_forge(selected: &Path, env: &RunEnv) -> Result<()> {
722 let selected = selected.canonicalize().wrap_err_with(|| {
723 format!("failed to canonicalize selected forge {}", selected.display())
724 })?;
725 if !selected.is_file() {
726 eyre::bail!("selected forge is not a file: {}", selected.display());
727 }
728 if selected.file_name() != Some(OsStr::new("forge")) {
729 eyre::bail!("selected forge is not named `forge`: {}", selected.display());
730 }
731
732 let mut which_forge = Command::new("sh");
733 which_forge.arg("-c").arg("command -v forge");
734 env.apply(&mut which_forge);
735 let resolved = output_text(&mut which_forge)?;
736 let resolved = PathBuf::from(resolved.trim())
737 .canonicalize()
738 .wrap_err_with(|| format!("failed to canonicalize resolved forge {}", resolved.trim()))?;
739 if resolved != selected {
740 eyre::bail!(
741 "selected forge {} does not match PATH-resolved forge {}",
742 selected.display(),
743 resolved.display()
744 );
745 }
746 Ok(())
747}
748
749fn forge_version(env: &RunEnv) -> Result<String> {
750 let mut command = Command::new("forge");
751 env.apply(&mut command);
752 command.arg("--version");
753 output_text(&mut command)
754}
755
756fn run_campaign(
757 cli: &Cli,
758 dirs: &Dirs,
759 foundry: &FoundrySelection,
760 target_commit: &str,
761 run_id: &str,
762) -> Result<ExitStatus> {
763 let _ = sh_println!("🚀 Running scfuzzbench campaign");
764 let mut command = Command::new("bash");
765 command
766 .current_dir(&dirs.scfuzzbench)
767 .arg("scripts/local-run.sh")
768 .args(["-f", "foundry"])
769 .args(["-r", &cli.target_repo])
770 .args(["-b", target_commit])
771 .args(["-t", &cli.timeout_seconds.to_string()])
772 .args(["-T", cli.benchmark_type.as_str()]);
773
774 if let Some(workers) = cli.workers {
775 command.args(["-w", &workers.to_string()]);
776 command.env("FOUNDRY_THREADS", workers.to_string());
777 }
778 if let Some(foundry_test_args) = cli.foundry_test_args.as_deref() {
779 command.args(["--foundry-test-args", foundry_test_args]);
780 }
781 if let Some(properties_path) = &cli.properties_path {
782 command.env("SCFUZZBENCH_PROPERTIES_PATH", properties_path);
783 }
784
785 foundry.env.apply(&mut command);
786 command
787 .env("SCFUZZBENCH_ROOT", &dirs.scfuzz_root)
788 .env("SCFUZZBENCH_WORKDIR", &dirs.scfuzz_work)
789 .env("SCFUZZBENCH_LOG_DIR", &dirs.scfuzz_logs)
790 .env("SCFUZZBENCH_LOCAL_OUTPUT_DIR", &dirs.raw)
791 .env("SCFUZZBENCH_RUN_ID", run_id)
792 .env("SCFUZZBENCH_INSTANCE_ID", run_id)
793 .env("SCFUZZBENCH_FUZZER_LABEL", &foundry.label)
794 .env("FOUNDRY_LABEL", &foundry.label)
795 .env("SCFUZZBENCH_FOUNDRY_SHOWMAP", "1")
796 .stdout(Stdio::inherit())
797 .stderr(Stdio::inherit());
798
799 run_status(&mut command).wrap_err("failed to execute scripts/local-run.sh")
800}
801
802fn run_analysis(cli: &Cli, dirs: &Dirs, foundry: &FoundrySelection, run_id: &str) -> Result<()> {
803 let _ = sh_println!("📊 Running scfuzzbench analysis");
804 let prepared_logs = dirs.unzipped.join(&foundry.label).join("logs");
805 fs::create_dir_all(&prepared_logs)
806 .wrap_err_with(|| format!("failed to create {}", prepared_logs.display()))?;
807 copy_analysis_logs(&dirs.scfuzz_logs, &prepared_logs)?;
808
809 make(
810 dirs,
811 &[
812 OsString::from("results-prepare"),
813 make_var("UNZIPPED_DIR", &dirs.unzipped),
814 make_var("ANALYSIS_LOGS_DIR", &dirs.analysis_logs),
815 ],
816 )?;
817 make(
818 dirs,
819 &[
820 OsString::from("results-analyze-filtered"),
821 make_var("ANALYSIS_LOGS_DIR", &dirs.analysis_logs),
822 make_var("ANALYSIS_OUT_DIR", &dirs.data),
823 make_str_var("RUN_ID", run_id),
824 ],
825 )?;
826 make(
827 dirs,
828 &[
829 OsString::from("report-events-to-cumulative"),
830 make_var("ANALYSIS_LOGS_DIR", &dirs.analysis_logs),
831 make_var("ANALYSIS_OUT_DIR", &dirs.data),
832 make_var("EVENTS_CSV", &dirs.data.join("events.csv")),
833 make_var("CUMULATIVE_CSV", &dirs.data.join("cumulative.csv")),
834 make_str_var("RUN_ID", run_id),
835 ],
836 )?;
837
838 let report_budget = format!("{:.3}", cli.timeout_seconds as f64 / 3600.0);
839 make(
840 dirs,
841 &[
842 OsString::from("report-benchmark"),
843 make_var("ANALYSIS_LOGS_DIR", &dirs.analysis_logs),
844 make_var("ANALYSIS_OUT_DIR", &dirs.data),
845 make_var("REPORT_CSV", &dirs.data.join("cumulative.csv")),
846 make_var("REPORT_OUT_DIR", &dirs.data),
847 make_var("IMAGES_OUT_DIR", &dirs.images),
848 make_str_var("REPORT_BUDGET", &report_budget),
849 ],
850 )?;
851 make(
852 dirs,
853 &[
854 OsString::from("report-invariant-overlap"),
855 make_var("ANALYSIS_LOGS_DIR", &dirs.analysis_logs),
856 make_var("ANALYSIS_OUT_DIR", &dirs.data),
857 make_var("EVENTS_CSV", &dirs.data.join("events.csv")),
858 make_var("IMAGES_OUT_DIR", &dirs.images),
859 make_str_var("REPORT_BUDGET", &report_budget),
860 ],
861 )?;
862 make(
863 dirs,
864 &[
865 OsString::from("report-runner-metrics"),
866 make_var("ANALYSIS_LOGS_DIR", &dirs.analysis_logs),
867 make_var("ANALYSIS_OUT_DIR", &dirs.data),
868 make_var("IMAGES_OUT_DIR", &dirs.images),
869 make_str_var("RUN_ID", run_id),
870 make_str_var("REPORT_BUDGET", &report_budget),
871 ],
872 )?;
873 Ok(())
874}
875
876fn validate_campaign_logs(dirs: &Dirs) -> Result<()> {
877 let foundry_log = dirs.scfuzz_logs.join("foundry.log");
878 ensure_non_empty_file(&foundry_log, "campaign foundry log")?;
879
880 let commands_log = dirs.scfuzz_logs.join("runner_commands.log");
881 ensure_non_empty_file(&commands_log, "campaign runner commands log")?;
882 let commands = fs::read_to_string(&commands_log)
883 .wrap_err_with(|| format!("failed to read {}", commands_log.display()))?;
884 if !commands.contains("forge test --mc CryticToFoundry") {
885 eyre::bail!(
886 "{} did not contain expected Foundry campaign command `forge test --mc CryticToFoundry`",
887 commands_log.display()
888 );
889 }
890 Ok(())
891}
892
893fn validate_differential_coverage(dirs: &Dirs) -> Result<()> {
894 let manifest_path = dirs.data.join("showmap_campaign_manifest.json");
895 ensure_non_empty_file(&manifest_path, "showmap campaign manifest")?;
896 let manifest: serde_json::Value = serde_json::from_str(
897 &fs::read_to_string(&manifest_path)
898 .wrap_err_with(|| format!("failed to read {}", manifest_path.display()))?,
899 )
900 .wrap_err_with(|| format!("failed to parse {}", manifest_path.display()))?;
901
902 let raw_trials = manifest.get("raw_trials").and_then(serde_json::Value::as_u64).unwrap_or(0);
903 if raw_trials == 0 {
904 eyre::bail!("{} has raw_trials=0", manifest_path.display());
905 }
906
907 let approaches = combined_approaches(&manifest_path, &manifest)?;
908 let has_covered_trial = approaches.iter().any(|entry| {
909 let trials = entry.get("trials").and_then(serde_json::Value::as_u64).unwrap_or(0);
910 let covered_edges =
911 entry.get("covered_edges").and_then(serde_json::Value::as_u64).unwrap_or(0);
912 trials > 0 && covered_edges > 0
913 });
914 if !has_covered_trial {
915 eyre::bail!(
916 "{} has no campaigns.combined approach with trials > 0 and covered_edges > 0",
917 manifest_path.display()
918 );
919 }
920
921 ensure_csv_has_data_row(&dirs.data.join("differential_coverage_relscores.csv"))?;
922
923 let relcov = dirs.data.join("differential_coverage_relcov.csv");
924 if approaches.len() > 1 {
925 ensure_csv_has_data_row(&relcov)?;
926 } else {
927 ensure_non_empty_file(&relcov, "differential coverage CSV")?;
928 }
929 Ok(())
930}
931
932fn combined_approaches<'a>(
933 manifest_path: &Path,
934 manifest: &'a serde_json::Value,
935) -> Result<Vec<&'a serde_json::Value>> {
936 let combined = manifest
937 .get("campaigns")
938 .and_then(|campaigns| campaigns.get("combined"))
939 .and_then(serde_json::Value::as_object)
940 .ok_or_else(|| {
941 eyre::eyre!("{} does not contain campaigns.combined", manifest_path.display())
942 })?;
943
944 let approaches = match combined.get("approaches") {
945 Some(approaches) => approaches.as_object().ok_or_else(|| {
946 eyre::eyre!(
947 "{} campaigns.combined.approaches is not an object",
948 manifest_path.display()
949 )
950 })?,
951 None => combined,
952 };
953
954 if approaches.is_empty() {
955 eyre::bail!("{} has empty campaigns.combined approaches", manifest_path.display());
956 }
957
958 Ok(approaches.values().collect())
959}
960
961fn ensure_non_empty_file(path: &Path, label: &str) -> Result<()> {
962 let metadata =
963 fs::metadata(path).wrap_err_with(|| format!("missing {label}: {}", path.display()))?;
964 if !metadata.is_file() || metadata.len() == 0 {
965 eyre::bail!("{label} is empty or not a file: {}", path.display());
966 }
967 Ok(())
968}
969
970fn ensure_csv_has_data_row(path: &Path) -> Result<()> {
971 ensure_non_empty_file(path, "differential coverage CSV")?;
972 let contents =
973 fs::read_to_string(path).wrap_err_with(|| format!("failed to read {}", path.display()))?;
974 let non_empty_lines = contents.lines().filter(|line| !line.trim().is_empty()).count();
975 if non_empty_lines < 2 {
976 eyre::bail!("{} has no data rows", path.display());
977 }
978 Ok(())
979}
980
981fn make(dirs: &Dirs, args: &[OsString]) -> Result<()> {
982 let mut command = Command::new("make");
983 command
984 .current_dir(&dirs.scfuzzbench)
985 .args(args)
986 .stdout(Stdio::inherit())
987 .stderr(Stdio::inherit());
988 run_required(&mut command)
989}
990
991fn make_var(name: &str, path: &Path) -> OsString {
992 let mut value = OsString::from(name);
993 value.push("=");
994 value.push(path.as_os_str());
995 value
996}
997
998fn make_str_var(name: &str, value: &str) -> OsString {
999 OsString::from(format!("{name}={value}"))
1000}
1001
1002fn collect_artifacts(dirs: &Dirs) -> Result<Vec<String>> {
1003 let _ = sh_println!("📁 Collecting deterministic artifact bundle");
1004 fs::create_dir_all(&dirs.artifacts)?;
1005
1006 let mut missing = Vec::new();
1007 for artifact in REQUIRED_DATA_ARTIFACTS {
1008 let src = dirs.data.join(artifact);
1009 let dest = dirs.artifacts.join(artifact);
1010 if src.exists() {
1011 copy_path(&src, &dest)?;
1012 } else {
1013 missing.push((*artifact).to_string());
1014 }
1015 }
1016
1017 copy_if_exists(
1018 &dirs.data.join("showmap_campaigns"),
1019 &dirs.artifacts.join("showmap_campaigns"),
1020 )?;
1021 copy_if_exists(&dirs.images, &dirs.artifacts.join("images"))?;
1022 collect_raw_archives(&dirs.raw, &dirs.artifacts.join("raw"))?;
1023 collect_lcov_outputs(dirs, &dirs.artifacts.join("lcov-diff"))?;
1024
1025 Ok(missing)
1026}
1027
1028fn collect_raw_archives(raw: &Path, dest: &Path) -> Result<()> {
1029 let logs = find_named(raw, "logs.zip")?;
1030 let corpus = find_named(raw, "corpus.zip")?;
1031 if logs.is_empty() && corpus.is_empty() {
1032 return Ok(());
1033 }
1034 fs::create_dir_all(dest)?;
1035 if let Some(path) = logs.first() {
1036 fs::copy(path, dest.join("logs.zip"))?;
1037 }
1038 if let Some(path) = corpus.first() {
1039 fs::copy(path, dest.join("corpus.zip"))?;
1040 }
1041 Ok(())
1042}
1043
1044fn collect_lcov_outputs(dirs: &Dirs, dest: &Path) -> Result<()> {
1045 let mut matches = Vec::new();
1046 for root in [&dirs.raw, &dirs.data, &dirs.work] {
1047 find_lcov_like(root, &mut matches)?;
1048 }
1049 matches.sort();
1050 if matches.is_empty() {
1051 return Ok(());
1052 }
1053 fs::create_dir_all(dest)?;
1054 for path in matches {
1055 if let Some(name) = path.file_name() {
1056 copy_path(&path, &dest.join(name))?;
1057 }
1058 }
1059 Ok(())
1060}
1061
1062fn write_manifest(
1063 cli: &Cli,
1064 dirs: &Dirs,
1065 foundry: &FoundrySelection,
1066 metadata: &RunMetadata<'_>,
1067) -> Result<PathBuf> {
1068 let artifacts = list_relative_files(&dirs.artifacts)?;
1069 let manifest = json!({
1070 "scfuzzbench": {
1071 "repo": &cli.scfuzzbench_repo,
1072 "ref": &cli.scfuzzbench_ref,
1073 "commit": metadata.scfuzzbench_commit,
1074 },
1075 "target": {
1076 "repo": &cli.target_repo,
1077 "ref": &cli.target_ref,
1078 "commit": metadata.target_commit,
1079 },
1080 "foundry": {
1081 "mode": foundry.mode,
1082 "label": &foundry.label,
1083 "bin": foundry.bin.display().to_string(),
1084 "repo": foundry.repo.as_deref(),
1085 "ref": foundry.ref_name.as_deref(),
1086 "commit": foundry.commit.as_deref(),
1087 "version_output": foundry.version_output.trim(),
1088 },
1089 "campaign": {
1090 "benchmark_type": cli.benchmark_type.as_str(),
1091 "timeout_seconds": cli.timeout_seconds,
1092 "workers": cli.workers,
1093 "run_id": metadata.run_id,
1094 "exit_code": metadata.campaign_exit_code,
1095 "foundry_test_args": cli.foundry_test_args.as_deref(),
1096 "properties_path": cli.properties_path.as_ref().map(|path| path.display().to_string()),
1097 },
1098 "artifacts": artifacts,
1099 });
1100 let path = dirs.artifacts.join("manifest.json");
1101 fs::write(&path, serde_json::to_string_pretty(&manifest)? + "\n")?;
1102 Ok(path)
1103}
1104
1105fn write_llm_summary(
1106 cli: &Cli,
1107 dirs: &Dirs,
1108 foundry: &FoundrySelection,
1109 metadata: &RunMetadata<'_>,
1110 missing: &[String],
1111) -> Result<PathBuf> {
1112 let mut lines = vec![
1113 "# Foundry scfuzzbench summary".to_string(),
1114 String::new(),
1115 format!(
1116 "- scfuzzbench: `{}` @ `{}` (`{}`)",
1117 cli.scfuzzbench_repo, cli.scfuzzbench_ref, metadata.scfuzzbench_commit
1118 ),
1119 format!(
1120 "- target: `{}` @ `{}` (`{}`)",
1121 cli.target_repo, cli.target_ref, metadata.target_commit
1122 ),
1123 format!("- foundry: `{}` ({})", foundry.version_output.trim(), foundry.mode),
1124 format!("- benchmark type: `{}`", cli.benchmark_type.as_str()),
1125 format!("- timeout seconds: `{}`", cli.timeout_seconds),
1126 format!(
1127 "- workers: `{}`",
1128 cli.workers.map(|w| w.to_string()).unwrap_or_else(|| "default".to_string())
1129 ),
1130 format!("- run id: `{}`", metadata.run_id),
1131 format!(
1132 "- campaign exit code: `{}`",
1133 metadata
1134 .campaign_exit_code
1135 .map(|c| c.to_string())
1136 .unwrap_or_else(|| "signal/unknown".to_string())
1137 ),
1138 format!(
1139 "- required artifacts missing: `{}`",
1140 if missing.is_empty() { "none".to_string() } else { missing.join(", ") }
1141 ),
1142 String::new(),
1143 "## Primary artifacts".to_string(),
1144 String::new(),
1145 "- `REPORT.md`".to_string(),
1146 "- `events.csv`, `summary.csv`, `cumulative.csv`".to_string(),
1147 "- `showmap_campaign_manifest.json` and `showmap_campaigns/`".to_string(),
1148 "- `differential_coverage_relscores.csv` and `differential_coverage_relcov.csv`"
1149 .to_string(),
1150 ];
1151
1152 let report = dirs.artifacts.join("REPORT.md");
1153 if report.exists() {
1154 let preview = fs::read_to_string(&report)
1155 .unwrap_or_default()
1156 .lines()
1157 .filter(|line| !line.trim().is_empty())
1158 .take(12)
1159 .map(str::to_string)
1160 .collect::<Vec<_>>();
1161 if !preview.is_empty() {
1162 lines.extend([String::new(), "## Report preview".to_string(), String::new()]);
1163 lines.extend(preview);
1164 }
1165 }
1166
1167 let path = dirs.artifacts.join("llm_summary.md");
1168 fs::write(&path, lines.join("\n") + "\n")?;
1169 Ok(path)
1170}
1171
1172fn run_required(command: &mut Command) -> Result<()> {
1173 let display = command_display(command);
1174 let status = run_status(command)?;
1175 if !status.success() {
1176 eyre::bail!("command failed ({status}): {display}");
1177 }
1178 Ok(())
1179}
1180
1181fn run_status(command: &mut Command) -> Result<ExitStatus> {
1182 let display = command_display(command);
1183 let (mut child, mut guard) = spawn_guarded(command, &display)?;
1184 let status = child.wait().wrap_err_with(|| format!("failed to wait for {display}"))?;
1185 guard.finish();
1186 Ok(status)
1187}
1188
1189fn output_text(command: &mut Command) -> Result<String> {
1190 let display = command_display(command);
1191 let output = guarded_output(command, &display)?;
1192 if !output.status.success() {
1193 eyre::bail!(
1194 "command failed ({}): {}\nstdout:\n{}\nstderr:\n{}",
1195 output.status,
1196 display,
1197 String::from_utf8_lossy(&output.stdout),
1198 String::from_utf8_lossy(&output.stderr)
1199 );
1200 }
1201 Ok(String::from_utf8(output.stdout)?.trim().to_string())
1202}
1203
1204fn guarded_output(command: &mut Command, display: &str) -> Result<Output> {
1205 command.stdout(Stdio::piped()).stderr(Stdio::piped());
1206 let (child, mut guard) = spawn_guarded(command, display)?;
1207 let output =
1208 child.wait_with_output().wrap_err_with(|| format!("failed to wait for {display}"))?;
1209 guard.finish();
1210 Ok(output)
1211}
1212
1213fn spawn_guarded(command: &mut Command, display: &str) -> Result<(Child, ActiveProcessGroup)> {
1214 configure_process_group(command);
1215 let child = command.spawn().wrap_err_with(|| format!("failed to execute {display}"))?;
1216 let guard = ActiveProcessGroup::new(child.id() as ProcessGroupId);
1217 Ok((child, guard))
1218}
1219
1220#[cfg(unix)]
1221fn configure_process_group(command: &mut Command) {
1222 command.process_group(0);
1223}
1224
1225#[cfg(not(unix))]
1226fn configure_process_group(_command: &mut Command) {}
1227
1228struct ActiveProcessGroup {
1229 pgid: ProcessGroupId,
1230 finished: bool,
1231}
1232
1233impl ActiveProcessGroup {
1234 fn new(pgid: ProcessGroupId) -> Self {
1235 ACTIVE_PROCESS_GROUPS.lock().expect("active process group lock poisoned").insert(pgid);
1236 Self { pgid, finished: false }
1237 }
1238
1239 fn finish(&mut self) {
1240 terminate_process_group(self.pgid);
1241 self.finished = true;
1242 unregister_active_process_group(self.pgid);
1243 }
1244}
1245
1246impl Drop for ActiveProcessGroup {
1247 fn drop(&mut self) {
1248 unregister_active_process_group(self.pgid);
1249 if !self.finished {
1250 terminate_process_group(self.pgid);
1251 }
1252 }
1253}
1254
1255fn unregister_active_process_group(pgid: ProcessGroupId) {
1256 ACTIVE_PROCESS_GROUPS.lock().expect("active process group lock poisoned").remove(&pgid);
1257}
1258
1259fn terminate_active_process_groups() {
1260 let pgids = ACTIVE_PROCESS_GROUPS
1261 .lock()
1262 .expect("active process group lock poisoned")
1263 .iter()
1264 .copied()
1265 .collect::<Vec<_>>();
1266 for pgid in pgids {
1267 terminate_process_group(pgid);
1268 }
1269}
1270
1271#[cfg(unix)]
1272fn terminate_process_group(pgid: ProcessGroupId) {
1273 if matches!(signal_process_group(pgid, libc::SIGINT), Ok(true)) {
1274 std::thread::sleep(PROCESS_GROUP_GRACE);
1275 let _ = signal_process_group(pgid, libc::SIGKILL);
1276 }
1277}
1278
1279#[cfg(not(unix))]
1280fn terminate_process_group(_pgid: ProcessGroupId) {}
1281
1282#[cfg(unix)]
1283fn signal_process_group(pgid: ProcessGroupId, signal: libc::c_int) -> std::io::Result<bool> {
1284 let rc = unsafe { libc::kill(-pgid, signal) };
1286 if rc == 0 {
1287 Ok(true)
1288 } else {
1289 let err = std::io::Error::last_os_error();
1290 if err.raw_os_error() == Some(libc::ESRCH) { Ok(false) } else { Err(err) }
1291 }
1292}
1293
1294fn command_display(command: &Command) -> String {
1295 let mut parts = vec![command.get_program().to_string_lossy().to_string()];
1296 parts.extend(command.get_args().map(|arg| arg.to_string_lossy().to_string()));
1297 parts.join(" ")
1298}
1299
1300fn dir_has_entries(path: &Path) -> Result<bool> {
1301 if !path.exists() {
1302 return Ok(false);
1303 }
1304 Ok(fs::read_dir(path)?.next().is_some())
1305}
1306
1307fn copy_if_exists(src: &Path, dest: &Path) -> Result<()> {
1308 if src.exists() {
1309 copy_path(src, dest)?;
1310 }
1311 Ok(())
1312}
1313
1314fn copy_path(src: &Path, dest: &Path) -> Result<()> {
1315 if src.is_dir() {
1316 copy_dir(src, dest)
1317 } else {
1318 if let Some(parent) = dest.parent() {
1319 fs::create_dir_all(parent)?;
1320 }
1321 fs::copy(src, dest)
1322 .wrap_err_with(|| format!("failed to copy {} to {}", src.display(), dest.display()))?;
1323 Ok(())
1324 }
1325}
1326
1327fn copy_dir(src: &Path, dest: &Path) -> Result<()> {
1328 if dest.exists() {
1329 fs::remove_dir_all(dest)?;
1330 }
1331 fs::create_dir_all(dest)?;
1332 copy_dir_contents(src, dest)
1333}
1334
1335fn copy_dir_contents(src: &Path, dest: &Path) -> Result<()> {
1336 fs::create_dir_all(dest)?;
1337 let mut entries = fs::read_dir(src)
1338 .wrap_err_with(|| format!("failed to read {}", src.display()))?
1339 .collect::<std::io::Result<Vec<_>>>()?;
1340 entries.sort_by_key(|entry| entry.file_name());
1341 for entry in entries {
1342 let src_path = entry.path();
1343 let dest_path = dest.join(entry.file_name());
1344 copy_path(&src_path, &dest_path)?;
1345 }
1346 Ok(())
1347}
1348
1349fn copy_analysis_logs(src: &Path, dest: &Path) -> Result<()> {
1350 fs::create_dir_all(dest)?;
1351 let mut entries = fs::read_dir(src)
1352 .wrap_err_with(|| format!("failed to read {}", src.display()))?
1353 .collect::<std::io::Result<Vec<_>>>()?;
1354 entries.sort_by_key(|entry| entry.file_name());
1355 for entry in entries {
1356 let src_path = entry.path();
1357 let file_name = entry.file_name();
1358 let dest_path = dest.join(&file_name);
1359 if src_path.is_dir() {
1360 copy_analysis_logs(&src_path, &dest_path)?;
1361 continue;
1362 }
1363 if is_showmap_log(&file_name) {
1364 continue;
1365 }
1366 copy_path(&src_path, &dest_path)?;
1367 }
1368 Ok(())
1369}
1370
1371fn is_showmap_log(file_name: &OsStr) -> bool {
1372 let name = file_name.to_string_lossy();
1373 name == "foundry_showmap.log" || name.ends_with("_showmap.log")
1374}
1375
1376fn find_named(root: &Path, name: &str) -> Result<Vec<PathBuf>> {
1377 let mut out = Vec::new();
1378 find_named_inner(root, OsStr::new(name), &mut out)?;
1379 out.sort();
1380 Ok(out)
1381}
1382
1383fn find_named_inner(root: &Path, name: &OsStr, out: &mut Vec<PathBuf>) -> Result<()> {
1384 if !root.exists() {
1385 return Ok(());
1386 }
1387 for entry in fs::read_dir(root)? {
1388 let entry = entry?;
1389 let path = entry.path();
1390 if path.file_name() == Some(name) {
1391 out.push(path.clone());
1392 }
1393 if path.is_dir() {
1394 find_named_inner(&path, name, out)?;
1395 }
1396 }
1397 Ok(())
1398}
1399
1400fn find_lcov_like(root: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
1401 if !root.exists() {
1402 return Ok(());
1403 }
1404 for entry in fs::read_dir(root)? {
1405 let entry = entry?;
1406 let path = entry.path();
1407 let name = entry.file_name().to_string_lossy().to_ascii_lowercase();
1408 if is_lcov_like_name(&name) {
1409 out.push(path.clone());
1410 } else if path.is_dir() {
1411 find_lcov_like(&path, out)?;
1412 }
1413 }
1414 Ok(())
1415}
1416
1417fn is_lcov_like_name(name: &str) -> bool {
1418 name.contains("coverage-diff")
1419 || name.contains("coverage_diff")
1420 || name.split(|ch: char| !ch.is_ascii_alphanumeric()).any(|part| part == "lcov")
1421}
1422
1423fn list_relative_files(root: &Path) -> Result<Vec<String>> {
1424 let mut files = Vec::new();
1425 list_relative_files_inner(root, root, &mut files)?;
1426 files.sort();
1427 Ok(files)
1428}
1429
1430fn list_relative_files_inner(root: &Path, current: &Path, files: &mut Vec<String>) -> Result<()> {
1431 if !current.exists() {
1432 return Ok(());
1433 }
1434 for entry in fs::read_dir(current)? {
1435 let entry = entry?;
1436 let path = entry.path();
1437 if path.is_dir() {
1438 list_relative_files_inner(root, &path, files)?;
1439 } else {
1440 files.push(path.strip_prefix(root)?.display().to_string());
1441 }
1442 }
1443 Ok(())
1444}
1445
1446fn sanitize_label(value: &str) -> String {
1447 value
1448 .chars()
1449 .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
1450 .collect::<String>()
1451 .trim_matches('-')
1452 .to_string()
1453}
1454
1455#[cfg(test)]
1456mod tests {
1457 use super::*;
1458
1459 fn base_cli() -> Cli {
1460 Cli {
1461 scfuzzbench_repo: DEFAULT_SCFUZZBENCH_REPO.to_string(),
1462 scfuzzbench_ref: DEFAULT_SCFUZZBENCH_REF.to_string(),
1463 target_repo: "https://github.com/example/target.git".to_string(),
1464 target_ref: "main".to_string(),
1465 benchmark_type: BenchmarkType::Property,
1466 timeout_seconds: 60,
1467 workers: None,
1468 output_dir: PathBuf::from("out"),
1469 foundry_bin: None,
1470 foundry_ref: None,
1471 foundry_repo: DEFAULT_FOUNDRY_REPO.to_string(),
1472 foundry_test_args: None,
1473 properties_path: None,
1474 force: false,
1475 }
1476 }
1477
1478 fn temp_dirs() -> (tempfile::TempDir, Dirs) {
1479 let temp = tempfile::tempdir().expect("failed to create temp dir");
1480 let dirs = Dirs::new(temp.path().join("run"));
1481 dirs.create().expect("failed to create scfuzzbench dirs");
1482 (temp, dirs)
1483 }
1484
1485 fn write_differential_coverage_inputs(dirs: &Dirs, manifest: serde_json::Value, relcov: &str) {
1486 fs::write(
1487 dirs.data.join("showmap_campaign_manifest.json"),
1488 serde_json::to_vec_pretty(&manifest).expect("failed to serialize manifest"),
1489 )
1490 .expect("failed to write manifest");
1491 fs::write(
1492 dirs.data.join("differential_coverage_relscores.csv"),
1493 "approach,score\nfoundry,1\n",
1494 )
1495 .expect("failed to write relscores");
1496 fs::write(dirs.data.join("differential_coverage_relcov.csv"), relcov)
1497 .expect("failed to write relcov");
1498 }
1499
1500 #[test]
1501 fn validates_repo_relative_properties_path() {
1502 let mut cli = base_cli();
1503 cli.properties_path = Some(PathBuf::from("test/recon/Properties.sol"));
1504 validate_options(&cli).expect("repo-relative properties path should be valid");
1505 }
1506
1507 #[cfg(unix)]
1508 #[test]
1509 fn rejects_absolute_properties_path() {
1510 let mut cli = base_cli();
1511 cli.properties_path = Some(PathBuf::from("/tmp/Properties.sol"));
1512 let err = validate_options(&cli).expect_err("absolute properties path should fail");
1513 assert!(err.to_string().contains("must be relative"));
1514 }
1515
1516 #[test]
1517 fn rejects_parent_dir_properties_path() {
1518 let mut cli = base_cli();
1519 cli.properties_path = Some(PathBuf::from("../Properties.sol"));
1520 let err = validate_options(&cli).expect_err("escaping properties path should fail");
1521 assert!(err.to_string().contains("must not escape"));
1522 }
1523
1524 #[cfg(unix)]
1525 #[test]
1526 fn non_zero_campaign_status_is_an_error() {
1527 let status =
1528 Command::new("sh").arg("-c").arg("exit 7").status().expect("failed to execute shell");
1529 let err = ensure_campaign_success(&status).expect_err("campaign failure should fail");
1530 assert!(err.to_string().contains("campaign failed"));
1531 }
1532
1533 #[test]
1534 fn installed_sed_shim_accepts_gnu_no_backup_in_place_form() {
1535 let temp = tempfile::tempdir().expect("failed to create temp dir");
1536 install_sed_shim(temp.path()).expect("failed to install sed shim");
1537 let shim = temp.path().join("sed");
1538 if !shim.exists() {
1539 return;
1540 }
1541
1542 let input = temp.path().join("input");
1543 fs::write(&input, "foo\n").expect("failed to write sed input");
1544 let status = Command::new(&shim)
1545 .args(["-i", "s/foo/bar/"])
1546 .arg(&input)
1547 .status()
1548 .expect("failed to run sed shim");
1549 assert!(status.success());
1550 assert_eq!(fs::read_to_string(&input).expect("failed to read sed output"), "bar\n");
1551 }
1552
1553 #[test]
1554 fn validates_nested_showmap_approaches_with_header_only_single_relcov() {
1555 let (_temp, dirs) = temp_dirs();
1556 write_differential_coverage_inputs(
1557 &dirs,
1558 json!({
1559 "raw_trials": 1,
1560 "campaigns": {
1561 "combined": {
1562 "approaches": {
1563 "foundry": {
1564 "trials": 1,
1565 "covered_edges": 12
1566 }
1567 }
1568 }
1569 }
1570 }),
1571 "approach,relative_coverage\n",
1572 );
1573
1574 validate_differential_coverage(&dirs)
1575 .expect("single-approach header-only relcov should be accepted");
1576 }
1577
1578 #[test]
1579 fn validates_multi_approach_relcov_rows() {
1580 let (_temp, dirs) = temp_dirs();
1581 let manifest = json!({
1582 "raw_trials": 2,
1583 "campaigns": {
1584 "combined": {
1585 "approaches": {
1586 "foundry": {
1587 "trials": 1,
1588 "covered_edges": 12
1589 },
1590 "echidna": {
1591 "trials": 1,
1592 "covered_edges": 10
1593 }
1594 }
1595 }
1596 }
1597 });
1598 write_differential_coverage_inputs(&dirs, manifest.clone(), "approach,relative_coverage\n");
1599
1600 let err = validate_differential_coverage(&dirs)
1601 .expect_err("multi-approach relcov should require data rows");
1602 assert!(err.to_string().contains("has no data rows"));
1603
1604 write_differential_coverage_inputs(
1605 &dirs,
1606 manifest,
1607 "approach,relative_coverage\nfoundry,1.0\n",
1608 );
1609 validate_differential_coverage(&dirs)
1610 .expect("multi-approach relcov with data rows should be accepted");
1611 }
1612
1613 #[test]
1614 fn does_not_collect_relcov_as_lcov_output() {
1615 let (_temp, dirs) = temp_dirs();
1616 fs::write(dirs.data.join("differential_coverage_relcov.csv"), "header\n")
1617 .expect("failed to write relcov");
1618 fs::write(dirs.data.join("coverage_diff.csv"), "diff\n")
1619 .expect("failed to write coverage diff");
1620 fs::create_dir_all(dirs.raw.join("nested")).expect("failed to create raw nested dir");
1621 fs::write(dirs.raw.join("nested/run-lcov.info"), "lcov\n")
1622 .expect("failed to write lcov file");
1623
1624 let dest = dirs.artifacts.join("lcov-diff");
1625 collect_lcov_outputs(&dirs, &dest).expect("failed to collect lcov outputs");
1626
1627 assert!(dest.join("coverage_diff.csv").exists());
1628 assert!(dest.join("run-lcov.info").exists());
1629 assert!(!dest.join("differential_coverage_relcov.csv").exists());
1630 }
1631}