Skip to main content

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}