use std::path::{Path, PathBuf};
use chrono::{DateTime, TimeZone, Utc};
use thiserror::Error;
const HEADER_LEN: usize = 24;
const V1_NAME_LEN: usize = 520;
const MAX_V2_NAME_CHARS: u32 = 32_768;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum Error {
#[error("$I file truncated: {got} bytes, need at least {HEADER_LEN} for the header")]
TruncatedHeader {
got: usize,
},
#[error("unsupported $I format version {version} (raw bytes {raw:#018x}); expected 1 or 2")]
UnsupportedVersion {
version: u64,
raw: u64,
},
#[error("$I v1 truncated: {got} bytes, need {needed} for the fixed 520-byte name field")]
TruncatedV1Name {
got: usize,
needed: usize,
},
#[error("$I v2 truncated: {got} bytes, need at least {needed} for the name-length field")]
TruncatedV2Length {
got: usize,
needed: usize,
},
#[error("$I v2 name length {chars} chars exceeds cap {MAX_V2_NAME_CHARS}")]
NameLengthTooLarge {
chars: u32,
},
#[error("$I v2 name claims {needed} bytes but only {got} are present")]
TruncatedV2Name {
got: usize,
needed: usize,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum IndexVersion {
V1,
V2,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RecycleBinIndex {
pub version: IndexVersion,
pub original_size: u64,
pub deleted_at: Option<DateTime<Utc>>,
pub original_path: String,
}
pub fn parse_index(data: &[u8]) -> Result<RecycleBinIndex, Error> {
if data.len() < HEADER_LEN {
return Err(Error::TruncatedHeader { got: data.len() });
}
let raw_version = read_u64_le(data, 0);
let original_size = read_u64_le(data, 8);
let filetime = read_u64_le(data, 16);
let deleted_at = filetime_to_utc(filetime);
match raw_version {
1 => {
let end = HEADER_LEN + V1_NAME_LEN;
let name_bytes = data.get(HEADER_LEN..end).ok_or(Error::TruncatedV1Name {
got: data.len(),
needed: end,
})?;
let original_path = decode_utf16le_nul_terminated(name_bytes);
Ok(RecycleBinIndex {
version: IndexVersion::V1,
original_size,
deleted_at,
original_path,
})
}
2 => {
if data.len() < HEADER_LEN + 4 {
return Err(Error::TruncatedV2Length {
got: data.len(),
needed: HEADER_LEN + 4,
});
}
let chars = read_u32_le(data, HEADER_LEN);
if chars > MAX_V2_NAME_CHARS {
return Err(Error::NameLengthTooLarge { chars });
}
let name_bytes_len = chars as usize * 2;
let start = HEADER_LEN + 4;
let end = start + name_bytes_len;
let name_bytes = data.get(start..end).ok_or(Error::TruncatedV2Name {
got: data.len().saturating_sub(start),
needed: name_bytes_len,
})?;
let original_path = decode_utf16le_nul_terminated(name_bytes);
Ok(RecycleBinIndex {
version: IndexVersion::V2,
original_size,
deleted_at,
original_path,
})
}
other => Err(Error::UnsupportedVersion {
version: other,
raw: raw_version,
}),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecycleBinPair {
pub index_path: PathBuf,
pub content_path: Option<PathBuf>,
}
pub fn scan_pairs(dir: &Path) -> std::io::Result<Vec<RecycleBinPair>> {
let mut pairs = Vec::new();
let entries = std::fs::read_dir(dir)?; for entry in entries {
let entry = entry?; let name = entry.file_name();
let Some(name) = name.to_str() else {
continue; };
if !is_index_name(name) {
continue;
}
let index_path = entry.path();
let content_name = content_name_for(name);
let candidate = dir.join(&content_name);
let content_path = candidate.is_file().then_some(candidate);
pairs.push(RecycleBinPair {
index_path,
content_path,
});
}
Ok(pairs)
}
fn is_index_name(name: &str) -> bool {
name.starts_with("$I")
}
fn content_name_for(index_name: &str) -> String {
match index_name.strip_prefix("$I") {
Some(rest) => format!("$R{rest}"),
None => index_name.to_string(), }
}
fn read_u64_le(data: &[u8], offset: usize) -> u64 {
match data.get(offset..offset + 8) {
Some(slice) => {
let mut buf = [0u8; 8];
buf.copy_from_slice(slice);
u64::from_le_bytes(buf)
}
None => 0, }
}
fn read_u32_le(data: &[u8], offset: usize) -> u32 {
match data.get(offset..offset + 4) {
Some(slice) => {
let mut buf = [0u8; 4];
buf.copy_from_slice(slice);
u32::from_le_bytes(buf)
}
None => 0, }
}
const TICKS_PER_SECOND: u64 = 10_000_000;
const EPOCH_DIFF_SECONDS: i64 = 11_644_473_600;
fn filetime_to_utc(filetime: u64) -> Option<DateTime<Utc>> {
if filetime == 0 {
return None;
}
let secs_since_filetime = (filetime / TICKS_PER_SECOND) as i64;
let sub_tick = (filetime % TICKS_PER_SECOND) as u32;
let nanos = sub_tick * 100;
let unix_secs = secs_since_filetime - EPOCH_DIFF_SECONDS;
match Utc.timestamp_opt(unix_secs, nanos) {
chrono::LocalResult::Single(dt) => Some(dt),
_ => None, }
}
fn decode_utf16le_nul_terminated(bytes: &[u8]) -> String {
let mut units = Vec::with_capacity(bytes.len() / 2);
for pair in bytes.chunks_exact(2) {
let unit = u16::from_le_bytes([pair[0], pair[1]]);
if unit == 0 {
break;
}
units.push(unit);
}
String::from_utf16_lossy(&units)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn v2_missing_length_field_is_error() {
let mut data = vec![0u8; HEADER_LEN];
data[0] = 2;
let err = parse_index(&data).unwrap_err();
assert!(matches!(err, Error::TruncatedV2Length { got, needed }
if got == HEADER_LEN && needed == HEADER_LEN + 4));
}
#[test]
fn v2_name_overruns_buffer_is_error() {
let mut data = vec![0u8; HEADER_LEN + 4 + 4];
data[0] = 2;
data[HEADER_LEN] = 10; let err = parse_index(&data).unwrap_err();
assert!(matches!(err, Error::TruncatedV2Name { got, needed }
if got == 4 && needed == 20));
}
#[test]
fn content_name_for_non_index_is_identity() {
assert_eq!(content_name_for("readme.txt"), "readme.txt");
}
#[test]
fn scan_pairs_directory_round_trip() {
let dir = std::env::temp_dir().join(format!("rb-core-scan-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("$IAAAAAA.txt"), b"i").unwrap();
std::fs::write(dir.join("$RAAAAAA.txt"), b"r").unwrap();
std::fs::write(dir.join("$IBBBBBB.txt"), b"i").unwrap(); std::fs::write(dir.join("desktop.ini"), b"x").unwrap();
let mut pairs = scan_pairs(&dir).unwrap();
pairs.sort_by_key(|p| p.index_path.clone());
assert_eq!(pairs.len(), 2);
let paired = pairs
.iter()
.find(|p| p.index_path.ends_with("$IAAAAAA.txt"))
.unwrap();
assert!(paired
.content_path
.as_ref()
.unwrap()
.ends_with("$RAAAAAA.txt"));
let lone = pairs
.iter()
.find(|p| p.index_path.ends_with("$IBBBBBB.txt"))
.unwrap();
assert!(lone.content_path.is_none());
std::fs::remove_dir_all(&dir).unwrap();
}
}