forge-guard 0.3.6

Pre-deployment smart contract auditing framework for Foundry
Documentation
//! Caching — stores audit results and analysis data to speed up re-runs.

use crate::core::{ForgeGuardError, ProjectConfig};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;

/// A simple filesystem-based cache for audit results.
#[allow(dead_code)]
pub struct Cache {
    enabled: bool,
    cache_dir: PathBuf,
    memory_cache: HashMap<String, String>,
    max_size: u64,
    ttl_seconds: u64,
}

impl Cache {
    /// Create a new cache instance.
    pub fn new(config: &ProjectConfig) -> Result<Self, ForgeGuardError> {
        let cache_dir = config.project_root.join(&config.cache.directory);
        let enabled = config.cache.enabled;

        if enabled {
            std::fs::create_dir_all(&cache_dir).ok();
            // Clean old cache entries
            Self::clean_expired(&cache_dir, config.cache.ttl_seconds);
        }

        Ok(Self {
            enabled,
            cache_dir,
            memory_cache: HashMap::new(),
            max_size: config.cache.max_size_mb,
            ttl_seconds: config.cache.ttl_seconds,
        })
    }

    /// Store a value in the cache.
    pub fn store<T: Serialize>(&self, key: &str, value: &T) -> Result<(), ForgeGuardError> {
        if !self.enabled {
            return Ok(());
        }

        let json = serde_json::to_string(value)?;
        let cache_file = self.cache_path(key);

        // Also store in memory cache
        // Can't modify memory_cache since we don't have &mut self
        // Use filesystem only for now

        std::fs::write(&cache_file, &json)?;
        // Set file modification time for TTL
        let _ = filetime::set_file_mtime(&cache_file, filetime::FileTime::now());

        Ok(())
    }

    /// Load a value from the cache.
    pub fn load<T: serde::de::DeserializeOwned>(
        &self,
        key: &str,
    ) -> Result<Option<T>, ForgeGuardError> {
        if !self.enabled {
            return Ok(None);
        }

        let cache_file = self.cache_path(key);
        if !cache_file.exists() {
            return Ok(None);
        }

        // Check TTL
        if let Ok(metadata) = std::fs::metadata(&cache_file) {
            if let Ok(modified) = metadata.modified() {
                let age = SystemTime::now()
                    .duration_since(modified)
                    .unwrap_or_default()
                    .as_secs();
                if age > self.ttl_seconds {
                    std::fs::remove_file(&cache_file).ok();
                    return Ok(None);
                }
            }
        }

        let content = std::fs::read_to_string(&cache_file)?;
        let value = serde_json::from_str(&content)?;
        Ok(Some(value))
    }

    /// Check if a key exists in the cache and is fresh.
    pub fn has(&self, key: &str) -> bool {
        if !self.enabled {
            return false;
        }
        let cache_file = self.cache_path(key);
        if !cache_file.exists() {
            return false;
        }
        if let Ok(metadata) = std::fs::metadata(&cache_file) {
            if let Ok(modified) = metadata.modified() {
                let age = SystemTime::now()
                    .duration_since(modified)
                    .unwrap_or_default()
                    .as_secs();
                return age <= self.ttl_seconds;
            }
        }
        false
    }

    /// Clear all cached entries.
    pub fn clear(&self) -> Result<(), ForgeGuardError> {
        if self.cache_dir.exists() {
            std::fs::remove_dir_all(&self.cache_dir)?;
            std::fs::create_dir_all(&self.cache_dir)?;
        }
        Ok(())
    }

    /// Get cache size in bytes.
    pub fn size(&self) -> u64 {
        let mut total = 0;
        if let Ok(entries) = std::fs::read_dir(&self.cache_dir) {
            for entry in entries.flatten() {
                if let Ok(metadata) = entry.metadata() {
                    total += metadata.len();
                }
            }
        }
        total
    }

    fn cache_path(&self, key: &str) -> PathBuf {
        // Sanitize the key for use as a filename
        let sanitized: String = key
            .chars()
            .map(|c| {
                if c.is_alphanumeric() || c == '-' || c == '_' {
                    c
                } else {
                    '_'
                }
            })
            .collect();
        self.cache_dir.join(format!("{}.json", sanitized))
    }

    /// Compute a content hash for incremental file analysis.
    /// Returns a hex string of the file's SHA-256 hash.
    /// Used to skip re-analysis of unchanged files.
    pub fn file_hash(path: &Path) -> Result<String, ForgeGuardError> {
        let content = std::fs::read(path)?;
        let hash = Sha256::digest(&content);
        Ok(hex::encode(hash))
    }

    /// Check if a file has changed since the last analysis using content hashing.
    /// Returns `true` if the file is unchanged (cache hit).
    pub fn is_file_unchanged(&self, file_path: &Path) -> bool {
        if !self.enabled {
            return false;
        }
        let hash = match Self::file_hash(file_path) {
            Ok(h) => h,
            Err(_) => return false,
        };
        let key = &format!("file_hash_{}", file_path.to_string_lossy());
        match self.load::<String>(key) {
            Ok(Some(cached)) => cached == hash,
            _ => false,
        }
    }

    /// Record a file hash for future incremental analysis.
    /// Call this after analyzing a file to mark it as processed.
    pub fn record_file_hash(&self, file_path: &Path) -> Result<(), ForgeGuardError> {
        if !self.enabled {
            return Ok(());
        }
        let hash = Self::file_hash(file_path)?;
        let key = &format!("file_hash_{}", file_path.to_string_lossy());
        self.store(key, &hash)
    }

    /// Filter out files that have not changed since the last analysis.
    /// Returns only the files that need (re-)analysis.
    pub fn filter_changed_files(&self, files: &[PathBuf]) -> Vec<PathBuf> {
        if !self.enabled {
            return files.to_vec();
        }
        files
            .iter()
            .filter(|f| !self.is_file_unchanged(f))
            .cloned()
            .collect()
    }

    fn clean_expired(cache_dir: &PathBuf, ttl_seconds: u64) {
        if let Ok(entries) = std::fs::read_dir(cache_dir) {
            for entry in entries.flatten() {
                if let Ok(metadata) = entry.metadata() {
                    if let Ok(modified) = metadata.modified() {
                        let age = SystemTime::now()
                            .duration_since(modified)
                            .unwrap_or_default()
                            .as_secs();
                        if age > ttl_seconds {
                            std::fs::remove_file(entry.path()).ok();
                        }
                    }
                }
            }
        }
    }
}

// Adding filetime dependency is optional; use a simple approach
mod filetime {
    use std::fs;
    use std::time::SystemTime;

    pub struct FileTime;
    impl FileTime {
        pub fn now() -> SystemTime {
            SystemTime::now()
        }
    }

    pub fn set_file_mtime(path: &std::path::Path, _time: SystemTime) -> std::io::Result<()> {
        // Touch the file to update its mtime
        let _ = fs::File::options().write(true).open(path)?;
        Ok(())
    }
}