Skip to main content

foundry_scfuzzbench/
scfuzzbench.rs

1//! Run local Foundry scfuzzbench campaigns and collect deterministic artifacts.
2
3use 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/// Run a local Foundry scfuzzbench campaign and collect deterministic artifacts.
57#[derive(Parser, Debug)]
58#[clap(
59    name = "foundry-scfuzzbench",
60    about = "Run Foundry scfuzzbench campaigns and collect analysis artifacts"
61)]
62struct Cli {
63    /// scfuzzbench repository to clone.
64    #[clap(long, default_value = DEFAULT_SCFUZZBENCH_REPO)]
65    scfuzzbench_repo: String,
66
67    /// scfuzzbench branch, tag, or commit to pin.
68    #[clap(long, default_value = DEFAULT_SCFUZZBENCH_REF)]
69    scfuzzbench_ref: String,
70
71    /// Target benchmark repository to run scfuzzbench against.
72    #[clap(long)]
73    target_repo: String,
74
75    /// Target benchmark branch, tag, or commit to pin.
76    #[clap(long)]
77    target_ref: String,
78
79    /// scfuzzbench benchmark type.
80    #[clap(long, value_enum)]
81    benchmark_type: BenchmarkType,
82
83    /// Campaign timeout in seconds.
84    #[clap(long)]
85    timeout_seconds: u64,
86
87    /// Number of Foundry worker threads.
88    #[clap(long)]
89    workers: Option<u64>,
90
91    /// Deterministic output directory for work files and final artifacts.
92    #[clap(long)]
93    output_dir: PathBuf,
94
95    /// Path to the forge binary to benchmark. Mutually exclusive with --foundry-ref.
96    #[clap(long, conflicts_with = "foundry_ref")]
97    foundry_bin: Option<PathBuf>,
98
99    /// Foundry branch, tag, or commit to build and benchmark. Mutually exclusive with
100    /// --foundry-bin.
101    #[clap(long, conflicts_with = "foundry_bin")]
102    foundry_ref: Option<String>,
103
104    /// Foundry repository to clone when --foundry-ref is used.
105    #[clap(long, default_value = DEFAULT_FOUNDRY_REPO)]
106    foundry_repo: String,
107
108    /// Extra arguments passed to scfuzzbench as --foundry-test-args.
109    #[clap(long)]
110    foundry_test_args: Option<String>,
111
112    /// Target-repository-relative properties path passed as SCFUZZBENCH_PROPERTIES_PATH.
113    /// Required for optimization mode.
114    #[clap(long)]
115    properties_path: Option<PathBuf>,
116
117    /// Remove --output-dir before running if it already exists.
118    #[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    // SAFETY: negative pid targets the process group created for the child process.
1285    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}