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