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