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