1use forensicnomicon::report::{Category, Evidence, Finding, Location, Severity, Source};
15use trash_core::macos::PutBack;
16
17use crate::{has_path_traversal, ANALYZER};
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum DsStoreAnomaly {
22 OrphanMetadata {
26 evidence: String,
29 },
30 PutBackTraversal {
33 offending: String,
35 },
36}
37
38impl DsStoreAnomaly {
39 fn code(&self) -> &'static str {
41 match self {
42 DsStoreAnomaly::OrphanMetadata { .. } => "TRASH-ORPHAN-METADATA",
43 DsStoreAnomaly::PutBackTraversal { .. } => "TRASH-PUTBACK-TRAVERSAL",
44 }
45 }
46
47 fn severity(&self) -> Severity {
49 match self {
50 DsStoreAnomaly::OrphanMetadata { .. } => Severity::Medium,
51 DsStoreAnomaly::PutBackTraversal { .. } => Severity::High,
52 }
53 }
54
55 fn category(&self) -> Category {
57 match self {
58 DsStoreAnomaly::OrphanMetadata { .. } => Category::Residue,
59 DsStoreAnomaly::PutBackTraversal { .. } => Category::Concealment,
60 }
61 }
62
63 fn evidence(&self) -> (&'static str, &str) {
65 match self {
66 DsStoreAnomaly::OrphanMetadata { evidence } => ("original_path", evidence),
67 DsStoreAnomaly::PutBackTraversal { offending } => ("put_back_path", offending),
68 }
69 }
70
71 fn note(&self) -> String {
73 match self {
74 DsStoreAnomaly::OrphanMetadata { evidence } => format!(
75 "a .DS_Store put-back record for {evidence} survives but the item is absent from \
76 the Trash — consistent with the content having been emptied while its metadata \
77 remains"
78 ),
79 DsStoreAnomaly::PutBackTraversal { offending } => format!(
80 "stored put-back path {offending} contains a parent-directory ('..') component — \
81 consistent with a crafted record whose restore would escape the intended tree"
82 ),
83 }
84 }
85
86 fn to_finding(&self, source: Source) -> Finding {
88 let (field, value) = self.evidence();
89 Finding::observation(self.severity(), self.category(), self.code())
90 .note(self.note())
91 .source(source)
92 .evidence_item(Evidence {
93 field: field.to_string(),
94 value: value.to_string(),
95 location: Some(Location::Path(value.to_string())),
96 })
97 .build()
98 }
99}
100
101fn source_for(record: &PutBack) -> Source {
103 Source {
104 analyzer: ANALYZER.to_string(),
105 scope: record.trash_name.clone(),
106 version: Some(env!("CARGO_PKG_VERSION").to_string()),
107 }
108}
109
110#[must_use]
118pub fn audit_put_back(record: &PutBack, item_present: bool) -> Vec<Finding> {
119 let source = source_for(record);
120 let mut anomalies = Vec::new();
121
122 if !item_present {
123 let evidence = record
124 .original_path()
125 .unwrap_or_else(|| record.trash_name.clone());
126 anomalies.push(DsStoreAnomaly::OrphanMetadata { evidence });
127 }
128
129 if let Some(offending) = [
131 record.original_location.as_deref(),
132 record.original_name.as_deref(),
133 ]
134 .into_iter()
135 .flatten()
136 .find(|value| has_path_traversal(value))
137 {
138 anomalies.push(DsStoreAnomaly::PutBackTraversal {
139 offending: offending.to_string(),
140 });
141 }
142
143 anomalies
144 .iter()
145 .map(|a| a.to_finding(source.clone()))
146 .collect()
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 fn put_back(name: &str, original_name: Option<&str>, location: Option<&str>) -> PutBack {
154 PutBack {
155 trash_name: name.to_string(),
156 original_name: original_name.map(str::to_string),
157 original_location: location.map(str::to_string),
158 }
159 }
160
161 fn clean() -> PutBack {
162 put_back(
163 "report.pdf",
164 Some("report.pdf"),
165 Some("System/Volumes/Data/Users/x/Downloads/"),
166 )
167 }
168
169 #[test]
171 fn present_clean_item_has_no_findings() {
172 assert!(audit_put_back(&clean(), true).is_empty());
173 }
174
175 #[test]
177 fn orphan_metadata_detected() {
178 let findings = audit_put_back(&clean(), false);
179 assert_eq!(findings.len(), 1);
180 assert_eq!(findings[0].code, "TRASH-ORPHAN-METADATA");
181 assert_eq!(findings[0].category, Category::Residue);
182 assert_eq!(findings[0].severity, Some(Severity::Medium));
183 assert_eq!(
185 findings[0].evidence[0].value,
186 "/Users/x/Downloads/report.pdf"
187 );
188 }
189
190 #[test]
192 fn traversal_in_location_detected() {
193 let r = put_back(
194 "p",
195 Some("p"),
196 Some("System/Volumes/Data/Users/x/../../etc/"),
197 );
198 let findings = audit_put_back(&r, true);
199 assert_eq!(findings.len(), 1);
200 assert_eq!(findings[0].code, "TRASH-PUTBACK-TRAVERSAL");
201 assert_eq!(findings[0].category, Category::Concealment);
202 assert_eq!(findings[0].severity, Some(Severity::High));
203 }
204
205 #[test]
207 fn traversal_in_name_detected() {
208 let r = put_back("p", Some("../escape"), Some("System/Volumes/Data/Users/x/"));
209 let findings = audit_put_back(&r, true);
210 assert_eq!(findings.len(), 1);
211 assert_eq!(findings[0].code, "TRASH-PUTBACK-TRAVERSAL");
212 }
213
214 #[test]
216 fn orphan_and_traversal_stack() {
217 let r = put_back("p", Some("p"), Some("System/Volumes/Data/Users/x/../etc/"));
218 let findings = audit_put_back(&r, false);
219 let codes: Vec<&str> = findings.iter().map(|f| f.code.as_ref()).collect();
220 assert_eq!(findings.len(), 2);
221 assert!(codes.contains(&"TRASH-ORPHAN-METADATA"));
222 assert!(codes.contains(&"TRASH-PUTBACK-TRAVERSAL"));
223 }
224
225 #[test]
227 fn source_carries_analyzer_and_scope() {
228 let findings = audit_put_back(&clean(), false);
229 assert_eq!(findings[0].source.analyzer, ANALYZER);
230 assert_eq!(findings[0].source.scope, "report.pdf");
231 }
232}