use forensicnomicon::report::{Category, Evidence, Finding, Location, Severity, Source};
use trash_core::{RecycleBinIndex, RecycleBinPair};
use crate::{has_path_traversal, ANALYZER};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AnomalyKind {
ContentPurged {
original_path: String,
},
PathTraversal {
original_path: String,
},
DeletionTimeMissing {
original_path: String,
},
}
impl AnomalyKind {
#[must_use]
pub fn code(&self) -> &'static str {
match self {
AnomalyKind::ContentPurged { .. } => "RECYCLEBIN-CONTENT-PURGED",
AnomalyKind::PathTraversal { .. } => "RECYCLEBIN-PATH-TRAVERSAL",
AnomalyKind::DeletionTimeMissing { .. } => "RECYCLEBIN-DELETION-TIME-MISSING",
}
}
#[must_use]
pub fn severity(&self) -> Severity {
match self {
AnomalyKind::ContentPurged { .. } => Severity::Medium,
AnomalyKind::PathTraversal { .. } => Severity::High,
AnomalyKind::DeletionTimeMissing { .. } => Severity::Low,
}
}
#[must_use]
pub fn category(&self) -> Category {
match self {
AnomalyKind::ContentPurged { .. } => Category::Residue,
AnomalyKind::PathTraversal { .. } => Category::Concealment,
AnomalyKind::DeletionTimeMissing { .. } => Category::Integrity,
}
}
#[must_use]
pub fn note(&self) -> String {
match self {
AnomalyKind::ContentPurged { original_path } => format!(
"$I index for {original_path} survives but its $R content file is absent — \
consistent with the file's content having been purged while its metadata remains"
),
AnomalyKind::PathTraversal { original_path } => format!(
"stored original path {original_path} contains parent-directory ('..') \
components — consistent with a crafted name rather than a normal deletion"
),
AnomalyKind::DeletionTimeMissing { original_path } => format!(
"deletion FILETIME for {original_path} is zero (unset) — the deletion time \
was not recorded or has been cleared"
),
}
}
fn to_finding(&self, source: Source) -> Finding {
let path = match self {
AnomalyKind::ContentPurged { original_path }
| AnomalyKind::PathTraversal { original_path }
| AnomalyKind::DeletionTimeMissing { original_path } => original_path.clone(),
};
Finding::observation(self.severity(), self.category(), self.code())
.note(self.note())
.source(source)
.evidence_item(Evidence {
field: "original_path".to_string(),
value: path.clone(),
location: Some(Location::Path(path)),
})
.build()
}
}
fn source_for(pair: &RecycleBinPair) -> Source {
let scope = pair
.index_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("$I")
.to_string();
Source {
analyzer: ANALYZER.to_string(),
scope,
version: Some(env!("CARGO_PKG_VERSION").to_string()),
}
}
#[must_use]
pub fn audit_pair(index: &RecycleBinIndex, pair: &RecycleBinPair) -> Vec<Finding> {
let source = source_for(pair);
let mut anomalies = Vec::new();
if pair.content_path.is_none() {
anomalies.push(AnomalyKind::ContentPurged {
original_path: index.original_path.clone(),
});
}
if has_path_traversal(&index.original_path) {
anomalies.push(AnomalyKind::PathTraversal {
original_path: index.original_path.clone(),
});
}
if index.deleted_at.is_none() {
anomalies.push(AnomalyKind::DeletionTimeMissing {
original_path: index.original_path.clone(),
});
}
anomalies
.iter()
.map(|a| a.to_finding(source.clone()))
.collect()
}