use forensicnomicon::report::{Category, Evidence, Finding, Location, Severity, Source};
use trash_core::linux::{TrashEntry, TrashInfo};
use crate::{has_path_traversal, ANALYZER};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrashAnomaly {
ContentPurged {
original_path: String,
},
PathTraversal {
original_path: String,
},
DeletionTimeMissing {
original_path: String,
},
}
impl TrashAnomaly {
fn code(&self) -> &'static str {
match self {
TrashAnomaly::ContentPurged { .. } => "TRASH-CONTENT-PURGED",
TrashAnomaly::PathTraversal { .. } => "TRASH-PATH-TRAVERSAL",
TrashAnomaly::DeletionTimeMissing { .. } => "TRASH-DELETION-TIME-MISSING",
}
}
fn severity(&self) -> Severity {
match self {
TrashAnomaly::PathTraversal { .. } => Severity::High,
TrashAnomaly::ContentPurged { .. } | TrashAnomaly::DeletionTimeMissing { .. } => {
Severity::Medium
}
}
}
fn category(&self) -> Category {
match self {
TrashAnomaly::ContentPurged { .. } => Category::Residue,
TrashAnomaly::PathTraversal { .. } => Category::Concealment,
TrashAnomaly::DeletionTimeMissing { .. } => Category::Integrity,
}
}
fn original_path(&self) -> &str {
match self {
TrashAnomaly::ContentPurged { original_path }
| TrashAnomaly::PathTraversal { original_path }
| TrashAnomaly::DeletionTimeMissing { original_path } => original_path,
}
}
fn note(&self) -> String {
match self {
TrashAnomaly::ContentPurged { original_path } => format!(
"`.trashinfo` metadata for {original_path} survives but its `files/` content \
is absent — consistent with the content having been purged while its metadata \
remains"
),
TrashAnomaly::PathTraversal { original_path } => format!(
"stored Path= {original_path} contains a parent-directory ('..') component, \
which the Trash spec forbids — consistent with a crafted entry rather than a \
normal deletion"
),
TrashAnomaly::DeletionTimeMissing { original_path } => format!(
"DeletionDate= for {original_path} was absent or unparseable — the deletion \
time is unknown"
),
}
}
fn to_finding(&self, source: Source) -> Finding {
let path = self.original_path().to_string();
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(entry: &TrashEntry) -> Source {
let scope = entry
.info_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("info")
.to_string();
Source {
analyzer: ANALYZER.to_string(),
scope,
version: Some(env!("CARGO_PKG_VERSION").to_string()),
}
}
#[must_use]
pub fn audit_entry(info: &TrashInfo, entry: &TrashEntry) -> Vec<Finding> {
let source = source_for(entry);
let mut anomalies = Vec::new();
if entry.content_path.is_none() {
anomalies.push(TrashAnomaly::ContentPurged {
original_path: info.original_path.clone(),
});
}
if has_path_traversal(&info.original_path) {
anomalies.push(TrashAnomaly::PathTraversal {
original_path: info.original_path.clone(),
});
}
if info.deleted_at.is_none() {
anomalies.push(TrashAnomaly::DeletionTimeMissing {
original_path: info.original_path.clone(),
});
}
anomalies
.iter()
.map(|a| a.to_finding(source.clone()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
use std::path::PathBuf;
fn info(path: &str, dated: bool) -> TrashInfo {
TrashInfo {
original_path: path.to_string(),
deleted_at: dated.then(|| {
NaiveDate::from_ymd_opt(2024, 1, 15)
.unwrap()
.and_hms_opt(13, 45, 9)
.unwrap()
}),
}
}
fn entry(content: bool) -> TrashEntry {
TrashEntry {
info_path: PathBuf::from("/t/info/report.pdf.trashinfo"),
content_path: content.then(|| PathBuf::from("/t/files/report.pdf")),
}
}
#[test]
fn clean_entry_has_no_findings() {
assert!(audit_entry(&info("/home/u/report.pdf", true), &entry(true)).is_empty());
}
#[test]
fn content_purged_detected() {
let findings = audit_entry(&info("/home/u/report.pdf", true), &entry(false));
assert_eq!(findings.len(), 1);
let f = &findings[0];
assert_eq!(f.code, "TRASH-CONTENT-PURGED");
assert_eq!(f.category, Category::Residue);
assert_eq!(f.severity, Some(Severity::Medium));
assert_eq!(f.evidence[0].field, "original_path");
assert_eq!(f.evidence[0].value, "/home/u/report.pdf");
}
#[test]
fn path_traversal_detected() {
let findings = audit_entry(&info("../../etc/shadow", true), &entry(true));
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].code, "TRASH-PATH-TRAVERSAL");
assert_eq!(findings[0].category, Category::Concealment);
assert_eq!(findings[0].severity, Some(Severity::High));
}
#[test]
fn embedded_dots_not_flagged() {
assert!(audit_entry(&info("/home/u/my..notes.txt", true), &entry(true)).is_empty());
}
#[test]
fn deletion_time_missing_detected() {
let findings = audit_entry(&info("/home/u/report.pdf", false), &entry(true));
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].code, "TRASH-DELETION-TIME-MISSING");
assert_eq!(findings[0].category, Category::Integrity);
assert_eq!(findings[0].severity, Some(Severity::Medium));
}
#[test]
fn multiple_anomalies_stack() {
let findings = audit_entry(&info("../../../secret", false), &entry(false));
let codes: Vec<&str> = findings.iter().map(|f| f.code.as_ref()).collect();
assert_eq!(findings.len(), 3);
assert!(codes.contains(&"TRASH-CONTENT-PURGED"));
assert!(codes.contains(&"TRASH-PATH-TRAVERSAL"));
assert!(codes.contains(&"TRASH-DELETION-TIME-MISSING"));
}
#[test]
fn source_carries_analyzer_and_scope() {
let findings = audit_entry(&info("/home/u/report.pdf", true), &entry(false));
let src = &findings[0].source;
assert_eq!(src.analyzer, ANALYZER);
assert_eq!(src.scope, "report.pdf.trashinfo");
}
}