use crate::reader;
use crate::{dosdate, ShellItem, ShellItemKind};
use forensicnomicon::shellbags;
#[must_use]
pub fn parse_idlist(data: &[u8]) -> Vec<ShellItem> {
let mut items = Vec::new();
let mut pos = 0usize;
while pos + 2 <= data.len() {
let cb = crate::reader::le_u16(data, pos) as usize;
if cb == 0 {
break; }
if cb < 3 {
break; }
let end = match pos.checked_add(cb) {
Some(e) if e <= data.len() => e,
_ => break, };
let raw = data[pos..end].to_vec();
let class = raw[2];
items.push(decode_item(class, raw));
pos = end;
}
items
}
#[must_use]
pub fn reconstruct_path(items: &[ShellItem]) -> String {
items
.iter()
.filter_map(ShellItem::display_name)
.collect::<Vec<_>>()
.join("\\")
}
fn blank(class: u8, kind: ShellItemKind, raw: Vec<u8>) -> ShellItem {
ShellItem {
class,
kind,
name: None,
long_name: None,
file_size: None,
modified: None,
created: None,
accessed: None,
mft_entry: None,
mft_sequence: None,
guid: None,
raw,
}
}
fn known_folder_name(guid: &str) -> Option<&'static str> {
if guid.eq_ignore_ascii_case(shellbags::MY_COMPUTER_GUID) {
Some("My Computer")
} else {
None
}
}
fn decode_item(class: u8, raw: Vec<u8>) -> ShellItem {
if shellbags::is_file_entry(class) {
return decode_file_entry(class, raw);
}
if class == shellbags::CLASS_NETWORK_LOCATION {
return decode_network(class, raw);
}
match class {
shellbags::CLASS_ROOT_FOLDER => decode_root(class, raw),
shellbags::CLASS_VOLUME_2E | shellbags::CLASS_VOLUME_2F => decode_volume(class, raw),
_ => match shellbags::major_class(class) {
shellbags::MAJOR_CLASS_URI => blank(class, ShellItemKind::Uri, raw),
shellbags::MAJOR_CLASS_CONTROL_PANEL => blank(class, ShellItemKind::ControlPanel, raw),
_ => blank(class, ShellItemKind::Unknown, raw),
},
}
}
fn decode_network(class: u8, raw: Vec<u8>) -> ShellItem {
let mut item = blank(class, ShellItemKind::Network, raw);
let location = reader::ascii_z(&item.raw, 4);
if !location.is_empty() {
item.name = Some(location);
}
item
}
fn decode_file_entry(class: u8, raw: Vec<u8>) -> ShellItem {
let mut item = blank(class, ShellItemKind::FileEntry, raw);
let data = &item.raw;
item.file_size = Some(reader::le_u32(data, 4));
item.modified = dosdate::fat_to_epoch(reader::le_u32(data, 8));
let (short, short_end) = if class == shellbags::CLASS_FILE_ENTRY_UNICODE {
let s = reader::utf16_z(data, 14);
let consumed = (s.encode_utf16().count() + 1) * 2;
(s, 14 + consumed)
} else {
let s = reader::ascii_z(data, 14);
let mut end = 14 + s.len() + 1;
if end % 2 != 0 {
end += 1;
}
(s, end)
};
if !short.is_empty() {
item.name = Some(short);
}
parse_beef0004(&mut item, short_end);
item
}
fn parse_beef0004(item: &mut ShellItem, block_start: usize) {
let data = item.raw.clone();
let Some(block_off) = locate_beef0004(&data, block_start) else {
return;
};
let version = reader::le_u16(&data, block_off + 2);
item.created = dosdate::fat_to_epoch(reader::le_u32(&data, block_off + 8));
item.accessed = dosdate::fat_to_epoch(reader::le_u32(&data, block_off + 12));
let long_name_offset = reader::le_u16(&data, block_off + 16) as usize;
if long_name_offset != 0 {
let abs = block_off.saturating_add(long_name_offset);
let long = reader::utf16_z(&data, abs);
if !long.is_empty() {
item.long_name = Some(long);
}
}
if version >= 7 {
let entry = reader::le_u48(&data, block_off + 20);
let seq = reader::le_u16(&data, block_off + 26);
if entry != 0 || seq != 0 {
item.mft_entry = Some(entry);
item.mft_sequence = Some(seq);
}
}
}
fn locate_beef0004(data: &[u8], hint: usize) -> Option<usize> {
let sig = shellbags::EXTENSION_BLOCK_0XBEEF0004.to_le_bytes();
if reader::le_u32(data, hint.wrapping_add(4)) == shellbags::EXTENSION_BLOCK_0XBEEF0004 {
return Some(hint);
}
data.windows(4)
.position(|w| w == sig)
.and_then(|p| p.checked_sub(4))
}
fn decode_volume(class: u8, raw: Vec<u8>) -> ShellItem {
let mut item = blank(class, ShellItemKind::Volume, raw);
if class == shellbags::CLASS_VOLUME_2F {
let name = reader::ascii_z(&item.raw, 3);
if !name.is_empty() {
item.name = Some(name);
}
}
item
}
fn decode_root(class: u8, raw: Vec<u8>) -> ShellItem {
let mut item = blank(class, ShellItemKind::Root, raw);
if let Some(guid) = reader::guid(&item.raw, 4) {
item.name = known_folder_name(&guid).map(ToString::to_string);
item.guid = Some(guid);
}
item
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod reconstruct_tests {
use super::*;
use crate::{ShellItem, ShellItemKind};
fn item_named(long: Option<&str>, short: Option<&str>, guid: Option<&str>) -> ShellItem {
ShellItem {
class: 0x32,
kind: ShellItemKind::FileEntry,
name: short.map(ToString::to_string),
long_name: long.map(ToString::to_string),
file_size: None,
modified: None,
created: None,
accessed: None,
mft_entry: None,
mft_sequence: None,
guid: guid.map(ToString::to_string),
raw: vec![0, 0],
}
}
#[test]
fn joins_display_names_with_backslash() {
let items = vec![
item_named(None, None, Some("My Computer")),
item_named(None, Some("C:\\"), None),
item_named(Some("Users"), Some("USERS"), None),
item_named(Some("secret report.docx"), Some("SECRET~1.DOC"), None),
];
assert_eq!(
reconstruct_path(&items),
"My Computer\\C:\\\\Users\\secret report.docx"
);
}
#[test]
fn prefers_long_name_then_short_then_guid() {
let items = vec![
item_named(Some("long"), Some("short"), Some("guid")),
item_named(None, Some("short"), Some("guid")),
item_named(None, None, Some("GUID-ONLY")),
];
assert_eq!(reconstruct_path(&items), "long\\short\\GUID-ONLY");
}
#[test]
fn skips_items_with_no_display_name() {
let items = vec![
item_named(None, Some("C:\\"), None),
item_named(None, None, None), item_named(Some("file.txt"), None, None),
];
assert_eq!(reconstruct_path(&items), "C:\\\\file.txt");
}
#[test]
fn empty_list_yields_empty_path() {
assert_eq!(reconstruct_path(&[]), "");
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod framing_tests {
use super::*;
fn item(class: u8, data: &[u8]) -> Vec<u8> {
let cb = (3 + data.len()) as u16;
let mut v = cb.to_le_bytes().to_vec();
v.push(class);
v.extend_from_slice(data);
v
}
fn list(items: &[Vec<u8>]) -> Vec<u8> {
let mut v = Vec::new();
for it in items {
v.extend_from_slice(it);
}
v.extend_from_slice(&[0u8, 0u8]); v
}
#[test]
fn splits_two_items_and_stops_at_terminator() {
let blob = list(&[item(0x1F, &[0xAA; 16]), item(0x32, &[0xBB; 8])]);
let items = parse_idlist(&blob);
assert_eq!(items.len(), 2);
assert_eq!(items[0].class, 0x1F);
assert_eq!(items[1].class, 0x32);
}
#[test]
fn empty_input_yields_no_items() {
assert!(parse_idlist(&[]).is_empty());
assert!(parse_idlist(&[0u8, 0u8]).is_empty());
}
#[test]
fn cb_overrunning_buffer_stops_cleanly() {
let blob = vec![100u8, 0u8, 0x32, 0xAA, 0xBB];
let items = parse_idlist(&blob);
assert!(items.is_empty());
}
#[test]
fn cb_below_minimum_stops_cleanly() {
let blob = vec![1u8, 0u8, 0x32];
let items = parse_idlist(&blob);
assert!(items.is_empty());
}
#[test]
fn raw_bytes_are_preserved_per_item() {
let raw0 = item(0x1F, &[0xAA; 16]);
let blob = list(&[raw0.clone()]);
let items = parse_idlist(&blob);
assert_eq!(items[0].raw, raw0);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod root_tests {
use super::*;
use crate::ShellItemKind;
const MY_COMPUTER_BYTES: [u8; 16] = [
0xE0, 0x4F, 0xD0, 0x20, 0xEA, 0x3A, 0x69, 0x10, 0xA2, 0xD8, 0x08, 0x00, 0x2B, 0x30, 0x30, 0x9D, ];
fn root_item(sort: u8, guid: &[u8; 16]) -> Vec<u8> {
let mut data = vec![sort];
data.extend_from_slice(guid);
let cb = (3 + data.len()) as u16;
let mut v = cb.to_le_bytes().to_vec();
v.push(0x1F);
v.extend_from_slice(&data);
v.extend_from_slice(&[0u8, 0u8]); v
}
#[test]
fn root_item_decodes_guid_and_kind() {
let blob = root_item(0x00, &MY_COMPUTER_BYTES);
let items = parse_idlist(&blob);
assert_eq!(items.len(), 1);
assert_eq!(items[0].kind, ShellItemKind::Root);
assert_eq!(
items[0].guid.as_deref(),
Some("20D04FE0-3AEA-1069-A2D8-08002B30309D")
);
}
#[test]
fn my_computer_guid_maps_to_display_name() {
let blob = root_item(0x00, &MY_COMPUTER_BYTES);
let items = parse_idlist(&blob);
assert_eq!(items[0].name.as_deref(), Some("My Computer"));
assert_eq!(items[0].display_name(), Some("My Computer"));
}
#[test]
fn unknown_root_guid_has_no_name_but_keeps_guid() {
let other = [0x11u8; 16];
let blob = root_item(0x00, &other);
let items = parse_idlist(&blob);
assert_eq!(items[0].kind, ShellItemKind::Root);
assert!(items[0].name.is_none());
assert!(items[0].guid.is_some());
assert_eq!(items[0].display_name(), items[0].guid.as_deref());
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod volume_tests {
use super::*;
use crate::ShellItemKind;
fn volume_2f(name: &str) -> Vec<u8> {
let mut field = [0u8; 20];
for (i, b) in name.bytes().enumerate().take(19) {
field[i] = b;
}
let mut v = Vec::new();
let cb = (3 + field.len()) as u16;
v.extend_from_slice(&cb.to_le_bytes());
v.push(0x2F);
v.extend_from_slice(&field);
v.extend_from_slice(&[0u8, 0u8]);
v
}
#[test]
fn drive_letter_volume_decodes_name() {
let blob = volume_2f("C:\\");
let items = parse_idlist(&blob);
assert_eq!(items.len(), 1);
assert_eq!(items[0].kind, ShellItemKind::Volume);
assert_eq!(items[0].class, 0x2F);
assert_eq!(items[0].name.as_deref(), Some("C:\\"));
assert_eq!(items[0].display_name(), Some("C:\\"));
}
#[test]
fn volume_2e_is_classified_as_volume() {
let mut v = Vec::new();
v.extend_from_slice(&(3u16 + 16).to_le_bytes());
v.push(0x2E);
v.extend_from_slice(&[0u8; 16]);
v.extend_from_slice(&[0u8, 0u8]);
let items = parse_idlist(&v);
assert_eq!(items[0].kind, ShellItemKind::Volume);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod file_entry_tests {
use super::*;
use crate::ShellItemKind;
fn fat(year: u16, month: u8, day: u8, hour: u8, minute: u8, second: u8) -> u32 {
let date: u16 = ((year - 1980) << 9) | (u16::from(month) << 5) | u16::from(day);
let time: u16 = (u16::from(hour) << 11) | (u16::from(minute) << 5) | u16::from(second / 2);
(u32::from(date) << 16) | u32::from(time)
}
#[allow(clippy::too_many_arguments)]
fn file_entry_with_beef0004(
short: &str,
long: &str,
size: u32,
modified: u32,
created: u32,
accessed: u32,
mft_entry: u64,
mft_seq: u16,
) -> Vec<u8> {
let mut body = Vec::new();
body.push(0x32); body.push(0x00); body.extend_from_slice(&size.to_le_bytes()); body.extend_from_slice(&modified.to_le_bytes()); body.extend_from_slice(&0x0020u16.to_le_bytes()); body.extend_from_slice(short.as_bytes());
body.push(0x00);
if body.len() % 2 != 0 {
body.push(0x00); }
let block_start_in_body = body.len();
let mut block = Vec::new();
block.extend_from_slice(&[0u8, 0u8]);
block.extend_from_slice(&8u16.to_le_bytes()); block.extend_from_slice(&0xBEEF_0004u32.to_le_bytes()); block.extend_from_slice(&created.to_le_bytes()); block.extend_from_slice(&accessed.to_le_bytes()); let long_name_off_pos = block.len();
block.extend_from_slice(&[0u8, 0u8]);
block.extend_from_slice(&[0u8, 0u8]); block.extend_from_slice(&mft_entry.to_le_bytes()[..6]); block.extend_from_slice(&mft_seq.to_le_bytes()); let long_name_offset_in_block = block.len() as u16;
for u in long.encode_utf16() {
block.extend_from_slice(&u.to_le_bytes());
}
block.extend_from_slice(&[0u8, 0u8]); let first_ext_off = (2 + block_start_in_body) as u16;
block.extend_from_slice(&first_ext_off.to_le_bytes());
let block_size = block.len() as u16;
block[0..2].copy_from_slice(&block_size.to_le_bytes());
block[long_name_off_pos..long_name_off_pos + 2]
.copy_from_slice(&long_name_offset_in_block.to_le_bytes());
body.extend_from_slice(&block);
let cb = (2 + body.len()) as u16;
let mut item = cb.to_le_bytes().to_vec();
item.extend_from_slice(&body);
item
}
fn list_one(item: Vec<u8>) -> Vec<u8> {
let mut v = item;
v.extend_from_slice(&[0u8, 0u8]);
v
}
#[test]
fn decodes_short_name_size_and_modified() {
let modified = fat(2024, 3, 14, 9, 26, 30);
let item = file_entry_with_beef0004(
"SECRET~1.DOC",
"secret report.docx",
4096,
modified,
0,
0,
0,
0,
);
let items = parse_idlist(&list_one(item));
assert_eq!(items.len(), 1);
let it = &items[0];
assert_eq!(it.kind, ShellItemKind::FileEntry);
assert_eq!(it.name.as_deref(), Some("SECRET~1.DOC"));
assert_eq!(it.file_size, Some(4096));
assert_eq!(it.modified, Some(1_710_408_390));
}
#[test]
fn decodes_long_name_from_beef0004() {
let item =
file_entry_with_beef0004("SECRET~1.DOC", "secret report.docx", 4096, 0, 0, 0, 0, 0);
let items = parse_idlist(&list_one(item));
assert_eq!(items[0].long_name.as_deref(), Some("secret report.docx"));
assert_eq!(items[0].display_name(), Some("secret report.docx"));
}
#[test]
fn decodes_created_and_accessed_timestamps() {
let created = fat(2020, 1, 2, 3, 4, 10);
let accessed = fat(2025, 12, 31, 23, 58, 0);
let item = file_entry_with_beef0004("F~1.TXT", "file.txt", 10, 0, created, accessed, 0, 0);
let items = parse_idlist(&list_one(item));
assert_eq!(items[0].created, Some(1_577_934_250)); assert_eq!(items[0].accessed, Some(1_767_225_480)); }
#[test]
fn decodes_mft_entry_and_sequence() {
let item =
file_entry_with_beef0004("F~1.TXT", "file.txt", 10, 0, 0, 0, 0x1234_5678_9ABC, 0x0007);
let items = parse_idlist(&list_one(item));
assert_eq!(items[0].mft_entry, Some(0x1234_5678_9ABC));
assert_eq!(items[0].mft_sequence, Some(0x0007));
}
#[test]
fn beef0004_version3_has_no_mft_reference() {
let mut body = Vec::new();
body.push(0x32);
body.push(0x00);
body.extend_from_slice(&100u32.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes());
body.extend_from_slice(&0u16.to_le_bytes());
body.extend_from_slice(b"OLD~1.TXT\0"); let mut block = Vec::new();
block.extend_from_slice(&[0u8, 0u8]); block.extend_from_slice(&3u16.to_le_bytes()); block.extend_from_slice(&0xBEEF_0004u32.to_le_bytes());
block.extend_from_slice(&0u32.to_le_bytes()); block.extend_from_slice(&0u32.to_le_bytes()); let lno_pos = block.len();
block.extend_from_slice(&[0u8, 0u8]); let lno = block.len() as u16;
for u in "old.txt".encode_utf16() {
block.extend_from_slice(&u.to_le_bytes());
}
block.extend_from_slice(&[0u8, 0u8]);
block.extend_from_slice(&0u16.to_le_bytes()); let bs = block.len() as u16;
block[0..2].copy_from_slice(&bs.to_le_bytes());
block[lno_pos..lno_pos + 2].copy_from_slice(&lno.to_le_bytes());
body.extend_from_slice(&block);
let cb = (2 + body.len()) as u16;
let mut item = cb.to_le_bytes().to_vec();
item.extend_from_slice(&body);
let items = parse_idlist(&list_one(item));
assert_eq!(items[0].long_name.as_deref(), Some("old.txt"));
assert!(items[0].mft_entry.is_none());
assert!(items[0].mft_sequence.is_none());
}
#[test]
fn file_entry_without_extension_block_still_decodes_short_name() {
let mut body = Vec::new();
body.push(0x32);
body.push(0x00);
body.extend_from_slice(&55u32.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes());
body.extend_from_slice(&0u16.to_le_bytes());
body.extend_from_slice(b"BARE.TXT\0");
let cb = (2 + body.len()) as u16;
let mut item = cb.to_le_bytes().to_vec();
item.extend_from_slice(&body);
let items = parse_idlist(&list_one(item));
assert_eq!(items[0].name.as_deref(), Some("BARE.TXT"));
assert_eq!(items[0].file_size, Some(55));
assert!(items[0].long_name.is_none());
}
#[test]
fn unicode_class_0xb1_decodes_utf16_short_name() {
let mut body = Vec::new();
body.push(0xB1);
body.push(0x00);
body.extend_from_slice(&7u32.to_le_bytes()); body.extend_from_slice(&0u32.to_le_bytes()); body.extend_from_slice(&0u16.to_le_bytes()); for u in "café.txt".encode_utf16() {
body.extend_from_slice(&u.to_le_bytes());
}
body.extend_from_slice(&[0u8, 0u8]); let cb = (2 + body.len()) as u16;
let mut item = cb.to_le_bytes().to_vec();
item.extend_from_slice(&body);
let items = parse_idlist(&list_one(item));
assert_eq!(items[0].kind, ShellItemKind::FileEntry);
assert_eq!(items[0].name.as_deref(), Some("café.txt"));
}
#[test]
fn malformed_fat_modified_yields_none_not_panic() {
let mut body = Vec::new();
body.push(0x32);
body.push(0x00);
body.extend_from_slice(&0u32.to_le_bytes());
body.extend_from_slice(&0x0000_0001u32.to_le_bytes());
body.extend_from_slice(&0u16.to_le_bytes());
body.extend_from_slice(b"X\0");
let cb = (2 + body.len()) as u16;
let mut item = cb.to_le_bytes().to_vec();
item.extend_from_slice(&body);
let items = parse_idlist(&list_one(item));
assert!(items[0].modified.is_none());
}
#[test]
fn directory_class_0x31_is_file_entry() {
let mut body = Vec::new();
body.push(0x31); body.push(0x00);
body.extend_from_slice(&0u32.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes());
body.extend_from_slice(&0x10u16.to_le_bytes()); body.extend_from_slice(b"USERS\0");
let cb = (2 + body.len()) as u16;
let mut item = cb.to_le_bytes().to_vec();
item.extend_from_slice(&body);
let items = parse_idlist(&list_one(item));
assert_eq!(items[0].kind, ShellItemKind::FileEntry);
assert_eq!(items[0].name.as_deref(), Some("USERS"));
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod other_class_tests {
use super::*;
use crate::ShellItemKind;
fn framed(class: u8, body: &[u8]) -> Vec<u8> {
let cb = (3 + body.len()) as u16;
let mut v = cb.to_le_bytes().to_vec();
v.push(class);
v.extend_from_slice(body);
v.extend_from_slice(&[0u8, 0u8]);
v
}
#[test]
fn network_0xc3_decodes_unc_location() {
let mut body = Vec::new();
body.push(0x00); body.extend_from_slice(b"\\\\SERVER\\share\0"); let items = parse_idlist(&framed(0xC3, &body));
assert_eq!(items[0].kind, ShellItemKind::Network);
assert_eq!(items[0].name.as_deref(), Some("\\\\SERVER\\share"));
}
#[test]
fn uri_major_class_0x61_is_classified_uri() {
let items = parse_idlist(&framed(0x61, &[0u8; 8]));
assert_eq!(items[0].kind, ShellItemKind::Uri);
}
#[test]
fn control_panel_major_class_0x71_is_classified() {
let items = parse_idlist(&framed(0x71, &[0u8; 8]));
assert_eq!(items[0].kind, ShellItemKind::ControlPanel);
}
#[test]
fn unknown_class_surfaces_class_byte_and_raw() {
let body = [0xDEu8, 0xAD, 0xBE, 0xEF];
let items = parse_idlist(&framed(0x99, &body));
assert_eq!(items[0].kind, ShellItemKind::Unknown);
assert_eq!(items[0].class, 0x99);
assert_eq!(items[0].raw[2], 0x99);
assert_eq!(&items[0].raw[3..7], &body);
}
}