use std::io::{Cursor, Read};
use forensicnomicon::jumplist as jl;
use forensicnomicon::shlink;
use crate::{filetime_to_unix, guid_string, parse_shell_link, ShellLink};
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JumpListKind {
Automatic,
Custom,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JumpList {
pub kind: JumpListKind,
pub app_id: Option<String>,
pub entries: Vec<JumpListEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JumpListEntry {
pub destlist: Option<DestListEntry>,
pub link: ShellLink,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DestListEntry {
pub droid_volume_guid: String,
pub droid_file_guid: String,
pub birth_droid_volume_guid: String,
pub birth_droid_file_guid: String,
pub hostname: String,
pub entry_number: u32,
pub last_access: i64,
pub pinned: bool,
pub access_count: Option<u32>,
pub path: String,
}
fn le_u16(data: &[u8], off: usize) -> u16 {
let mut b = [0u8; 2];
if let Some(s) = data.get(off..off + 2) {
b.copy_from_slice(s);
}
u16::from_le_bytes(b)
}
fn le_u32(data: &[u8], off: usize) -> u32 {
let mut b = [0u8; 4];
if let Some(s) = data.get(off..off + 4) {
b.copy_from_slice(s);
}
u32::from_le_bytes(b)
}
fn le_i32(data: &[u8], off: usize) -> i32 {
le_u32(data, off) as i32
}
fn le_u64(data: &[u8], off: usize) -> u64 {
let mut b = [0u8; 8];
if let Some(s) = data.get(off..off + 8) {
b.copy_from_slice(s);
}
u64::from_le_bytes(b)
}
fn utf16le_lossy(data: &[u8], off: usize, count: usize) -> (String, usize) {
let byte_len = count.saturating_mul(2);
let end = off.saturating_add(byte_len);
let units: Vec<u16> = data
.get(off..end)
.unwrap_or_default()
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
(String::from_utf16_lossy(&units), end)
}
fn appid_from_filename(name: &str) -> Option<String> {
let stem = name
.rsplit('/')
.next()
.unwrap_or(name)
.rsplit('\\')
.next()
.unwrap_or(name);
let id = stem.split('.').next().unwrap_or(stem);
if !id.is_empty() && id.chars().all(|c| c.is_ascii_hexdigit()) {
Some(id.to_ascii_lowercase())
} else {
None
}
}
#[must_use]
pub fn parse_automatic_destinations(data: &[u8], filename: Option<&str>) -> Option<JumpList> {
let mut comp = cfb::CompoundFile::open(Cursor::new(data)).ok()?;
let destlist = {
let mut stream = comp.open_stream("DestList").ok()?;
let mut buf = Vec::new();
stream.read_to_end(&mut buf).ok()?;
buf
};
let format_version = le_u32(&destlist, jl::DESTLIST_HEADER_FORMAT_VERSION_OFFSET);
let extended = format_version >= 2;
let mut entries = Vec::new();
let mut off = jl::DESTLIST_HEADER_SIZE;
while off + jl::DESTLIST_ENTRY_PIN_STATUS_OFFSET + 4 <= destlist.len() {
let (destlist_entry, next) = parse_destlist_entry(&destlist, off, extended);
if next <= off {
break; }
off = next;
let stream_name = format!("{:x}", destlist_entry.entry_number);
let mut lnk = Vec::new();
if let Ok(mut stream) = comp.open_stream(&stream_name) {
let _ = stream.read_to_end(&mut lnk);
}
if let Some(link) = parse_shell_link(&lnk) {
entries.push(JumpListEntry {
destlist: Some(destlist_entry),
link,
});
}
}
Some(JumpList {
kind: JumpListKind::Automatic,
app_id: filename.and_then(appid_from_filename),
entries,
})
}
fn parse_destlist_entry(data: &[u8], base: usize, extended: bool) -> (DestListEntry, usize) {
let guid_at = |field_off: usize| -> String {
data.get(base + field_off..base + field_off + 16)
.and_then(guid_string)
.unwrap_or_default()
};
let droid_volume_guid = guid_at(jl::DESTLIST_ENTRY_DROID_VOLUME_GUID_OFFSET);
let droid_file_guid = guid_at(jl::DESTLIST_ENTRY_DROID_FILE_GUID_OFFSET);
let birth_droid_volume_guid = guid_at(jl::DESTLIST_ENTRY_BIRTH_DROID_VOLUME_GUID_OFFSET);
let birth_droid_file_guid = guid_at(jl::DESTLIST_ENTRY_BIRTH_DROID_FILE_GUID_OFFSET);
let hostname = {
let start = base + jl::DESTLIST_ENTRY_HOSTNAME_OFFSET;
let raw = data
.get(start..start + jl::DESTLIST_ENTRY_HOSTNAME_SIZE)
.unwrap_or_default();
let end = raw.iter().position(|&c| c == 0).unwrap_or(raw.len());
String::from_utf8_lossy(&raw[..end]).into_owned()
};
let entry_number = le_u32(data, base + jl::DESTLIST_ENTRY_ENTRY_NUMBER_OFFSET);
let last_access = filetime_to_unix(le_u64(
data,
base + jl::DESTLIST_ENTRY_LAST_ACCESS_FILETIME_OFFSET,
));
let pin_status = le_i32(data, base + jl::DESTLIST_ENTRY_PIN_STATUS_OFFSET);
let pinned = pin_status >= 0;
let (access_count, path_size_off, path_off, trailing) = if extended {
(
Some(le_u32(
data,
base + jl::DESTLIST_ENTRY_V2_ACCESS_COUNT_OFFSET,
)),
jl::DESTLIST_ENTRY_V2_PATH_SIZE_OFFSET,
jl::DESTLIST_ENTRY_V2_PATH_OFFSET,
jl::DESTLIST_ENTRY_V2_TRAILING_ALIGNMENT,
)
} else {
(
None,
jl::DESTLIST_ENTRY_V1_PATH_SIZE_OFFSET,
jl::DESTLIST_ENTRY_V1_PATH_OFFSET,
0,
)
};
let path_chars = le_u16(data, base + path_size_off) as usize;
let (path, after_path) = utf16le_lossy(data, base + path_off, path_chars);
let next = after_path.saturating_add(trailing);
(
DestListEntry {
droid_volume_guid,
droid_file_guid,
birth_droid_volume_guid,
birth_droid_file_guid,
hostname,
entry_number,
last_access,
pinned,
access_count,
path,
},
next,
)
}
#[must_use]
pub fn parse_custom_destinations(data: &[u8], filename: Option<&str>) -> Option<JumpList> {
if le_u32(data, 0) != jl::CUSTOM_DESTINATIONS_FORMAT_VERSION {
return None;
}
let clsid_bytes = clsid_wire_bytes();
let is_entry_prefix = |p: usize| -> bool {
data.get(p..p + 16) == Some(&clsid_bytes[..])
&& le_u32(data, p + 16) == shlink::HEADER_SIZE
&& data.get(p + 20..p + 36) == Some(&clsid_bytes[..])
};
let mut starts = Vec::new();
let mut i = 12; while i + 36 <= data.len() {
if is_entry_prefix(i) {
starts.push(i);
i += 16 + shlink::HEADER_SIZE as usize;
} else {
i += 1;
}
}
let mut entries = Vec::new();
for (idx, &prefix) in starts.iter().enumerate() {
let lnk_start = prefix + 16;
let hard_end = starts.get(idx + 1).copied().unwrap_or(data.len());
let end = footer_before(data, lnk_start, hard_end).unwrap_or(hard_end);
let slice = data.get(lnk_start..end).unwrap_or_default();
if let Some(link) = parse_shell_link(slice) {
entries.push(JumpListEntry {
destlist: None,
link,
});
}
}
Some(JumpList {
kind: JumpListKind::Custom,
app_id: filename.and_then(appid_from_filename),
entries,
})
}
fn footer_before(data: &[u8], start: usize, hard_end: usize) -> Option<usize> {
let sig = jl::CUSTOM_DESTINATIONS_FOOTER_SIGNATURE.to_le_bytes();
let region = data.get(start..hard_end)?;
region.windows(4).position(|w| w == sig).map(|p| start + p)
}
fn clsid_wire_bytes() -> [u8; 16] {
let hex: String = jl::LNK_CLSID.chars().filter(|c| *c != '-').collect();
let mut raw = [0u8; 16];
for (i, slot) in raw.iter_mut().enumerate() {
*slot = u8::from_str_radix(hex.get(i * 2..i * 2 + 2).unwrap_or("00"), 16).unwrap_or(0);
}
[
raw[3], raw[2], raw[1], raw[0], raw[5], raw[4], raw[7], raw[6], raw[8], raw[9], raw[10], raw[11], raw[12], raw[13], raw[14], raw[15], ]
}