forge-guard 0.3.6

Pre-deployment smart contract auditing framework for Foundry
Documentation
//! Fuzzing adapter — provides integration points for fuzzing campaigns.

use crate::core::ForgeGuardError;

/// Configuration for a fuzzing campaign.
pub struct FuzzingConfig {
    /// Number of fuzz runs.
    pub runs: u32,
    /// Fuzz seed for reproducibility.
    pub seed: Option<u64>,
    /// Test function filter.
    pub test_filter: Option<String>,
    /// Contract filter.
    pub contract_filter: Option<String>,
    /// Whether to fail on revert.
    pub fail_on_revert: bool,
}

impl Default for FuzzingConfig {
    fn default() -> Self {
        Self {
            runs: 10_000,
            seed: None,
            test_filter: None,
            contract_filter: None,
            fail_on_revert: false,
        }
    }
}

/// Result of a fuzzing campaign.
#[derive(Debug, Clone)]
pub struct FuzzingReport {
    pub passed: bool,
    pub runs: u32,
    pub duration_secs: f64,
    pub output: String,
    pub errors: String,
}

/// Adapter for running fuzzing campaigns via Forge.
pub struct FuzzingAdapter;

impl FuzzingAdapter {
    /// Run a fuzzing campaign using forge.
    pub fn run_forge_fuzz(config: &FuzzingConfig) -> Result<FuzzingReport, ForgeGuardError> {
        let mut cmd = std::process::Command::new("forge");
        cmd.arg("test");
        cmd.arg("--fuzz-runs").arg(config.runs.to_string());

        if let Some(seed) = config.seed {
            cmd.arg("--fuzz-seed").arg(seed.to_string());
        }

        if let Some(test) = &config.test_filter {
            cmd.arg("--match-test").arg(test);
        }

        if let Some(contract) = &config.contract_filter {
            cmd.arg("--match-contract").arg(contract);
        }

        if config.fail_on_revert {
            cmd.arg("--fail-on-revert");
        }

        let start = std::time::Instant::now();
        let output = cmd
            .output()
            .map_err(|e| ForgeGuardError::Command(format!("Failed to run fuzz: {}", e)))?;

        let duration = start.elapsed();

        Ok(FuzzingReport {
            passed: output.status.success(),
            runs: config.runs,
            duration_secs: duration.as_secs_f64(),
            output: String::from_utf8_lossy(&output.stdout).to_string(),
            errors: if output.status.success() {
                String::new()
            } else {
                String::from_utf8_lossy(&output.stderr).to_string()
            },
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_fuzzing_config_default_values() {
        let config = FuzzingConfig::default();
        assert_eq!(config.runs, 10_000);
        assert_eq!(config.seed, None);
        assert_eq!(config.test_filter, None);
        assert_eq!(config.contract_filter, None);
        assert!(!config.fail_on_revert);
    }

    #[test]
    fn test_fuzzing_config_zero_runs() {
        let config = FuzzingConfig {
            runs: 0,
            ..Default::default()
        };
        assert_eq!(config.runs, 0);
    }

    #[test]
    fn test_fuzzing_config_seed_zero() {
        let config = FuzzingConfig {
            seed: Some(0),
            ..Default::default()
        };
        assert_eq!(config.seed, Some(0));
    }

    #[test]
    fn test_fuzzing_config_with_empty_filters() {
        let config = FuzzingConfig {
            test_filter: Some(String::new()),
            contract_filter: Some(String::new()),
            ..Default::default()
        };
        assert_eq!(config.test_filter, Some(String::new()));
        assert_eq!(config.contract_filter, Some(String::new()));
    }

    #[test]
    fn test_fuzzing_config_all_fields_custom() {
        let config = FuzzingConfig {
            runs: 100_000,
            seed: Some(99999),
            test_filter: Some("testFuzz".into()),
            contract_filter: Some("Vault".into()),
            fail_on_revert: true,
        };
        assert_eq!(config.runs, 100_000);
        assert_eq!(config.seed, Some(99999));
        assert_eq!(config.test_filter, Some("testFuzz".into()));
        assert_eq!(config.contract_filter, Some("Vault".into()));
        assert!(config.fail_on_revert);
    }

    #[test]
    fn test_fuzzing_report_pass() {
        let report = FuzzingReport {
            passed: true,
            runs: 10_000,
            duration_secs: 1.5,
            output: "[PASS] all tests passed".into(),
            errors: String::new(),
        };
        assert!(report.passed);
        assert_eq!(report.runs, 10_000);
        assert!(report.duration_secs > 0.0);
        assert!(report.output.contains("PASS"));
        assert!(report.errors.is_empty());
    }

    #[test]
    fn test_fuzzing_report_failure() {
        let report = FuzzingReport {
            passed: false,
            runs: 5_000,
            duration_secs: 30.0,
            output: String::new(),
            errors: "[FAIL] reentrancy detected in withdraw".into(),
        };
        assert!(!report.passed);
        assert_eq!(report.runs, 5_000);
        assert!(report.errors.contains("reentrancy"));
        assert!(report.output.is_empty());
    }

    #[test]
    fn test_fuzzing_report_duration_edge_cases() {
        let instant = FuzzingReport {
            passed: true,
            runs: 1,
            duration_secs: 0.000_001,
            output: "fast".into(),
            errors: String::new(),
        };
        assert!(instant.duration_secs > 0.0);
        assert!(instant.duration_secs < 1.0);

        let long = FuzzingReport {
            passed: true,
            runs: 1_000_000,
            duration_secs: 999_999.99,
            output: String::new(),
            errors: String::new(),
        };
        assert!(long.duration_secs > 3600.0);
    }

    #[test]
    fn test_fuzzing_report_debug_format() {
        let report = FuzzingReport {
            passed: true,
            runs: 10_000,
            duration_secs: 2.5,
            output: "output".into(),
            errors: String::new(),
        };
        let debug = format!("{:?}", report);
        assert!(debug.contains("passed"));
        assert!(debug.contains("runs"));
        assert!(debug.contains("duration_secs"));
    }

    #[test]
    fn test_fuzzing_report_clone_equality() {
        let report = FuzzingReport {
            passed: true,
            runs: 10_000,
            duration_secs: 2.5,
            output: "test output".into(),
            errors: "test errors".into(),
        };
        let cloned = report.clone();
        assert_eq!(cloned.passed, report.passed);
        assert_eq!(cloned.runs, report.runs);
        assert_eq!(cloned.duration_secs, report.duration_secs);
        assert_eq!(cloned.output, report.output);
        assert_eq!(cloned.errors, report.errors);
    }

    #[test]
    fn test_fuzzing_adapter_forge_not_installed() {
        // Without forge, run_forge_fuzz should error gracefully, not panic
        let config = FuzzingConfig::default();
        let result = FuzzingAdapter::run_forge_fuzz(&config);
        assert!(result.is_err());
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("Failed to run fuzz"),
            "Error should mention command failure: {}",
            err
        );
    }

    #[test]
    fn test_fuzzing_report_stderr_on_failure() {
        let report = FuzzingReport {
            passed: false,
            runs: 100,
            duration_secs: 0.5,
            output: String::new(),
            errors: "Error: stack underflow\n".into(),
        };
        assert!(!report.errors.is_empty());
        assert!(report.output.is_empty());
    }
}