use crate::core::types::{FileHash, FileMetadata, ScanContext, ScanOutcome, ThreatInfo};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanResult {
pub id: String,
pub outcome: ScanOutcome,
pub file_metadata: FileMetadata,
pub engine: String,
pub engine_version: Option<String>,
pub started_at: DateTime<Utc>,
pub completed_at: DateTime<Utc>,
#[serde(with = "duration_serde")]
pub duration: Duration,
pub context: ScanContext,
pub cached: bool,
#[serde(default)]
pub details: std::collections::HashMap<String, serde_json::Value>,
}
impl ScanResult {
pub fn new(
outcome: ScanOutcome,
file_metadata: FileMetadata,
engine: impl Into<String>,
duration: Duration,
context: ScanContext,
) -> Self {
let now = Utc::now();
Self {
id: uuid::Uuid::new_v4().to_string(),
outcome,
file_metadata,
engine: engine.into(),
engine_version: None,
started_at: now - chrono::Duration::from_std(duration).unwrap_or_default(),
completed_at: now,
duration,
context,
cached: false,
details: std::collections::HashMap::new(),
}
}
pub fn is_clean(&self) -> bool {
self.outcome.is_clean()
}
pub fn is_infected(&self) -> bool {
self.outcome.is_infected()
}
pub fn threats(&self) -> Option<&[ThreatInfo]> {
match &self.outcome {
ScanOutcome::Infected { threats } => Some(threats),
_ => None,
}
}
pub fn file_hash(&self) -> &FileHash {
&self.file_metadata.hash
}
pub fn with_engine_version(mut self, version: impl Into<String>) -> Self {
self.engine_version = Some(version.into());
self
}
pub fn with_cached(mut self, cached: bool) -> Self {
self.cached = cached;
self
}
pub fn with_detail(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
self.details.insert(key.into(), value);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanReport {
pub id: String,
pub results: Vec<ScanResult>,
pub aggregated_outcome: ScanOutcome,
pub file_hash: FileHash,
pub requested_at: DateTime<Utc>,
pub completed_at: DateTime<Utc>,
#[serde(with = "duration_serde")]
pub total_duration: Duration,
pub context: ScanContext,
}
impl ScanReport {
pub fn from_results(results: Vec<ScanResult>, context: ScanContext) -> Self {
let now = Utc::now();
let aggregated_outcome = aggregate_outcomes(results.iter().map(|r| &r.outcome));
let file_hash = results
.first()
.map(|r| r.file_metadata.hash.clone())
.unwrap_or_else(|| FileHash::new("unknown"));
let total_duration = results.iter().map(|r| r.duration).sum();
let requested_at = results
.iter()
.map(|r| r.started_at)
.min()
.unwrap_or(now);
Self {
id: uuid::Uuid::new_v4().to_string(),
results,
aggregated_outcome,
file_hash,
requested_at,
completed_at: now,
total_duration,
context,
}
}
pub fn is_clean(&self) -> bool {
self.aggregated_outcome.is_clean()
}
pub fn is_infected(&self) -> bool {
self.aggregated_outcome.is_infected()
}
pub fn all_threats(&self) -> Vec<&ThreatInfo> {
self.results
.iter()
.filter_map(|r| r.threats())
.flatten()
.collect()
}
pub fn detecting_engines(&self) -> Vec<&str> {
self.results
.iter()
.filter(|r| r.is_infected())
.map(|r| r.engine.as_str())
.collect()
}
pub fn engine_count(&self) -> usize {
self.results.len()
}
}
fn aggregate_outcomes<'a>(outcomes: impl Iterator<Item = &'a ScanOutcome>) -> ScanOutcome {
let mut has_error = false;
let mut has_infected = false;
let mut all_threats = Vec::new();
let mut has_suspicious = false;
let mut suspicious_reason = String::new();
let mut suspicious_confidence = 0.0f32;
for outcome in outcomes {
match outcome {
ScanOutcome::Error { recoverable: false } => {
return ScanOutcome::Error { recoverable: false };
}
ScanOutcome::Error { .. } => has_error = true,
ScanOutcome::Infected { threats } => {
has_infected = true;
all_threats.extend(threats.clone());
}
ScanOutcome::Suspicious { reason, confidence } => {
has_suspicious = true;
if *confidence > suspicious_confidence {
suspicious_confidence = *confidence;
suspicious_reason = reason.clone();
}
}
ScanOutcome::Clean => {}
}
}
if has_infected {
ScanOutcome::Infected {
threats: all_threats,
}
} else if has_suspicious {
ScanOutcome::Suspicious {
reason: suspicious_reason,
confidence: suspicious_confidence,
}
} else if has_error {
ScanOutcome::Error { recoverable: true }
} else {
ScanOutcome::Clean
}
}
mod duration_serde {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::time::Duration;
pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
duration.as_millis().serialize(serializer)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
let millis = u64::deserialize(deserializer)?;
Ok(Duration::from_millis(millis))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::types::ThreatSeverity;
#[test]
fn test_scan_result_creation() {
let hash = FileHash::new("abc123");
let metadata = FileMetadata::new(1000, hash);
let context = ScanContext::new().with_tenant_id("test");
let result = ScanResult::new(
ScanOutcome::Clean,
metadata,
"test-engine",
Duration::from_millis(100),
context,
);
assert!(result.is_clean());
assert!(!result.is_infected());
assert!(result.threats().is_none());
}
#[test]
fn test_scan_result_infected() {
let hash = FileHash::new("abc123");
let metadata = FileMetadata::new(1000, hash);
let context = ScanContext::new();
let threats = vec![ThreatInfo::new("Test.Malware", ThreatSeverity::High, "test")];
let result = ScanResult::new(
ScanOutcome::Infected {
threats: threats.clone(),
},
metadata,
"test-engine",
Duration::from_millis(100),
context,
);
assert!(!result.is_clean());
assert!(result.is_infected());
assert_eq!(result.threats().unwrap().len(), 1);
}
#[test]
fn test_aggregate_outcomes() {
let outcomes = vec![ScanOutcome::Clean, ScanOutcome::Clean];
assert!(aggregate_outcomes(outcomes.iter()).is_clean());
let outcomes = vec![
ScanOutcome::Clean,
ScanOutcome::Infected {
threats: vec![ThreatInfo::new("Test", ThreatSeverity::High, "engine")],
},
];
assert!(aggregate_outcomes(outcomes.iter()).is_infected());
}
}