trash_forensic/
windows.rs1use forensicnomicon::report::{Category, Evidence, Finding, Location, Severity, Source};
17use trash_core::{RecycleBinIndex, RecycleBinPair};
18
19use crate::{has_path_traversal, ANALYZER};
20
21#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum AnomalyKind {
27 ContentPurged {
30 original_path: String,
32 },
33 PathTraversal {
37 original_path: String,
39 },
40 DeletionTimeMissing {
42 original_path: String,
44 },
45}
46
47impl AnomalyKind {
48 #[must_use]
50 pub fn code(&self) -> &'static str {
51 match self {
52 AnomalyKind::ContentPurged { .. } => "RECYCLEBIN-CONTENT-PURGED",
53 AnomalyKind::PathTraversal { .. } => "RECYCLEBIN-PATH-TRAVERSAL",
54 AnomalyKind::DeletionTimeMissing { .. } => "RECYCLEBIN-DELETION-TIME-MISSING",
55 }
56 }
57
58 #[must_use]
60 pub fn severity(&self) -> Severity {
61 match self {
62 AnomalyKind::ContentPurged { .. } => Severity::Medium,
63 AnomalyKind::PathTraversal { .. } => Severity::High,
64 AnomalyKind::DeletionTimeMissing { .. } => Severity::Low,
65 }
66 }
67
68 #[must_use]
70 pub fn category(&self) -> Category {
71 match self {
72 AnomalyKind::ContentPurged { .. } => Category::Residue,
73 AnomalyKind::PathTraversal { .. } => Category::Concealment,
74 AnomalyKind::DeletionTimeMissing { .. } => Category::Integrity,
75 }
76 }
77
78 #[must_use]
80 pub fn note(&self) -> String {
81 match self {
82 AnomalyKind::ContentPurged { original_path } => format!(
83 "$I index for {original_path} survives but its $R content file is absent — \
84 consistent with the file's content having been purged while its metadata remains"
85 ),
86 AnomalyKind::PathTraversal { original_path } => format!(
87 "stored original path {original_path} contains parent-directory ('..') \
88 components — consistent with a crafted name rather than a normal deletion"
89 ),
90 AnomalyKind::DeletionTimeMissing { original_path } => format!(
91 "deletion FILETIME for {original_path} is zero (unset) — the deletion time \
92 was not recorded or has been cleared"
93 ),
94 }
95 }
96
97 fn to_finding(&self, source: Source) -> Finding {
99 let path = match self {
100 AnomalyKind::ContentPurged { original_path }
101 | AnomalyKind::PathTraversal { original_path }
102 | AnomalyKind::DeletionTimeMissing { original_path } => original_path.clone(),
103 };
104 Finding::observation(self.severity(), self.category(), self.code())
105 .note(self.note())
106 .source(source)
107 .evidence_item(Evidence {
108 field: "original_path".to_string(),
109 value: path.clone(),
110 location: Some(Location::Path(path)),
111 })
112 .build()
113 }
114}
115
116fn source_for(pair: &RecycleBinPair) -> Source {
118 let scope = pair
119 .index_path
120 .file_name()
121 .and_then(|n| n.to_str())
122 .unwrap_or("$I")
123 .to_string();
124 Source {
125 analyzer: ANALYZER.to_string(),
126 scope,
127 version: Some(env!("CARGO_PKG_VERSION").to_string()),
128 }
129}
130
131#[must_use]
138pub fn audit_pair(index: &RecycleBinIndex, pair: &RecycleBinPair) -> Vec<Finding> {
139 let source = source_for(pair);
140 let mut anomalies = Vec::new();
141
142 if pair.content_path.is_none() {
143 anomalies.push(AnomalyKind::ContentPurged {
144 original_path: index.original_path.clone(),
145 });
146 }
147
148 if has_path_traversal(&index.original_path) {
149 anomalies.push(AnomalyKind::PathTraversal {
150 original_path: index.original_path.clone(),
151 });
152 }
153
154 if index.deleted_at.is_none() {
155 anomalies.push(AnomalyKind::DeletionTimeMissing {
156 original_path: index.original_path.clone(),
157 });
158 }
159
160 anomalies
161 .iter()
162 .map(|a| a.to_finding(source.clone()))
163 .collect()
164}