1use forensicnomicon::report::{Category, Evidence, Finding, Location, Severity, Source};
17use trash_core::linux::{TrashEntry, TrashInfo};
18
19use crate::{has_path_traversal, ANALYZER};
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum TrashAnomaly {
24 ContentPurged {
27 original_path: String,
29 },
30 PathTraversal {
33 original_path: String,
35 },
36 DeletionTimeMissing {
38 original_path: String,
40 },
41}
42
43impl TrashAnomaly {
44 fn code(&self) -> &'static str {
46 match self {
47 TrashAnomaly::ContentPurged { .. } => "TRASH-CONTENT-PURGED",
48 TrashAnomaly::PathTraversal { .. } => "TRASH-PATH-TRAVERSAL",
49 TrashAnomaly::DeletionTimeMissing { .. } => "TRASH-DELETION-TIME-MISSING",
50 }
51 }
52
53 fn severity(&self) -> Severity {
55 match self {
56 TrashAnomaly::PathTraversal { .. } => Severity::High,
57 TrashAnomaly::ContentPurged { .. } | TrashAnomaly::DeletionTimeMissing { .. } => {
58 Severity::Medium
59 }
60 }
61 }
62
63 fn category(&self) -> Category {
65 match self {
66 TrashAnomaly::ContentPurged { .. } => Category::Residue,
67 TrashAnomaly::PathTraversal { .. } => Category::Concealment,
68 TrashAnomaly::DeletionTimeMissing { .. } => Category::Integrity,
69 }
70 }
71
72 fn original_path(&self) -> &str {
74 match self {
75 TrashAnomaly::ContentPurged { original_path }
76 | TrashAnomaly::PathTraversal { original_path }
77 | TrashAnomaly::DeletionTimeMissing { original_path } => original_path,
78 }
79 }
80
81 fn note(&self) -> String {
83 match self {
84 TrashAnomaly::ContentPurged { original_path } => format!(
85 "`.trashinfo` metadata for {original_path} survives but its `files/` content \
86 is absent — consistent with the content having been purged while its metadata \
87 remains"
88 ),
89 TrashAnomaly::PathTraversal { original_path } => format!(
90 "stored Path= {original_path} contains a parent-directory ('..') component, \
91 which the Trash spec forbids — consistent with a crafted entry rather than a \
92 normal deletion"
93 ),
94 TrashAnomaly::DeletionTimeMissing { original_path } => format!(
95 "DeletionDate= for {original_path} was absent or unparseable — the deletion \
96 time is unknown"
97 ),
98 }
99 }
100
101 fn to_finding(&self, source: Source) -> Finding {
103 let path = self.original_path().to_string();
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(entry: &TrashEntry) -> Source {
118 let scope = entry
119 .info_path
120 .file_name()
121 .and_then(|n| n.to_str())
122 .unwrap_or("info")
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_entry(info: &TrashInfo, entry: &TrashEntry) -> Vec<Finding> {
139 let source = source_for(entry);
140 let mut anomalies = Vec::new();
141
142 if entry.content_path.is_none() {
143 anomalies.push(TrashAnomaly::ContentPurged {
144 original_path: info.original_path.clone(),
145 });
146 }
147
148 if has_path_traversal(&info.original_path) {
149 anomalies.push(TrashAnomaly::PathTraversal {
150 original_path: info.original_path.clone(),
151 });
152 }
153
154 if info.deleted_at.is_none() {
155 anomalies.push(TrashAnomaly::DeletionTimeMissing {
156 original_path: info.original_path.clone(),
157 });
158 }
159
160 anomalies
161 .iter()
162 .map(|a| a.to_finding(source.clone()))
163 .collect()
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use chrono::NaiveDate;
170 use std::path::PathBuf;
171
172 fn info(path: &str, dated: bool) -> TrashInfo {
173 TrashInfo {
174 original_path: path.to_string(),
175 deleted_at: dated.then(|| {
176 NaiveDate::from_ymd_opt(2024, 1, 15)
177 .unwrap()
178 .and_hms_opt(13, 45, 9)
179 .unwrap()
180 }),
181 }
182 }
183
184 fn entry(content: bool) -> TrashEntry {
185 TrashEntry {
186 info_path: PathBuf::from("/t/info/report.pdf.trashinfo"),
187 content_path: content.then(|| PathBuf::from("/t/files/report.pdf")),
188 }
189 }
190
191 #[test]
193 fn clean_entry_has_no_findings() {
194 assert!(audit_entry(&info("/home/u/report.pdf", true), &entry(true)).is_empty());
195 }
196
197 #[test]
199 fn content_purged_detected() {
200 let findings = audit_entry(&info("/home/u/report.pdf", true), &entry(false));
201 assert_eq!(findings.len(), 1);
202 let f = &findings[0];
203 assert_eq!(f.code, "TRASH-CONTENT-PURGED");
204 assert_eq!(f.category, Category::Residue);
205 assert_eq!(f.severity, Some(Severity::Medium));
206 assert_eq!(f.evidence[0].field, "original_path");
208 assert_eq!(f.evidence[0].value, "/home/u/report.pdf");
209 }
210
211 #[test]
213 fn path_traversal_detected() {
214 let findings = audit_entry(&info("../../etc/shadow", true), &entry(true));
215 assert_eq!(findings.len(), 1);
216 assert_eq!(findings[0].code, "TRASH-PATH-TRAVERSAL");
217 assert_eq!(findings[0].category, Category::Concealment);
218 assert_eq!(findings[0].severity, Some(Severity::High));
219 }
220
221 #[test]
223 fn embedded_dots_not_flagged() {
224 assert!(audit_entry(&info("/home/u/my..notes.txt", true), &entry(true)).is_empty());
225 }
226
227 #[test]
229 fn deletion_time_missing_detected() {
230 let findings = audit_entry(&info("/home/u/report.pdf", false), &entry(true));
231 assert_eq!(findings.len(), 1);
232 assert_eq!(findings[0].code, "TRASH-DELETION-TIME-MISSING");
233 assert_eq!(findings[0].category, Category::Integrity);
234 assert_eq!(findings[0].severity, Some(Severity::Medium));
235 }
236
237 #[test]
239 fn multiple_anomalies_stack() {
240 let findings = audit_entry(&info("../../../secret", false), &entry(false));
241 let codes: Vec<&str> = findings.iter().map(|f| f.code.as_ref()).collect();
242 assert_eq!(findings.len(), 3);
243 assert!(codes.contains(&"TRASH-CONTENT-PURGED"));
244 assert!(codes.contains(&"TRASH-PATH-TRAVERSAL"));
245 assert!(codes.contains(&"TRASH-DELETION-TIME-MISSING"));
246 }
247
248 #[test]
250 fn source_carries_analyzer_and_scope() {
251 let findings = audit_entry(&info("/home/u/report.pdf", true), &entry(false));
252 let src = &findings[0].source;
253 assert_eq!(src.analyzer, ANALYZER);
254 assert_eq!(src.scope, "report.pdf.trashinfo");
255 }
256}