use forensicnomicon::report::{Category, Evidence, Finding, Location, Severity, Source};
use trash_core::macos::PutBack;
use crate::{has_path_traversal, ANALYZER};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DsStoreAnomaly {
OrphanMetadata {
evidence: String,
},
PutBackTraversal {
offending: String,
},
}
impl DsStoreAnomaly {
fn code(&self) -> &'static str {
match self {
DsStoreAnomaly::OrphanMetadata { .. } => "TRASH-ORPHAN-METADATA",
DsStoreAnomaly::PutBackTraversal { .. } => "TRASH-PUTBACK-TRAVERSAL",
}
}
fn severity(&self) -> Severity {
match self {
DsStoreAnomaly::OrphanMetadata { .. } => Severity::Medium,
DsStoreAnomaly::PutBackTraversal { .. } => Severity::High,
}
}
fn category(&self) -> Category {
match self {
DsStoreAnomaly::OrphanMetadata { .. } => Category::Residue,
DsStoreAnomaly::PutBackTraversal { .. } => Category::Concealment,
}
}
fn evidence(&self) -> (&'static str, &str) {
match self {
DsStoreAnomaly::OrphanMetadata { evidence } => ("original_path", evidence),
DsStoreAnomaly::PutBackTraversal { offending } => ("put_back_path", offending),
}
}
fn note(&self) -> String {
match self {
DsStoreAnomaly::OrphanMetadata { evidence } => format!(
"a .DS_Store put-back record for {evidence} survives but the item is absent from \
the Trash — consistent with the content having been emptied while its metadata \
remains"
),
DsStoreAnomaly::PutBackTraversal { offending } => format!(
"stored put-back path {offending} contains a parent-directory ('..') component — \
consistent with a crafted record whose restore would escape the intended tree"
),
}
}
fn to_finding(&self, source: Source) -> Finding {
let (field, value) = self.evidence();
Finding::observation(self.severity(), self.category(), self.code())
.note(self.note())
.source(source)
.evidence_item(Evidence {
field: field.to_string(),
value: value.to_string(),
location: Some(Location::Path(value.to_string())),
})
.build()
}
}
fn source_for(record: &PutBack) -> Source {
Source {
analyzer: ANALYZER.to_string(),
scope: record.trash_name.clone(),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
}
}
#[must_use]
pub fn audit_put_back(record: &PutBack, item_present: bool) -> Vec<Finding> {
let source = source_for(record);
let mut anomalies = Vec::new();
if !item_present {
let evidence = record
.original_path()
.unwrap_or_else(|| record.trash_name.clone());
anomalies.push(DsStoreAnomaly::OrphanMetadata { evidence });
}
if let Some(offending) = [
record.original_location.as_deref(),
record.original_name.as_deref(),
]
.into_iter()
.flatten()
.find(|value| has_path_traversal(value))
{
anomalies.push(DsStoreAnomaly::PutBackTraversal {
offending: offending.to_string(),
});
}
anomalies
.iter()
.map(|a| a.to_finding(source.clone()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn put_back(name: &str, original_name: Option<&str>, location: Option<&str>) -> PutBack {
PutBack {
trash_name: name.to_string(),
original_name: original_name.map(str::to_string),
original_location: location.map(str::to_string),
}
}
fn clean() -> PutBack {
put_back(
"report.pdf",
Some("report.pdf"),
Some("System/Volumes/Data/Users/x/Downloads/"),
)
}
#[test]
fn present_clean_item_has_no_findings() {
assert!(audit_put_back(&clean(), true).is_empty());
}
#[test]
fn orphan_metadata_detected() {
let findings = audit_put_back(&clean(), false);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].code, "TRASH-ORPHAN-METADATA");
assert_eq!(findings[0].category, Category::Residue);
assert_eq!(findings[0].severity, Some(Severity::Medium));
assert_eq!(
findings[0].evidence[0].value,
"/Users/x/Downloads/report.pdf"
);
}
#[test]
fn traversal_in_location_detected() {
let r = put_back(
"p",
Some("p"),
Some("System/Volumes/Data/Users/x/../../etc/"),
);
let findings = audit_put_back(&r, true);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].code, "TRASH-PUTBACK-TRAVERSAL");
assert_eq!(findings[0].category, Category::Concealment);
assert_eq!(findings[0].severity, Some(Severity::High));
}
#[test]
fn traversal_in_name_detected() {
let r = put_back("p", Some("../escape"), Some("System/Volumes/Data/Users/x/"));
let findings = audit_put_back(&r, true);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].code, "TRASH-PUTBACK-TRAVERSAL");
}
#[test]
fn orphan_and_traversal_stack() {
let r = put_back("p", Some("p"), Some("System/Volumes/Data/Users/x/../etc/"));
let findings = audit_put_back(&r, false);
let codes: Vec<&str> = findings.iter().map(|f| f.code.as_ref()).collect();
assert_eq!(findings.len(), 2);
assert!(codes.contains(&"TRASH-ORPHAN-METADATA"));
assert!(codes.contains(&"TRASH-PUTBACK-TRAVERSAL"));
}
#[test]
fn source_carries_analyzer_and_scope() {
let findings = audit_put_back(&clean(), false);
assert_eq!(findings[0].source.analyzer, ANALYZER);
assert_eq!(findings[0].source.scope, "report.pdf");
}
}