Skip to main content

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}