use forge_guard::chains::ChainRegistry;
use forge_guard::core::ProjectConfig;
use forge_guard::plugins::PluginRegistry;
use forge_guard::security::SecurityEngine;
use forge_guard::utils::Cache;
use std::path::Path;
const VULNERABLE_CONTRACT: &str = r#"
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VulnerableToken {
address public owner;
mapping(address => uint256) public balances;
constructor() {
owner = msg.sender;
}
// Reentrancy: state write after external call
function withdraw(uint256 amount) external {
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] -= amount;
}
// Access control: no modifier on sensitive function
function mint(address to, uint256 amount) external {
balances[to] += amount;
}
// tx.origin usage
function transfer(address to, uint256 amount) external {
require(tx.origin == owner);
balances[to] += amount;
}
// Unchecked external call to user-supplied address
function execute(address target, bytes calldata data) external {
(bool ok, ) = target.call(data);
require(ok, "call failed");
}
}
"#;
const CLEAN_CONTRACT: &str = r#"
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title Counter
/// @notice A minimal counter without vulnerabilities
contract Counter {
uint64 private countValue;
/// @notice Increment the counter
function increment() external {
countValue += 1;
}
/// @notice Get the current count
/// @return The current count value
function getCount() external view returns (uint64) {
return countValue;
}
}
"#;
fn create_temp_sol_file(dir: &Path, name: &str, content: &str) -> std::path::PathBuf {
let path = dir.join(name);
std::fs::write(&path, content).expect("Failed to write temp file");
path
}
fn test_config(temp_dir: &Path) -> ProjectConfig {
let mut config = ProjectConfig::from_default_location();
config.project_root = temp_dir.to_path_buf();
config.cache.enabled = false;
config.src_dirs = vec![temp_dir.to_path_buf()];
config
}
fn test_config_with_cache(temp_dir: &Path) -> ProjectConfig {
let mut config = test_config(temp_dir);
config.cache.enabled = true;
config.cache.directory = ".test-cache".into();
config.cache.ttl_seconds = 3600;
config
}
#[test]
fn test_quick_mode_detects_vulnerabilities() {
let temp = tempfile::tempdir().expect("Failed to create temp dir");
let config = test_config(temp.path());
let plugin_reg = PluginRegistry::new(&config).unwrap();
let engine = SecurityEngine::new(&config, &plugin_reg).unwrap();
let chain_reg = ChainRegistry::default();
let file = create_temp_sol_file(temp.path(), "VulnerableToken.sol", VULNERABLE_CONTRACT);
let files = vec![file];
let findings = engine
.analyze_files_quick(&files, &chain_reg)
.expect("Quick analysis should succeed");
assert!(
!findings.is_empty(),
"Quick mode should find vulnerabilities"
);
let tx_origin_findings: Vec<_> = findings
.iter()
.filter(|f| f.title.to_lowercase().contains("origin"))
.collect();
assert!(
!tx_origin_findings.is_empty(),
"Quick mode should detect tx.origin usage"
);
let reentrancy_findings: Vec<_> = findings
.iter()
.filter(|f| f.title.to_lowercase().contains("reentrancy"))
.collect();
assert!(
reentrancy_findings.is_empty(),
"Quick mode should skip parser-heavy reentrancy checks"
);
let ac_findings: Vec<_> = findings
.iter()
.filter(|f| f.title.to_lowercase().contains("access control"))
.collect();
assert!(
ac_findings.is_empty(),
"Quick mode should skip parser-heavy access control checks"
);
}
#[test]
fn test_full_mode_finds_more_than_quick_mode() {
let temp = tempfile::tempdir().expect("Failed to create temp dir");
let config = test_config(temp.path());
let plugin_reg = PluginRegistry::new(&config).unwrap();
let engine = SecurityEngine::new(&config, &plugin_reg).unwrap();
let chain_reg = ChainRegistry::default();
let file = create_temp_sol_file(temp.path(), "VulnerableToken.sol", VULNERABLE_CONTRACT);
let files = vec![file];
let quick_findings = engine
.analyze_files_quick(&files, &chain_reg)
.expect("Quick analysis should succeed");
let full_findings = engine
.analyze_files(&files, &chain_reg)
.expect("Full analysis should succeed");
assert!(
full_findings.len() >= quick_findings.len(),
"Full mode ({}) should find at least as many findings as quick mode ({})",
full_findings.len(),
quick_findings.len()
);
let reentrancy_in_full: Vec<_> = full_findings
.iter()
.filter(|f| f.title.to_lowercase().contains("reentrancy"))
.collect();
assert!(
!reentrancy_in_full.is_empty(),
"Full mode should detect reentrancy issues"
);
let ac_in_full: Vec<_> = full_findings
.iter()
.filter(|f| f.title.to_lowercase().contains("access control"))
.collect();
assert!(
!ac_in_full.is_empty(),
"Full mode should detect access control issues"
);
}
#[test]
fn test_clean_contract_no_findings() {
let temp = tempfile::tempdir().expect("Failed to create temp dir");
let config = test_config(temp.path());
let plugin_reg = PluginRegistry::new(&config).unwrap();
let engine = SecurityEngine::new(&config, &plugin_reg).unwrap();
let chain_reg = ChainRegistry::default();
let file = create_temp_sol_file(temp.path(), "Counter.sol", CLEAN_CONTRACT);
let files = vec![file];
let quick_findings = engine
.analyze_files_quick(&files, &chain_reg)
.expect("Quick analysis should succeed");
let full_findings = engine
.analyze_files(&files, &chain_reg)
.expect("Full analysis should succeed");
assert!(
quick_findings.is_empty(),
"Clean contract should have 0 quick findings, got {}",
quick_findings.len()
);
assert!(
full_findings.is_empty(),
"Clean contract should have 0 full findings, got {}",
full_findings.len()
);
}
#[test]
fn test_incremental_file_analysis_unchanged() {
let temp = tempfile::tempdir().expect("Failed to create temp dir");
let config = test_config_with_cache(temp.path());
let cache = Cache::new(&config).expect("Cache creation should succeed");
let file = create_temp_sol_file(temp.path(), "test.sol", CLEAN_CONTRACT);
assert!(
!cache.is_file_unchanged(&file),
"New file should not be unchanged"
);
cache
.record_file_hash(&file)
.expect("Recording file hash should succeed");
assert!(
cache.is_file_unchanged(&file),
"File should be unchanged after recording hash"
);
}
#[test]
fn test_incremental_file_analysis_modified() {
let temp = tempfile::tempdir().expect("Failed to create temp dir");
let config = test_config_with_cache(temp.path());
let cache = Cache::new(&config).expect("Cache creation should succeed");
let file = create_temp_sol_file(temp.path(), "test.sol", CLEAN_CONTRACT);
cache
.record_file_hash(&file)
.expect("Recording initial hash should succeed");
assert!(cache.is_file_unchanged(&file));
const MODIFIED_CONTRACT: &str = r#"
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Modified {
uint256 public value;
function set(uint256 v) external { value = v; }
}
"#;
std::fs::write(&file, MODIFIED_CONTRACT).expect("Failed to modify file");
assert!(
!cache.is_file_unchanged(&file),
"Modified file should be detected as changed"
);
}
#[test]
fn test_filter_changed_files() {
let temp = tempfile::tempdir().expect("Failed to create temp dir");
let config = test_config_with_cache(temp.path());
let cache = Cache::new(&config).expect("Cache creation should succeed");
let file1 = create_temp_sol_file(temp.path(), "unchanged.sol", CLEAN_CONTRACT);
let file2 = create_temp_sol_file(temp.path(), "changed.sol", CLEAN_CONTRACT);
cache.record_file_hash(&file1).expect("Should record hash");
cache.record_file_hash(&file2).expect("Should record hash");
std::fs::write(&file2, VULNERABLE_CONTRACT).expect("Failed to modify file2");
let files = vec![file1.clone(), file2.clone()];
let changed = cache.filter_changed_files(&files);
assert_eq!(changed.len(), 1, "Only one file should be changed");
assert_eq!(
changed[0], file2,
"The modified file should be the only changed file"
);
}
#[test]
fn test_quick_mode_with_vulnerable_token_detects_relevant_issues() {
let temp = tempfile::tempdir().expect("Failed to create temp dir");
let config = test_config(temp.path());
let plugin_reg = PluginRegistry::new(&config).unwrap();
let engine = SecurityEngine::new(&config, &plugin_reg).unwrap();
let chain_reg = ChainRegistry::default();
let delegatecall_contract = r#"
pragma solidity ^0.8.20;
contract Proxy {
address public implementation;
function delegate(address target, bytes calldata data) external {
(bool ok, ) = target.delegatecall(data);
require(ok);
}
}
"#;
let file = create_temp_sol_file(temp.path(), "Proxy.sol", delegatecall_contract);
let files = vec![file];
let quick_findings = engine
.analyze_files_quick(&files, &chain_reg)
.expect("Quick analysis should succeed");
let dc_findings: Vec<_> = quick_findings
.iter()
.filter(|f| f.title.to_lowercase().contains("delegatecall"))
.collect();
assert!(
!dc_findings.is_empty(),
"Quick mode should detect delegatecall usage"
);
}
#[test]
fn test_cache_file_hash_consistency() {
let temp = tempfile::tempdir().expect("Failed to create temp dir");
let file = create_temp_sol_file(temp.path(), "test.sol", CLEAN_CONTRACT);
let hash1 = Cache::file_hash(&file).expect("First hash should succeed");
let hash2 = Cache::file_hash(&file).expect("Second hash should succeed");
assert_eq!(hash1, hash2, "File hash should be consistent");
let other_file = create_temp_sol_file(temp.path(), "other.sol", VULNERABLE_CONTRACT);
let hash3 = Cache::file_hash(&other_file).expect("Third hash should succeed");
assert_ne!(hash1, hash3, "Different files should have different hashes");
}
#[test]
fn test_quick_mode_capped_findings() {
let temp = tempfile::tempdir().expect("Failed to create temp dir");
let config = test_config(temp.path());
let plugin_reg = PluginRegistry::new(&config).unwrap();
let engine = SecurityEngine::new(&config, &plugin_reg).unwrap();
let chain_reg = ChainRegistry::default();
let mut content = String::from("pragma solidity ^0.8.20;\ncontract ManyIssues {\n");
for i in 0..10 {
content.push_str(&format!(
" function issue{}() external {{ (bool ok, ) = address(0x{}).delegatecall(\"\"); require(ok); }}\n",
i,
format!("{:040x}", i)
));
}
content.push_str("}\n");
let file = create_temp_sol_file(temp.path(), "ManyIssues.sol", &content);
let files = vec![file];
let quick_findings = engine
.analyze_files_quick(&files, &chain_reg)
.expect("Quick analysis should succeed");
assert!(
!quick_findings.is_empty(),
"Quick mode should find some issues"
);
let all_delegatecall = quick_findings
.iter()
.all(|f| f.title.to_lowercase().contains("delegatecall"));
assert!(
all_delegatecall,
"Quick mode should only find delegatecall issues, found: {:?}",
quick_findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_cache_disabled_no_effect() {
let temp = tempfile::tempdir().expect("Failed to create temp dir");
let mut config = test_config_with_cache(temp.path());
config.cache.enabled = false;
let cache = Cache::new(&config).expect("Cache creation should succeed");
let file = create_temp_sol_file(temp.path(), "test.sol", CLEAN_CONTRACT);
assert!(
!cache.is_file_unchanged(&file),
"Disabled cache should report file as changed"
);
cache
.record_file_hash(&file)
.expect("Recording with disabled cache should not error");
assert!(
!cache.is_file_unchanged(&file),
"Disabled cache should still report file as changed after recorded"
);
let files = vec![file];
let changed = cache.filter_changed_files(&files);
assert_eq!(
changed.len(),
1,
"Disabled cache should return all files as changed"
);
}