use crate::core::{ScanContext, ScanReport};
use crate::policy::action::PolicyAction;
use crate::policy::rules::{Condition, PolicyRule};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyDecision {
pub action: PolicyAction,
pub matched_rule_id: Option<String>,
pub matched_rule_name: Option<String>,
pub reason: Option<String>,
}
impl PolicyDecision {
pub fn new(action: PolicyAction) -> Self {
Self {
action,
matched_rule_id: None,
matched_rule_name: None,
reason: None,
}
}
pub fn with_rule(mut self, rule: &PolicyRule) -> Self {
self.matched_rule_id = Some(rule.id.clone());
self.matched_rule_name = Some(rule.name.clone());
self
}
pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
self.reason = Some(reason.into());
self
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PolicyEngineConfig {
pub default_action: PolicyAction,
pub first_match_wins: bool,
}
impl PolicyEngineConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_default_action(mut self, action: PolicyAction) -> Self {
self.default_action = action;
self
}
pub fn with_first_match_wins(mut self, enabled: bool) -> Self {
self.first_match_wins = enabled;
self
}
}
#[derive(Debug, Clone, Default)]
pub struct PolicyEngine {
rules: Vec<PolicyRule>,
config: PolicyEngineConfig,
}
impl PolicyEngine {
pub fn new() -> Self {
Self::default()
}
pub fn with_config(config: PolicyEngineConfig) -> Self {
Self {
rules: Vec::new(),
config,
}
}
pub fn add_rule(&mut self, rule: PolicyRule) {
self.rules.push(rule);
self.rules.sort_by(|a, b| b.priority.cmp(&a.priority));
}
pub fn with_rule(mut self, rule: PolicyRule) -> Self {
self.add_rule(rule);
self
}
pub fn rule_count(&self) -> usize {
self.rules.len()
}
pub fn rules(&self) -> &[PolicyRule] {
&self.rules
}
pub fn clear_rules(&mut self) {
self.rules.clear();
}
pub fn evaluate(&self, report: &ScanReport, context: &ScanContext) -> PolicyDecision {
for rule in &self.rules {
if rule.matches(report, context) {
tracing::debug!(
rule_id = %rule.id,
rule_name = %rule.name,
"Policy rule matched"
);
return PolicyDecision::new(rule.action.clone())
.with_rule(rule)
.with_reason(format!("Matched rule: {}", rule.name));
}
}
PolicyDecision::new(self.config.default_action.clone())
.with_reason("No matching rules; using default action")
}
pub fn default_policy() -> Self {
Self::new()
.with_rule(
PolicyRule::new("block-infected", PolicyAction::block("Malware detected"))
.with_name("Block Infected Files")
.with_condition(Condition::is_infected())
.with_priority(100),
)
.with_rule(
PolicyRule::new("quarantine-suspicious", PolicyAction::quarantine("Suspicious content"))
.with_name("Quarantine Suspicious Files")
.with_condition(Condition::is_suspicious())
.with_priority(90),
)
.with_rule(
PolicyRule::new("review-errors", PolicyAction::require_review())
.with_name("Review Scan Errors")
.with_condition(Condition::is_error())
.with_priority(80),
)
.with_rule(
PolicyRule::new("allow-clean", PolicyAction::allow())
.with_name("Allow Clean Files")
.with_condition(Condition::is_clean())
.with_priority(0),
)
}
pub fn strict_policy() -> Self {
Self::new()
.with_rule(
PolicyRule::new("block-infected", PolicyAction::block("Malware detected"))
.with_name("Block Infected Files")
.with_condition(Condition::is_infected())
.with_priority(100),
)
.with_rule(
PolicyRule::new("block-suspicious", PolicyAction::block("Suspicious content"))
.with_name("Block Suspicious Files")
.with_condition(Condition::is_suspicious())
.with_priority(90),
)
.with_rule(
PolicyRule::new("block-errors", PolicyAction::block("Scan error occurred"))
.with_name("Block on Scan Errors")
.with_condition(Condition::is_error())
.with_priority(80),
)
.with_rule(
PolicyRule::new("allow-clean", PolicyAction::allow())
.with_name("Allow Clean Files")
.with_condition(Condition::is_clean())
.with_priority(0),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::{FileHash, FileMetadata, ScanOutcome, ScanResult, ThreatInfo, ThreatSeverity};
use std::time::Duration;
fn make_test_report(outcome: ScanOutcome) -> ScanReport {
let hash = FileHash::new("test");
let metadata = FileMetadata::new(1000, hash);
let result = ScanResult::new(
outcome,
metadata,
"test",
Duration::from_millis(10),
ScanContext::new(),
);
ScanReport::from_results(vec![result], ScanContext::new())
}
#[test]
fn test_policy_engine_default() {
let engine = PolicyEngine::default_policy();
let context = ScanContext::new();
let clean_report = make_test_report(ScanOutcome::Clean);
let decision = engine.evaluate(&clean_report, &context);
assert!(decision.action.is_allowed());
let threats = vec![ThreatInfo::new("Test", ThreatSeverity::High, "test")];
let infected_report = make_test_report(ScanOutcome::Infected { threats });
let decision = engine.evaluate(&infected_report, &context);
assert!(decision.action.is_blocked());
}
#[test]
fn test_policy_engine_strict() {
let engine = PolicyEngine::strict_policy();
let context = ScanContext::new();
let error_report = make_test_report(ScanOutcome::Error { recoverable: true });
let decision = engine.evaluate(&error_report, &context);
assert!(decision.action.is_blocked());
}
#[test]
fn test_policy_engine_custom_rule() {
let engine = PolicyEngine::new()
.with_rule(
PolicyRule::new("test-rule", PolicyAction::quarantine("Test reason"))
.with_condition(Condition::tenant_equals("test-tenant"))
.with_priority(100),
)
.with_rule(
PolicyRule::new("default-allow", PolicyAction::allow())
.with_condition(Condition::Always)
.with_priority(0),
);
let report = make_test_report(ScanOutcome::Clean);
let context = ScanContext::new().with_tenant_id("test-tenant");
let decision = engine.evaluate(&report, &context);
assert!(decision.action.is_quarantine());
assert_eq!(decision.matched_rule_id, Some("test-rule".to_string()));
let context = ScanContext::new().with_tenant_id("other-tenant");
let decision = engine.evaluate(&report, &context);
assert!(decision.action.is_allowed());
}
}