rebecca-core 0.3.0

Core planning, safety, scanning, and history models for Rebecca.
Documentation
use std::collections::hash_map::DefaultHasher;
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

use serde::{Deserialize, Serialize};

use crate::error::{RebeccaError, Result};
use crate::manifest::parse_cleaner_manifest_file;
use crate::{Platform, RuleDefinition};

pub const EXTERNAL_RULE_STORE_VERSION: u16 = 1;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExternalRuleIndex {
    pub version: u16,
    #[serde(default)]
    pub entries: Vec<ExternalRuleEntry>,
}

impl Default for ExternalRuleIndex {
    fn default() -> Self {
        Self {
            version: EXTERNAL_RULE_STORE_VERSION,
            entries: Vec::new(),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExternalRuleEntry {
    pub import_id: String,
    pub source_display_path: PathBuf,
    pub stored_manifest_path: PathBuf,
    pub content_hash: String,
    pub imported_at_unix_seconds: u64,
    pub enabled: bool,
    pub rule_ids: Vec<String>,
    pub platforms: Vec<Platform>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ExternalRuleImportReport {
    pub imported: ExternalRuleEntry,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ExternalRuleListReport {
    pub store_dir: PathBuf,
    pub entries: Vec<ExternalRuleEntry>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub diagnostics: Vec<ExternalRuleStoreDiagnostic>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ExternalRuleMutationReport {
    pub import_id: String,
    pub enabled: bool,
    pub removed: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ExternalRuleStoreDiagnostic {
    pub import_id: Option<String>,
    pub reason_code: &'static str,
    pub message: String,
}

#[derive(Debug, Clone)]
pub struct ExternalRuleStore {
    root_dir: PathBuf,
}

impl ExternalRuleStore {
    pub fn new(root_dir: PathBuf) -> Self {
        Self { root_dir }
    }

    pub fn root_dir(&self) -> &Path {
        &self.root_dir
    }

    pub fn default_for_state_dir(state_dir: &Path) -> Self {
        Self::new(state_dir.join("external-rules"))
    }

    pub fn index_path(&self) -> PathBuf {
        self.root_dir.join("index.json")
    }

    pub fn manifests_dir(&self) -> PathBuf {
        self.root_dir.join("manifests")
    }

    pub fn import_manifest(&self, source: &Path) -> Result<ExternalRuleImportReport> {
        let raw = fs::read_to_string(source).map_err(|err| {
            RebeccaError::RuleCatalogInvalid(format!(
                "external rule manifest is not readable: {}: {err}",
                source.display()
            ))
        })?;
        let rules = parse_cleaner_manifest_file(&source.display().to_string(), &raw)?;
        let content_hash = content_hash(&raw);
        let import_id = content_hash.clone();
        let stored_manifest_path = self.manifests_dir().join(format!("{content_hash}.toml"));
        let mut index = self.load_index()?;

        fs::create_dir_all(self.manifests_dir())?;
        fs::write(&stored_manifest_path, raw)?;

        let entry = ExternalRuleEntry {
            import_id: import_id.clone(),
            source_display_path: source.to_path_buf(),
            stored_manifest_path,
            content_hash,
            imported_at_unix_seconds: unix_now(),
            enabled: false,
            rule_ids: sorted_rule_ids(&rules),
            platforms: sorted_platforms(&rules),
        };

        index.entries.retain(|entry| entry.import_id != import_id);
        index.entries.push(entry.clone());
        index
            .entries
            .sort_by(|left, right| left.import_id.cmp(&right.import_id));
        self.store_index(&index)?;

        Ok(ExternalRuleImportReport { imported: entry })
    }

    pub fn list(&self) -> Result<ExternalRuleListReport> {
        Ok(ExternalRuleListReport {
            store_dir: self.root_dir.clone(),
            entries: self.load_index()?.entries,
            diagnostics: Vec::new(),
        })
    }

    pub fn set_enabled(
        &self,
        import_id: &str,
        enabled: bool,
    ) -> Result<ExternalRuleMutationReport> {
        let mut index = self.load_index()?;
        let Some(entry) = index
            .entries
            .iter_mut()
            .find(|entry| entry.import_id == import_id)
        else {
            return Err(unknown_import_id(import_id));
        };
        if enabled {
            validate_stored_entry(entry)?;
        }
        entry.enabled = enabled;
        self.store_index(&index)?;
        Ok(ExternalRuleMutationReport {
            import_id: import_id.to_string(),
            enabled,
            removed: false,
        })
    }

    pub fn remove(&self, import_id: &str) -> Result<ExternalRuleMutationReport> {
        let mut index = self.load_index()?;
        let Some(position) = index
            .entries
            .iter()
            .position(|entry| entry.import_id == import_id)
        else {
            return Err(unknown_import_id(import_id));
        };
        let entry = index.entries.remove(position);
        if entry.stored_manifest_path.exists() {
            fs::remove_file(&entry.stored_manifest_path)?;
        }
        self.store_index(&index)?;
        Ok(ExternalRuleMutationReport {
            import_id: import_id.to_string(),
            enabled: false,
            removed: true,
        })
    }

    pub fn load_enabled_rules(&self) -> ExternalRuleLoadReport {
        let mut report = ExternalRuleLoadReport::default();
        let index = match self.load_index() {
            Ok(index) => index,
            Err(err) => {
                report.diagnostics.push(ExternalRuleStoreDiagnostic {
                    import_id: None,
                    reason_code: "external-rule-index-unreadable",
                    message: err.to_string(),
                });
                return report;
            }
        };

        for entry in index.entries.into_iter().filter(|entry| entry.enabled) {
            match load_stored_entry_rules(&entry) {
                Ok(rules) => report.rules.extend(rules),
                Err(err) => report.diagnostics.push(ExternalRuleStoreDiagnostic {
                    import_id: Some(entry.import_id),
                    reason_code: "external-rule-invalid",
                    message: err.to_string(),
                }),
            }
        }

        report
    }

    fn load_index(&self) -> Result<ExternalRuleIndex> {
        let path = self.index_path();
        let raw = match fs::read_to_string(&path) {
            Ok(raw) => raw,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
                return Ok(ExternalRuleIndex::default());
            }
            Err(err) => return Err(err.into()),
        };
        let index = serde_json::from_str::<ExternalRuleIndex>(&raw)?;
        if index.version != EXTERNAL_RULE_STORE_VERSION {
            return Err(RebeccaError::RuleCatalogInvalid(format!(
                "{} uses unsupported external rule store version {}; expected {EXTERNAL_RULE_STORE_VERSION}",
                path.display(),
                index.version
            )));
        }
        Ok(index)
    }

    fn store_index(&self, index: &ExternalRuleIndex) -> Result<()> {
        fs::create_dir_all(&self.root_dir)?;
        fs::write(self.index_path(), serde_json::to_vec_pretty(index)?)?;
        Ok(())
    }
}

#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ExternalRuleLoadReport {
    pub rules: Vec<RuleDefinition>,
    pub diagnostics: Vec<ExternalRuleStoreDiagnostic>,
}

fn validate_stored_entry(entry: &ExternalRuleEntry) -> Result<()> {
    load_stored_entry_rules(entry).map(|_| ())
}

fn load_stored_entry_rules(entry: &ExternalRuleEntry) -> Result<Vec<RuleDefinition>> {
    let raw = fs::read_to_string(&entry.stored_manifest_path)?;
    let current_hash = content_hash(&raw);
    if current_hash != entry.content_hash {
        return Err(RebeccaError::RuleCatalogInvalid(format!(
            "stored external rule {} content hash changed",
            entry.import_id
        )));
    }
    parse_cleaner_manifest_file(&entry.stored_manifest_path.display().to_string(), &raw)
}

fn unknown_import_id(import_id: &str) -> RebeccaError {
    RebeccaError::RuleCatalogInvalid(format!("unknown external rule import id: {import_id}"))
}

fn sorted_rule_ids(rules: &[RuleDefinition]) -> Vec<String> {
    let mut ids = rules.iter().map(|rule| rule.id.clone()).collect::<Vec<_>>();
    ids.sort();
    ids.dedup();
    ids
}

fn sorted_platforms(rules: &[RuleDefinition]) -> Vec<Platform> {
    let mut platforms = rules.iter().map(|rule| rule.platform).collect::<Vec<_>>();
    platforms.sort_by_key(|platform| platform.label());
    platforms.dedup();
    platforms
}

fn content_hash(raw: &str) -> String {
    let mut hasher = DefaultHasher::new();
    raw.hash(&mut hasher);
    format!("{:016x}", hasher.finish())
}

fn unix_now() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_secs())
        .unwrap_or_default()
}

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

    fn valid_manifest() -> String {
        r#"
manifest_version = 1
id = "example-cache"
category = "development"
name = "Example cache"
safety_level = "safe"
restore_hint = "Example rebuilds this cache automatically."

[provenance]
source = "reference-only"
license = "example-user-rule"
notes = "Local user-authored validation fixture; no external rule data copied."

[[platforms]]
platform = "macos"

[[platforms.targets]]
kind = "template"
value = "MACOS_CACHE_HOME/Example"
search_kind = "file"
"#
        .to_string()
    }

    #[test]
    fn import_stores_manifest_disabled_by_default() {
        let temp = tempfile::tempdir().unwrap();
        let source = temp.path().join("example.toml");
        fs::write(&source, valid_manifest()).unwrap();
        let store = ExternalRuleStore::new(temp.path().join("store"));

        let report = store.import_manifest(&source).unwrap();

        assert!(!report.imported.enabled);
        assert_eq!(report.imported.rule_ids, ["macos.example-cache"]);
        assert!(report.imported.stored_manifest_path.exists());
        assert!(store.list().unwrap().entries[0].content_hash.len() >= 16);
    }

    #[test]
    fn enabled_rules_are_revalidated_before_loading() {
        let temp = tempfile::tempdir().unwrap();
        let source = temp.path().join("example.toml");
        fs::write(&source, valid_manifest()).unwrap();
        let store = ExternalRuleStore::new(temp.path().join("store"));
        let import_id = store.import_manifest(&source).unwrap().imported.import_id;
        store.set_enabled(&import_id, true).unwrap();

        let report = store.load_enabled_rules();

        assert!(report.diagnostics.is_empty());
        assert_eq!(report.rules[0].id, "macos.example-cache");
    }

    #[test]
    fn corrupted_stored_manifest_fails_closed() {
        let temp = tempfile::tempdir().unwrap();
        let source = temp.path().join("example.toml");
        fs::write(&source, valid_manifest()).unwrap();
        let store = ExternalRuleStore::new(temp.path().join("store"));
        let imported = store.import_manifest(&source).unwrap().imported;
        store.set_enabled(&imported.import_id, true).unwrap();
        fs::write(&imported.stored_manifest_path, "manifest_version = 1\n").unwrap();

        let report = store.load_enabled_rules();

        assert!(report.rules.is_empty());
        assert_eq!(report.diagnostics[0].reason_code, "external-rule-invalid");
    }
}