trash_core/lib.rs
1//! Read-only reader for the Windows Recycle Bin `$I` index file format.
2//!
3//! When a file is sent to the Recycle Bin on Windows Vista and later, the shell
4//! writes two files into `$Recycle.Bin\<SID>\`:
5//!
6//! * an **`$I…`** index file holding the deleted file's metadata (original path,
7//! original size, deletion time), and
8//! * an **`$R…`** content file holding the deleted file's data.
9//!
10//! The two are paired by the trailing identifier + extension after the `$I` /
11//! `$R` prefix (`$IAB12CD.docx` ⇄ `$RAB12CD.docx`).
12//!
13//! This crate parses the `$I` metadata and pairs `$I`/`$R` files by a directory
14//! scan. It produces no findings — the [`trash-forensic`] analyzer layers
15//! anomaly detection on top.
16//!
17//! # Format
18//!
19//! The byte layout follows the libyal *Windows Recycle.Bin file formats*
20//! specification (see `docs/validation.md` for the citation):
21//!
22//! | Offset | Size | Field |
23//! |---|---|---|
24//! | 0 | 8 | Format version (`1` = pre-Win10, `2` = Win10+), little-endian |
25//! | 8 | 8 | Original file size, little-endian |
26//! | 16 | 8 | Deletion time, Windows `FILETIME` (100 ns ticks since 1601-01-01 UTC) |
27//!
28//! For **version 1** the original filename is a fixed 520-byte UTF-16LE field at
29//! offset 24 (260 `wchar_t`). For **version 2** offset 24 holds a 4-byte
30//! little-endian filename length in characters (including the NUL terminator),
31//! followed by the variable-length UTF-16LE path at offset 28.
32//!
33//! All integers are read through bounds-checked helpers: `$I` bytes are treated
34//! as attacker-controlled, so a truncated or hostile file yields an [`Error`],
35//! never a panic.
36//!
37//! [`trash-forensic`]: https://docs.rs/trash-forensic
38
39#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
40
41use std::path::{Path, PathBuf};
42
43use chrono::{DateTime, TimeZone, Utc};
44use thiserror::Error;
45
46/// Fixed header size shared by both format versions: version (8) + size (8) +
47/// FILETIME (8) = 24 bytes before any filename data.
48const HEADER_LEN: usize = 24;
49
50/// Version-1 fixed filename field: 260 `wchar_t` (UTF-16LE) = 520 bytes.
51const V1_NAME_LEN: usize = 520;
52
53/// Upper bound on a version-2 filename character count. Windows paths are capped
54/// far below this; the cap defends the allocation against a hostile length field
55/// (`u32::MAX` chars would request ~8 GiB). 32 768 chars (`\\?\` extended-path
56/// ceiling) is generous and bounded.
57const MAX_V2_NAME_CHARS: u32 = 32_768;
58
59/// Errors returned while parsing a `$I` index file.
60#[derive(Debug, Error, PartialEq, Eq)]
61pub enum Error {
62 /// The file is shorter than the 24-byte fixed header.
63 #[error("$I file truncated: {got} bytes, need at least {HEADER_LEN} for the header")]
64 TruncatedHeader {
65 /// Number of bytes actually present.
66 got: usize,
67 },
68
69 /// The 8-byte version field at offset 0 is neither `1` nor `2`.
70 #[error("unsupported $I format version {version} (raw bytes {raw:#018x}); expected 1 or 2")]
71 UnsupportedVersion {
72 /// The version value as read.
73 version: u64,
74 /// The raw little-endian bytes, for the investigator.
75 raw: u64,
76 },
77
78 /// A version-1 file does not contain the full fixed 520-byte name field.
79 #[error("$I v1 truncated: {got} bytes, need {needed} for the fixed 520-byte name field")]
80 TruncatedV1Name {
81 /// Bytes present.
82 got: usize,
83 /// Bytes required (24 + 520).
84 needed: usize,
85 },
86
87 /// A version-2 file is too short to hold the 4-byte name-length field.
88 #[error("$I v2 truncated: {got} bytes, need at least {needed} for the name-length field")]
89 TruncatedV2Length {
90 /// Bytes present.
91 got: usize,
92 /// Bytes required (24 + 4).
93 needed: usize,
94 },
95
96 /// The version-2 name-length field exceeds [`MAX_V2_NAME_CHARS`] — rejected
97 /// before allocating to defend against a hostile length.
98 #[error("$I v2 name length {chars} chars exceeds cap {MAX_V2_NAME_CHARS}")]
99 NameLengthTooLarge {
100 /// The offending character count.
101 chars: u32,
102 },
103
104 /// The version-2 name-length field claims more bytes than the file holds.
105 #[error("$I v2 name claims {needed} bytes but only {got} are present")]
106 TruncatedV2Name {
107 /// Bytes present after the length field.
108 got: usize,
109 /// Bytes the length field demands.
110 needed: usize,
111 },
112}
113
114/// The format version recorded in a `$I` file's 8-byte version field.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
117pub enum IndexVersion {
118 /// Pre-Windows 10: fixed 520-byte UTF-16LE filename at offset 24.
119 V1,
120 /// Windows 10 and later: length-prefixed variable-length filename.
121 V2,
122}
123
124/// Decoded metadata from a single `$I` index file.
125#[derive(Debug, Clone, PartialEq, Eq)]
126#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
127pub struct RecycleBinIndex {
128 /// Format version of the parsed `$I` file.
129 pub version: IndexVersion,
130 /// Original size of the deleted file, in bytes.
131 pub original_size: u64,
132 /// Deletion timestamp in UTC, or `None` when the `FILETIME` is zero
133 /// (recorded but not set).
134 pub deleted_at: Option<DateTime<Utc>>,
135 /// Original full path of the deleted file (UTF-16LE in the source).
136 pub original_path: String,
137}
138
139/// Parse the raw bytes of a `$I` index file.
140///
141/// # Errors
142///
143/// Returns [`Error`] when the data is truncated, carries an unsupported version,
144/// or (version 2) declares a filename length that overflows the buffer or the
145/// safety cap. Never panics on hostile input.
146pub fn parse_index(data: &[u8]) -> Result<RecycleBinIndex, Error> {
147 if data.len() < HEADER_LEN {
148 return Err(Error::TruncatedHeader { got: data.len() });
149 }
150
151 let raw_version = read_u64_le(data, 0);
152 let original_size = read_u64_le(data, 8);
153 let filetime = read_u64_le(data, 16);
154 let deleted_at = filetime_to_utc(filetime);
155
156 match raw_version {
157 1 => {
158 let end = HEADER_LEN + V1_NAME_LEN;
159 let name_bytes = data.get(HEADER_LEN..end).ok_or(Error::TruncatedV1Name {
160 got: data.len(),
161 needed: end,
162 })?;
163 let original_path = decode_utf16le_nul_terminated(name_bytes);
164 Ok(RecycleBinIndex {
165 version: IndexVersion::V1,
166 original_size,
167 deleted_at,
168 original_path,
169 })
170 }
171 2 => {
172 // Length field is 4 bytes at offset 24.
173 if data.len() < HEADER_LEN + 4 {
174 return Err(Error::TruncatedV2Length {
175 got: data.len(),
176 needed: HEADER_LEN + 4,
177 });
178 }
179 let chars = read_u32_le(data, HEADER_LEN);
180 if chars > MAX_V2_NAME_CHARS {
181 return Err(Error::NameLengthTooLarge { chars });
182 }
183 let name_bytes_len = chars as usize * 2;
184 let start = HEADER_LEN + 4;
185 let end = start + name_bytes_len;
186 let name_bytes = data.get(start..end).ok_or(Error::TruncatedV2Name {
187 got: data.len().saturating_sub(start),
188 needed: name_bytes_len,
189 })?;
190 let original_path = decode_utf16le_nul_terminated(name_bytes);
191 Ok(RecycleBinIndex {
192 version: IndexVersion::V2,
193 original_size,
194 deleted_at,
195 original_path,
196 })
197 }
198 other => Err(Error::UnsupportedVersion {
199 version: other,
200 raw: raw_version,
201 }),
202 }
203}
204
205/// A matched `$I`/`$R` pair (or a lone `$I`) discovered by a directory scan.
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct RecycleBinPair {
208 /// The `$I` index file path.
209 pub index_path: PathBuf,
210 /// The paired `$R` content file path, if one exists in the directory.
211 pub content_path: Option<PathBuf>,
212}
213
214/// Scan a directory for `$I` index files and pair each with its `$R` content
215/// file by the trailing identifier + extension.
216///
217/// Files are paired by replacing the leading `$I` with `$R` in the filename
218/// (`$IAB12CD.docx` ⇄ `$RAB12CD.docx`); a `$I` with no matching `$R` yields a
219/// pair whose `content_path` is `None`.
220///
221/// # Errors
222///
223/// Propagates any I/O error from reading the directory.
224pub fn scan_pairs(dir: &Path) -> std::io::Result<Vec<RecycleBinPair>> {
225 let mut pairs = Vec::new();
226 let entries = std::fs::read_dir(dir)?; // cov:unreachable: read_dir error arm needs a missing/denied dir
227 for entry in entries {
228 let entry = entry?; // cov:unreachable: per-entry `?` needs a mid-scan I/O fault tests cannot force
229 let name = entry.file_name();
230 let Some(name) = name.to_str() else {
231 continue; // cov:unreachable: non-UTF-8 entry is OS-specific, not portably constructible
232 };
233 if !is_index_name(name) {
234 continue;
235 }
236 let index_path = entry.path();
237 let content_name = content_name_for(name);
238 let candidate = dir.join(&content_name);
239 let content_path = candidate.is_file().then_some(candidate);
240 pairs.push(RecycleBinPair {
241 index_path,
242 content_path,
243 });
244 }
245 Ok(pairs)
246}
247
248/// Whether a filename is a `$I` index file (`$I` prefix, case-sensitive as on
249/// disk Windows stores it).
250fn is_index_name(name: &str) -> bool {
251 name.starts_with("$I")
252}
253
254/// Map a `$I…` filename to its paired `$R…` filename.
255fn content_name_for(index_name: &str) -> String {
256 // is_index_name guarantees the `$I` prefix, so this slice is in bounds.
257 match index_name.strip_prefix("$I") {
258 Some(rest) => format!("$R{rest}"),
259 None => index_name.to_string(), // cov:unreachable: callers gate on is_index_name
260 }
261}
262
263/// Read a little-endian `u64`, returning 0 if the range is out of bounds. The
264/// caller has already length-checked the header, so out-of-range never happens
265/// for the header reads; the guard keeps the helper panic-free for any caller.
266fn read_u64_le(data: &[u8], offset: usize) -> u64 {
267 match data.get(offset..offset + 8) {
268 Some(slice) => {
269 let mut buf = [0u8; 8];
270 buf.copy_from_slice(slice);
271 u64::from_le_bytes(buf)
272 }
273 None => 0, // cov:unreachable: header length-checked before every call
274 }
275}
276
277/// Read a little-endian `u32`, returning 0 if the range is out of bounds.
278fn read_u32_le(data: &[u8], offset: usize) -> u32 {
279 match data.get(offset..offset + 4) {
280 Some(slice) => {
281 let mut buf = [0u8; 4];
282 buf.copy_from_slice(slice);
283 u32::from_le_bytes(buf)
284 }
285 None => 0, // cov:unreachable: v2 length field length-checked before call
286 }
287}
288
289/// `FILETIME` ticks per second (100 ns units).
290const TICKS_PER_SECOND: u64 = 10_000_000;
291
292/// Seconds between the `FILETIME` epoch (1601-01-01) and the Unix epoch
293/// (1970-01-01).
294const EPOCH_DIFF_SECONDS: i64 = 11_644_473_600;
295
296/// Convert a Windows `FILETIME` (100 ns ticks since 1601-01-01 UTC) to a UTC
297/// datetime. A zero `FILETIME` means "not set" and maps to `None`. An out-of-range
298/// value (beyond chrono's representable span) also maps to `None` rather than
299/// panicking.
300fn filetime_to_utc(filetime: u64) -> Option<DateTime<Utc>> {
301 if filetime == 0 {
302 return None;
303 }
304 let secs_since_filetime = (filetime / TICKS_PER_SECOND) as i64;
305 let sub_tick = (filetime % TICKS_PER_SECOND) as u32;
306 let nanos = sub_tick * 100;
307 let unix_secs = secs_since_filetime - EPOCH_DIFF_SECONDS;
308 match Utc.timestamp_opt(unix_secs, nanos) {
309 chrono::LocalResult::Single(dt) => Some(dt),
310 _ => None, // cov:unreachable: nanos < 1e9 by construction, secs in i64 range
311 }
312}
313
314/// Decode a UTF-16LE byte slice up to the first NUL `wchar_t`, lossily replacing
315/// invalid sequences with U+FFFD. Bytes after the NUL terminator (padding in the
316/// fixed v1 field) are ignored.
317fn decode_utf16le_nul_terminated(bytes: &[u8]) -> String {
318 let mut units = Vec::with_capacity(bytes.len() / 2);
319 for pair in bytes.chunks_exact(2) {
320 let unit = u16::from_le_bytes([pair[0], pair[1]]);
321 if unit == 0 {
322 break;
323 }
324 units.push(unit);
325 }
326 String::from_utf16_lossy(&units)
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 /// A version-2 file with the 24-byte header but no 4-byte length field must
334 /// report `TruncatedV2Length`, not panic.
335 #[test]
336 fn v2_missing_length_field_is_error() {
337 let mut data = vec![0u8; HEADER_LEN];
338 data[0] = 2;
339 let err = parse_index(&data).unwrap_err();
340 assert!(matches!(err, Error::TruncatedV2Length { got, needed }
341 if got == HEADER_LEN && needed == HEADER_LEN + 4));
342 }
343
344 /// A version-2 length field claiming more name bytes than the file holds must
345 /// report `TruncatedV2Name`, not index out of bounds.
346 #[test]
347 fn v2_name_overruns_buffer_is_error() {
348 // header + length=10 chars (20 bytes) but only 4 name bytes present.
349 let mut data = vec![0u8; HEADER_LEN + 4 + 4];
350 data[0] = 2;
351 data[HEADER_LEN] = 10; // 10 chars => 20 bytes demanded
352 let err = parse_index(&data).unwrap_err();
353 assert!(matches!(err, Error::TruncatedV2Name { got, needed }
354 if got == 4 && needed == 20));
355 }
356
357 /// A non-`$I` filename maps to itself (defensive arm) without panic.
358 #[test]
359 fn content_name_for_non_index_is_identity() {
360 assert_eq!(content_name_for("readme.txt"), "readme.txt");
361 }
362
363 /// `scan_pairs` over a temp directory matches `$I` to `$R` and leaves a lone
364 /// `$I` unpaired, skipping non-index files.
365 #[test]
366 fn scan_pairs_directory_round_trip() {
367 let dir = std::env::temp_dir().join(format!("rb-core-scan-{}", std::process::id()));
368 let _ = std::fs::remove_dir_all(&dir);
369 std::fs::create_dir_all(&dir).unwrap();
370 std::fs::write(dir.join("$IAAAAAA.txt"), b"i").unwrap();
371 std::fs::write(dir.join("$RAAAAAA.txt"), b"r").unwrap();
372 std::fs::write(dir.join("$IBBBBBB.txt"), b"i").unwrap(); // lone $I
373 std::fs::write(dir.join("desktop.ini"), b"x").unwrap(); // ignored
374
375 let mut pairs = scan_pairs(&dir).unwrap();
376 pairs.sort_by_key(|p| p.index_path.clone());
377 assert_eq!(pairs.len(), 2);
378
379 let paired = pairs
380 .iter()
381 .find(|p| p.index_path.ends_with("$IAAAAAA.txt"))
382 .unwrap();
383 assert!(paired
384 .content_path
385 .as_ref()
386 .unwrap()
387 .ends_with("$RAAAAAA.txt"));
388
389 let lone = pairs
390 .iter()
391 .find(|p| p.index_path.ends_with("$IBBBBBB.txt"))
392 .unwrap();
393 assert!(lone.content_path.is_none());
394
395 std::fs::remove_dir_all(&dir).unwrap();
396 }
397}