use std::collections::HashMap;
use chrono::{DateTime, Utc};
use crate::error::Result;
use crate::rewind::{EntryKey, RewindEngine};
use crate::{
apply_fixup, parse_attributes, AttributeBody, FileName, Filetime, MftRecordHeader,
StandardInformation,
};
#[derive(Debug, Clone)]
pub struct MftEntry {
pub entry_number: u64,
pub sequence_number: u16,
pub filename: String,
pub parent_entry: u64,
pub parent_sequence: u16,
pub is_directory: bool,
pub is_in_use: bool,
pub si_created: Option<DateTime<Utc>>,
pub si_modified: Option<DateTime<Utc>>,
pub si_mft_modified: Option<DateTime<Utc>>,
pub si_accessed: Option<DateTime<Utc>>,
pub fn_created: Option<DateTime<Utc>>,
pub fn_modified: Option<DateTime<Utc>>,
pub fn_mft_modified: Option<DateTime<Utc>>,
pub fn_accessed: Option<DateTime<Utc>>,
pub full_path: String,
pub file_size: u64,
pub has_ads: bool,
}
pub struct MftData {
pub entries: Vec<MftEntry>,
pub by_entry: HashMap<u64, usize>,
pub by_key: HashMap<EntryKey, usize>,
}
impl MftData {
pub fn parse(data: &[u8]) -> Result<Self> {
const REC: usize = 1024;
let mut entries = Vec::new();
let mut by_entry = HashMap::new();
let mut by_key = HashMap::new();
for chunk in data.chunks(REC) {
if chunk.len() < REC || chunk.get(0..4) != Some(b"FILE") {
continue;
}
let mut buf = chunk.to_vec();
if apply_fixup(&mut buf, 512).is_err() {
continue;
}
let Ok(header) = MftRecordHeader::parse(&buf) else {
continue; };
let Ok(attrs) = parse_attributes(&buf, header.first_attribute_offset as usize) else {
continue;
};
let (mut si_created, mut si_modified, mut si_mft_modified, mut si_accessed) =
(None, None, None, None);
for a in attrs.iter().filter(|a| a.type_code == 0x10) {
if let Some(si) = a
.resident_content(&buf)
.and_then(|c| StandardInformation::parse(c).ok())
{
si_created = to_datetime(si.created);
si_modified = to_datetime(si.modified);
si_mft_modified = to_datetime(si.mft_modified);
si_accessed = to_datetime(si.accessed);
}
}
let mut best: Option<(u8, FileName)> = None;
for a in attrs.iter().filter(|a| a.type_code == 0x30) {
if let Some(fnm) = a
.resident_content(&buf)
.and_then(|c| FileName::parse(c).ok())
{
let priority = match fnm.namespace {
1 | 3 => 3,
2 => 1,
_ => 2,
};
if best.as_ref().is_none_or(|(p, _)| priority > *p) {
best = Some((priority, fnm));
}
} }
let Some((_, best_fn)) = best else {
continue;
};
let has_ads = attrs
.iter()
.any(|a| a.type_code == 0x80 && a.name.is_some());
let file_size = attrs
.iter()
.filter(|a| a.type_code == 0x80 && a.name.is_none())
.map(|a| match &a.body {
AttributeBody::NonResident { real_size, .. } => *real_size,
AttributeBody::Resident { content_length, .. } => u64::from(*content_length),
})
.next()
.unwrap_or(0);
let entry_number = u64::from(header.record_number);
let sequence_number = header.sequence_number;
let idx = entries.len();
entries.push(MftEntry {
entry_number,
sequence_number,
filename: best_fn.name.clone(),
parent_entry: best_fn.parent.record_number,
parent_sequence: best_fn.parent.sequence,
is_directory: header.is_directory(),
is_in_use: header.is_in_use(),
si_created,
si_modified,
si_mft_modified,
si_accessed,
fn_created: to_datetime(best_fn.created),
fn_modified: to_datetime(best_fn.modified),
fn_mft_modified: to_datetime(best_fn.mft_modified),
fn_accessed: to_datetime(best_fn.accessed),
full_path: String::new(),
file_size,
has_ads,
});
by_entry.insert(entry_number, idx);
by_key.insert(EntryKey::new(entry_number, sequence_number), idx);
}
let paths: Vec<String> = (0..entries.len())
.map(|i| resolve_full_path(&entries, &by_entry, i))
.collect();
for (entry, path) in entries.iter_mut().zip(paths) {
entry.full_path = path;
}
Ok(Self {
entries,
by_entry,
by_key,
})
}
#[must_use]
pub fn seed_rewind(&self) -> RewindEngine {
let mft_iter = self.entries.iter().map(|e| {
(
e.entry_number,
e.sequence_number,
e.filename.clone(),
e.parent_entry,
e.parent_sequence,
)
});
RewindEngine::from_mft(mft_iter)
}
#[must_use]
pub fn detect_timestomping(&self) -> Vec<&MftEntry> {
self.entries
.iter()
.filter(|e| {
if let (Some(si_c), Some(fn_c)) = (e.si_created, e.fn_created) {
si_c < fn_c || {
if let Some(si_m) = e.si_modified {
si_m < fn_c
} else {
false
}
}
} else {
false
}
})
.collect()
}
#[must_use]
pub fn get_by_entry(&self, entry_number: u64) -> Option<&MftEntry> {
self.by_entry
.get(&entry_number)
.and_then(|&idx| self.entries.get(idx))
}
#[must_use]
pub fn get_by_key(&self, key: &EntryKey) -> Option<&MftEntry> {
self.by_key.get(key).and_then(|&idx| self.entries.get(idx))
}
}
fn to_datetime(ft: Filetime) -> Option<DateTime<Utc>> {
if ft.is_zero() {
return None;
}
let secs = ft.to_unix_seconds();
let sub_nanos = ft.to_unix_nanos().rem_euclid(1_000_000_000);
let nsec = u32::try_from(sub_nanos).unwrap_or(0);
DateTime::from_timestamp(secs, nsec)
}
fn resolve_full_path(entries: &[MftEntry], by_entry: &HashMap<u64, usize>, idx: usize) -> String {
let mut parts = Vec::new();
let mut cur = idx;
for _ in 0..256 {
let Some(e) = entries.get(cur) else {
break; };
parts.push(e.filename.clone());
if e.parent_entry == 5 || e.parent_entry == e.entry_number {
break;
}
match by_entry.get(&e.parent_entry) {
Some(&p) if p != cur => cur = p,
_ => break,
}
}
parts.reverse();
format!(".\\{}", parts.join("\\"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mft_data_empty() {
let result = MftData::parse(&[]);
assert!(result.is_err() || result.unwrap().entries.is_empty());
}
#[test]
fn test_entry_key_equality() {
let k1 = EntryKey::new(100, 3);
let k2 = EntryKey::new(100, 3);
let k3 = EntryKey::new(100, 4);
assert_eq!(k1, k2);
assert_ne!(k1, k3);
}
#[test]
fn test_mft_data_get_by_entry_not_found() {
let mft_data = MftData {
entries: Vec::new(),
by_entry: HashMap::new(),
by_key: HashMap::new(),
};
assert!(mft_data.get_by_entry(100).is_none());
}
#[test]
fn test_mft_data_get_by_key_not_found() {
let mft_data = MftData {
entries: Vec::new(),
by_entry: HashMap::new(),
by_key: HashMap::new(),
};
let key = EntryKey::new(100, 3);
assert!(mft_data.get_by_key(&key).is_none());
}
fn make_mft_entry(
entry_number: u64,
sequence_number: u16,
filename: &str,
parent_entry: u64,
parent_sequence: u16,
is_dir: bool,
is_in_use: bool,
) -> MftEntry {
MftEntry {
entry_number,
sequence_number,
filename: filename.to_string(),
parent_entry,
parent_sequence,
is_directory: is_dir,
is_in_use,
si_created: None,
si_modified: None,
si_mft_modified: None,
si_accessed: None,
fn_created: None,
fn_modified: None,
fn_mft_modified: None,
fn_accessed: None,
full_path: format!(".\\{filename}"),
file_size: 0,
has_ads: false,
}
}
#[test]
fn test_mft_data_get_by_entry_found() {
let entry = make_mft_entry(100, 3, "test.txt", 5, 5, false, true);
let mut by_entry = HashMap::new();
by_entry.insert(100u64, 0usize);
let mut by_key = HashMap::new();
by_key.insert(EntryKey::new(100, 3), 0usize);
let mft_data = MftData {
entries: vec![entry],
by_entry,
by_key,
};
let found = mft_data.get_by_entry(100);
assert!(found.is_some());
assert_eq!(found.unwrap().filename, "test.txt");
}
#[test]
fn parse_extracts_entry_fields_via_ntfs_forensic() {
let data = build_mft_entry_bytes(100, 1, 5, 5, "testfile.txt", 0x01);
let mft = MftData::parse(&data).expect("parse");
assert_eq!(mft.entries.len(), 1);
let e = &mft.entries[0];
assert_eq!(e.entry_number, 100);
assert_eq!(e.sequence_number, 1);
assert_eq!(e.filename, "testfile.txt");
assert_eq!(e.parent_entry, 5);
assert_eq!(e.parent_sequence, 5);
assert!(!e.is_directory);
assert!(e.is_in_use);
assert!(e.si_created.is_some());
assert!(e.fn_created.is_some());
assert!(!e.has_ads);
assert!(mft.by_entry.contains_key(&100));
assert!(mft.by_key.contains_key(&EntryKey::new(100, 1)));
}
#[test]
fn test_mft_data_get_by_key_found() {
let entry = make_mft_entry(100, 3, "test.txt", 5, 5, false, true);
let mut by_entry = HashMap::new();
by_entry.insert(100u64, 0usize);
let mut by_key = HashMap::new();
by_key.insert(EntryKey::new(100, 3), 0usize);
let mft_data = MftData {
entries: vec![entry],
by_entry,
by_key,
};
let key = EntryKey::new(100, 3);
let found = mft_data.get_by_key(&key);
assert!(found.is_some());
assert_eq!(found.unwrap().filename, "test.txt");
}
#[test]
fn test_detect_timestomping_si_before_fn() {
use chrono::DateTime;
let mut entry = make_mft_entry(100, 1, "suspicious.exe", 5, 5, false, true);
entry.si_created = Some(DateTime::from_timestamp(1_700_000_000, 0).unwrap());
entry.fn_created = Some(DateTime::from_timestamp(1_700_001_000, 0).unwrap());
let mut by_entry = HashMap::new();
by_entry.insert(100u64, 0usize);
let mft_data = MftData {
entries: vec![entry],
by_entry,
by_key: HashMap::new(),
};
let stomped = mft_data.detect_timestomping();
assert_eq!(stomped.len(), 1);
assert_eq!(stomped[0].filename, "suspicious.exe");
}
#[test]
fn test_detect_timestomping_si_modified_before_fn_created() {
use chrono::DateTime;
let mut entry = make_mft_entry(100, 1, "modified.exe", 5, 5, false, true);
entry.si_created = Some(DateTime::from_timestamp(1_700_001_000, 0).unwrap());
entry.si_modified = Some(DateTime::from_timestamp(1_700_000_000, 0).unwrap());
entry.fn_created = Some(DateTime::from_timestamp(1_700_001_000, 0).unwrap());
let mft_data = MftData {
entries: vec![entry],
by_entry: HashMap::new(),
by_key: HashMap::new(),
};
let stomped = mft_data.detect_timestomping();
assert_eq!(stomped.len(), 1);
}
#[test]
fn test_detect_timestomping_none_when_consistent() {
use chrono::DateTime;
let mut entry = make_mft_entry(100, 1, "normal.txt", 5, 5, false, true);
let ts = DateTime::from_timestamp(1_700_001_000, 0).unwrap();
entry.si_created = Some(ts);
entry.si_modified = Some(ts);
entry.fn_created = Some(ts);
let mft_data = MftData {
entries: vec![entry],
by_entry: HashMap::new(),
by_key: HashMap::new(),
};
let stomped = mft_data.detect_timestomping();
assert_eq!(stomped.len(), 0);
}
#[test]
fn test_detect_timestomping_no_timestamps() {
let entry = make_mft_entry(100, 1, "no_ts.txt", 5, 5, false, true);
let mft_data = MftData {
entries: vec![entry],
by_entry: HashMap::new(),
by_key: HashMap::new(),
};
let stomped = mft_data.detect_timestomping();
assert_eq!(stomped.len(), 0);
}
#[test]
fn test_seed_rewind() {
let entry = make_mft_entry(100, 1, "test.txt", 5, 5, false, true);
let mut by_entry = HashMap::new();
by_entry.insert(100u64, 0usize);
let mut by_key = HashMap::new();
by_key.insert(EntryKey::new(100, 1), 0usize);
let mft_data = MftData {
entries: vec![entry],
by_entry,
by_key,
};
let engine = mft_data.seed_rewind();
assert_eq!(engine.lookup_len(), 1);
let path = engine.resolve_path(&EntryKey::new(100, 1));
assert_eq!(path, ".\\test.txt");
}
#[test]
fn test_mft_data_multiple_entries() {
let e1 = make_mft_entry(100, 1, "file1.txt", 5, 5, false, true);
let e2 = make_mft_entry(200, 2, "file2.txt", 100, 1, false, true);
let e3 = make_mft_entry(300, 1, "dir1", 5, 5, true, true);
let mut by_entry = HashMap::new();
by_entry.insert(100u64, 0usize);
by_entry.insert(200u64, 1usize);
by_entry.insert(300u64, 2usize);
let mut by_key = HashMap::new();
by_key.insert(EntryKey::new(100, 1), 0usize);
by_key.insert(EntryKey::new(200, 2), 1usize);
by_key.insert(EntryKey::new(300, 1), 2usize);
let mft_data = MftData {
entries: vec![e1, e2, e3],
by_entry,
by_key,
};
assert_eq!(mft_data.entries.len(), 3);
assert_eq!(mft_data.get_by_entry(200).unwrap().filename, "file2.txt");
assert_eq!(
mft_data
.get_by_key(&EntryKey::new(300, 1))
.unwrap()
.filename,
"dir1"
);
assert!(
mft_data
.get_by_key(&EntryKey::new(300, 1))
.unwrap()
.is_directory
);
}
#[test]
fn test_detect_timestomping_si_modified_none() {
use chrono::DateTime;
let mut entry = make_mft_entry(100, 1, "check.exe", 5, 5, false, true);
let ts = DateTime::from_timestamp(1_700_001_000, 0).unwrap();
entry.si_created = Some(ts);
entry.si_modified = None;
entry.fn_created = Some(ts);
let mft_data = MftData {
entries: vec![entry],
by_entry: HashMap::new(),
by_key: HashMap::new(),
};
let stomped = mft_data.detect_timestomping();
assert_eq!(stomped.len(), 0);
}
#[test]
fn test_mft_entry_has_ads_field() {
let mut entry = make_mft_entry(100, 1, "ads.txt", 5, 5, false, true);
entry.has_ads = true;
assert!(entry.has_ads);
}
#[test]
fn test_mft_entry_file_size() {
let mut entry = make_mft_entry(100, 1, "big.bin", 5, 5, false, true);
entry.file_size = 1_048_576;
assert_eq!(entry.file_size, 1_048_576);
}
#[test]
fn test_mft_data_parse_invalid_data() {
let garbage = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04];
let result = MftData::parse(&garbage);
if let Ok(mft_data) = result {
assert!(mft_data.entries.is_empty());
} }
#[test]
fn test_mft_data_parse_short_data() {
let data = vec![0xAA; 512];
let result = MftData::parse(&data);
if let Ok(mft_data) = result {
assert!(mft_data.entries.is_empty());
} }
#[test]
fn test_mft_data_parse_corrupt_entries_skipped() {
let mut data = vec![0u8; 1024 * 4];
for i in 0..4 {
let o = i * 1024;
data[o..o + 4].copy_from_slice(b"FILE");
data[o + 0x04..o + 0x06].copy_from_slice(&0x30u16.to_le_bytes()); data[o + 0x06..o + 0x08].copy_from_slice(&3u16.to_le_bytes()); data[o + 0x14..o + 0x16].copy_from_slice(&0x38u16.to_le_bytes()); data[o + 0x16..o + 0x18].copy_from_slice(&0x01u16.to_le_bytes()); data[o + 0x18..o + 0x1C].copy_from_slice(&0x38u32.to_le_bytes()); data[o + 0x1C..o + 0x20].copy_from_slice(&1024u32.to_le_bytes()); data[o + 0x38..o + 0x3C].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
}
let mft_data = MftData::parse(&data).unwrap();
assert!(mft_data.entries.is_empty());
}
#[test]
fn test_mft_entry_ads_detection_field() {
let mut entry = make_mft_entry(100, 1, "file_with_ads.txt", 5, 5, false, true);
assert!(!entry.has_ads);
entry.has_ads = true;
assert!(entry.has_ads);
let mft_data = MftData {
entries: vec![entry],
by_entry: HashMap::new(),
by_key: HashMap::new(),
};
assert_eq!(mft_data.detect_timestomping().len(), 0);
}
#[test]
fn test_mft_data_seed_rewind_multiple() {
let e1 = make_mft_entry(10, 1, "Users", 5, 5, true, true);
let e2 = make_mft_entry(20, 1, "admin", 10, 1, true, true);
let e3 = make_mft_entry(30, 1, "Desktop", 20, 1, true, true);
let mut by_entry = HashMap::new();
by_entry.insert(10u64, 0usize);
by_entry.insert(20u64, 1usize);
by_entry.insert(30u64, 2usize);
let mut by_key = HashMap::new();
by_key.insert(EntryKey::new(10, 1), 0usize);
by_key.insert(EntryKey::new(20, 1), 1usize);
by_key.insert(EntryKey::new(30, 1), 2usize);
let mft_data = MftData {
entries: vec![e1, e2, e3],
by_entry,
by_key,
};
let engine = mft_data.seed_rewind();
assert_eq!(engine.lookup_len(), 3);
let path = engine.resolve_path(&EntryKey::new(30, 1));
assert_eq!(path, ".\\Users\\admin\\Desktop");
}
#[test]
fn test_mft_data_full_path_field() {
let entry = make_mft_entry(100, 1, "test.txt", 5, 5, false, true);
assert_eq!(entry.full_path, ".\\test.txt");
}
fn build_mft_entry_bytes(
entry_number: u32,
sequence: u16,
parent_entry: u64,
parent_seq: u16,
filename: &str,
flags: u16, ) -> Vec<u8> {
let name_utf16: Vec<u16> = filename.encode_utf16().collect();
let fn_name_len = name_utf16.len();
let si_data_size: u32 = 72;
let si_attr_header_size: u16 = 24;
let si_total_size: u32 = u32::from(si_attr_header_size) + si_data_size;
let si_total_aligned = (si_total_size + 7) & !7;
let fn_data_size: u32 = 66 + (fn_name_len as u32 * 2);
let fn_attr_header_size: u16 = 24;
let fn_total_size: u32 = u32::from(fn_attr_header_size) + fn_data_size;
let fn_total_aligned = (fn_total_size + 7) & !7;
let first_attr_offset: u16 = 0x38; let bytes_used: u32 =
u32::from(first_attr_offset) + si_total_aligned + fn_total_aligned + 8; let alloc_size: u32 = 1024;
let mut buf = vec![0u8; alloc_size as usize];
buf[0..4].copy_from_slice(b"FILE"); buf[0x04..0x06].copy_from_slice(&0x30u16.to_le_bytes()); buf[0x06..0x08].copy_from_slice(&3u16.to_le_bytes()); buf[0x08..0x10].copy_from_slice(&0u64.to_le_bytes()); buf[0x10..0x12].copy_from_slice(&sequence.to_le_bytes()); buf[0x12..0x14].copy_from_slice(&0u16.to_le_bytes()); buf[0x14..0x16].copy_from_slice(&first_attr_offset.to_le_bytes()); buf[0x16..0x18].copy_from_slice(&flags.to_le_bytes()); buf[0x18..0x1C].copy_from_slice(&bytes_used.to_le_bytes()); buf[0x1C..0x20].copy_from_slice(&alloc_size.to_le_bytes()); buf[0x20..0x28].copy_from_slice(&0u64.to_le_bytes()); buf[0x28..0x2C].copy_from_slice(&0u32.to_le_bytes()); buf[0x2C..0x30].copy_from_slice(&entry_number.to_le_bytes()); buf[0x30..0x32].copy_from_slice(&0x0001u16.to_le_bytes()); buf[0x32..0x34].copy_from_slice(&0x0000u16.to_le_bytes()); buf[0x34..0x36].copy_from_slice(&0x0000u16.to_le_bytes());
buf[0x1FE..0x200].copy_from_slice(&0x0001u16.to_le_bytes());
buf[0x3FE..0x400].copy_from_slice(&0x0001u16.to_le_bytes());
let mut off = first_attr_offset as usize;
buf[off..off + 4].copy_from_slice(&0x10u32.to_le_bytes()); buf[off + 4..off + 8].copy_from_slice(&si_total_aligned.to_le_bytes()); buf[off + 8] = 0; buf[off + 9] = 0; buf[off + 10..off + 12].copy_from_slice(&0u16.to_le_bytes()); buf[off + 12..off + 14].copy_from_slice(&0u16.to_le_bytes()); buf[off + 14..off + 16].copy_from_slice(&0u16.to_le_bytes()); buf[off + 16..off + 20].copy_from_slice(&si_data_size.to_le_bytes()); buf[off + 20..off + 22].copy_from_slice(&si_attr_header_size.to_le_bytes()); buf[off + 22..off + 24].copy_from_slice(&0u16.to_le_bytes());
let ts: i64 = 133_500_480_000_000_000; let si_data_off = off + si_attr_header_size as usize;
buf[si_data_off..si_data_off + 8].copy_from_slice(&ts.to_le_bytes()); buf[si_data_off + 8..si_data_off + 16].copy_from_slice(&ts.to_le_bytes()); buf[si_data_off + 16..si_data_off + 24].copy_from_slice(&ts.to_le_bytes()); buf[si_data_off + 24..si_data_off + 32].copy_from_slice(&ts.to_le_bytes());
off += si_total_aligned as usize;
buf[off..off + 4].copy_from_slice(&0x30u32.to_le_bytes()); buf[off + 4..off + 8].copy_from_slice(&fn_total_aligned.to_le_bytes()); buf[off + 8] = 0; buf[off + 9] = 0; buf[off + 10..off + 12].copy_from_slice(&0u16.to_le_bytes()); buf[off + 12..off + 14].copy_from_slice(&0u16.to_le_bytes()); buf[off + 14..off + 16].copy_from_slice(&1u16.to_le_bytes()); buf[off + 16..off + 20].copy_from_slice(&fn_data_size.to_le_bytes()); buf[off + 20..off + 22].copy_from_slice(&fn_attr_header_size.to_le_bytes()); buf[off + 22..off + 24].copy_from_slice(&0u16.to_le_bytes());
let fn_data_off = off + fn_attr_header_size as usize;
let parent_ref = parent_entry | (u64::from(parent_seq) << 48);
buf[fn_data_off..fn_data_off + 8].copy_from_slice(&parent_ref.to_le_bytes());
buf[fn_data_off + 8..fn_data_off + 16].copy_from_slice(&ts.to_le_bytes()); buf[fn_data_off + 16..fn_data_off + 24].copy_from_slice(&ts.to_le_bytes()); buf[fn_data_off + 24..fn_data_off + 32].copy_from_slice(&ts.to_le_bytes()); buf[fn_data_off + 32..fn_data_off + 40].copy_from_slice(&ts.to_le_bytes()); buf[fn_data_off + 40..fn_data_off + 48].copy_from_slice(&0u64.to_le_bytes());
buf[fn_data_off + 48..fn_data_off + 56].copy_from_slice(&0u64.to_le_bytes());
buf[fn_data_off + 56..fn_data_off + 60].copy_from_slice(&0u32.to_le_bytes());
buf[fn_data_off + 60..fn_data_off + 64].copy_from_slice(&0u32.to_le_bytes());
buf[fn_data_off + 64] = fn_name_len as u8;
buf[fn_data_off + 65] = 0x03;
for (i, &ch) in name_utf16.iter().enumerate() {
let name_off = fn_data_off + 66 + i * 2;
buf[name_off..name_off + 2].copy_from_slice(&ch.to_le_bytes());
}
off += fn_total_aligned as usize;
buf[off..off + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
buf
}
#[test]
fn test_mft_data_parse_valid_entry() {
let entry_data = build_mft_entry_bytes(
100, 1, 5, 5, "testfile.txt",
0x01, );
let mft_data = MftData::parse(&entry_data).unwrap();
if !mft_data.entries.is_empty() {
let e = &mft_data.entries[0];
assert_eq!(e.filename, "testfile.txt");
assert_eq!(e.parent_entry, 5);
assert!(e.si_created.is_some());
assert!(e.fn_created.is_some());
assert!(!e.has_ads);
let entry_num = e.entry_number;
assert!(mft_data.by_entry.contains_key(&entry_num));
assert!(mft_data
.by_key
.contains_key(&EntryKey::new(entry_num, e.sequence_number)));
} }
#[test]
fn test_mft_data_parse_entry_with_ads() {
let mut entry_data = build_mft_entry_bytes(200, 1, 5, 5, "ads_file.txt", 0x01);
let first_attr_offset = 0x38usize;
let mut off = first_attr_offset;
loop {
if off + 4 > entry_data.len() {
break; }
let attr_type = u32::from_le_bytes([
entry_data[off],
entry_data[off + 1],
entry_data[off + 2],
entry_data[off + 3],
]);
if attr_type == 0xFFFF_FFFF {
break;
}
let attr_size = u32::from_le_bytes([
entry_data[off + 4],
entry_data[off + 5],
entry_data[off + 6],
entry_data[off + 7],
]) as usize;
if attr_size == 0 || off + attr_size > entry_data.len() {
break; }
off += attr_size;
}
let ads_name = "Zone.Identifier";
let ads_name_utf16: Vec<u16> = ads_name.encode_utf16().collect();
let ads_name_bytes = ads_name_utf16.len() * 2;
let ads_attr_header_size = 24u16;
let ads_content_size = 0u32; let ads_name_offset = ads_attr_header_size;
let ads_total =
(u32::from(ads_attr_header_size) + ads_name_bytes as u32 + ads_content_size + 7) & !7;
if off + ads_total as usize + 8 <= entry_data.len() {
entry_data[off..off + 4].copy_from_slice(&0x80u32.to_le_bytes()); entry_data[off + 4..off + 8].copy_from_slice(&ads_total.to_le_bytes());
entry_data[off + 8] = 0; entry_data[off + 9] = ads_name_utf16.len() as u8; entry_data[off + 10..off + 12].copy_from_slice(&ads_name_offset.to_le_bytes());
entry_data[off + 12..off + 14].copy_from_slice(&0u16.to_le_bytes());
entry_data[off + 14..off + 16].copy_from_slice(&2u16.to_le_bytes()); entry_data[off + 16..off + 20].copy_from_slice(&ads_content_size.to_le_bytes());
let content_off = ads_name_offset + ads_name_bytes as u16;
entry_data[off + 20..off + 22].copy_from_slice(&content_off.to_le_bytes());
let name_start = off + ads_name_offset as usize;
for (i, &ch) in ads_name_utf16.iter().enumerate() {
let pos = name_start + i * 2;
if pos + 2 <= entry_data.len() {
entry_data[pos..pos + 2].copy_from_slice(&ch.to_le_bytes());
}
}
let end_off = off + ads_total as usize;
if end_off + 4 <= entry_data.len() {
entry_data[end_off..end_off + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
}
let new_bytes_used = (end_off + 8) as u32;
entry_data[0x18..0x1C].copy_from_slice(&new_bytes_used.to_le_bytes());
}
let mft_data = MftData::parse(&entry_data).unwrap();
if !mft_data.entries.is_empty() {
let e = &mft_data.entries[0];
assert_eq!(e.filename, "ads_file.txt");
} }
#[test]
fn test_mft_data_parse_multiple_entries() {
let entry0 = build_mft_entry_bytes(0, 1, 5, 5, "root", 0x03); let entry1 = build_mft_entry_bytes(1, 1, 0, 1, "file1.txt", 0x01);
let entry2 = build_mft_entry_bytes(2, 1, 0, 1, "file2.doc", 0x01);
let mut data = Vec::new();
data.extend_from_slice(&entry0);
data.extend_from_slice(&entry1);
data.extend_from_slice(&entry2);
let mft_data = MftData::parse(&data).unwrap();
assert!(mft_data.entries.len() <= 3);
}
#[test]
fn test_mft_data_parse_entry_without_filename_skipped() {
let mut buf = vec![0u8; 1024];
buf[0..4].copy_from_slice(b"FILE");
buf[0x04..0x06].copy_from_slice(&0x30u16.to_le_bytes());
buf[0x06..0x08].copy_from_slice(&3u16.to_le_bytes());
buf[0x10..0x12].copy_from_slice(&1u16.to_le_bytes()); buf[0x14..0x16].copy_from_slice(&0x38u16.to_le_bytes()); buf[0x16..0x18].copy_from_slice(&0x01u16.to_le_bytes()); let si_size = 96u32;
let si_aligned = (si_size + 7) & !7;
buf[0x18..0x1C].copy_from_slice(&(0x38u32 + si_aligned + 8).to_le_bytes()); buf[0x1C..0x20].copy_from_slice(&1024u32.to_le_bytes()); buf[0x28..0x2C].copy_from_slice(&50u32.to_le_bytes());
buf[0x30..0x32].copy_from_slice(&0x0001u16.to_le_bytes());
buf[0x1FE..0x200].copy_from_slice(&0x0001u16.to_le_bytes());
buf[0x3FE..0x400].copy_from_slice(&0x0001u16.to_le_bytes());
let off = 0x38;
buf[off..off + 4].copy_from_slice(&0x10u32.to_le_bytes());
buf[off + 4..off + 8].copy_from_slice(&si_aligned.to_le_bytes());
buf[off + 8] = 0;
buf[off + 16..off + 20].copy_from_slice(&72u32.to_le_bytes());
buf[off + 20..off + 22].copy_from_slice(&24u16.to_le_bytes());
let end_off = off + si_aligned as usize;
buf[end_off..end_off + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
let mft_data = MftData::parse(&buf).unwrap();
assert!(mft_data.entries.is_empty());
}
#[test]
fn test_mft_data_is_directory_and_in_use() {
let dir_entry = make_mft_entry(100, 1, "Documents", 5, 5, true, true);
assert!(dir_entry.is_directory);
assert!(dir_entry.is_in_use);
let deleted_entry = make_mft_entry(200, 1, "deleted.txt", 5, 5, false, false);
assert!(!deleted_entry.is_directory);
assert!(!deleted_entry.is_in_use);
}
fn build_raw_mft_entry_buf(seq: u16, flags: u16) -> Vec<u8> {
let mut buf = vec![0u8; 1024];
buf[0..4].copy_from_slice(b"FILE");
buf[0x04..0x06].copy_from_slice(&0x30u16.to_le_bytes());
buf[0x06..0x08].copy_from_slice(&3u16.to_le_bytes());
buf[0x08..0x10].copy_from_slice(&0u64.to_le_bytes());
buf[0x10..0x12].copy_from_slice(&seq.to_le_bytes());
buf[0x12..0x14].copy_from_slice(&1u16.to_le_bytes());
buf[0x14..0x16].copy_from_slice(&0x38u16.to_le_bytes());
buf[0x16..0x18].copy_from_slice(&flags.to_le_bytes());
buf[0x18..0x1C].copy_from_slice(&512u32.to_le_bytes());
buf[0x1C..0x20].copy_from_slice(&1024u32.to_le_bytes());
buf[0x28..0x2A].copy_from_slice(&0u16.to_le_bytes());
let marker: u16 = 0x0001;
buf[0x30..0x32].copy_from_slice(&marker.to_le_bytes());
buf[0x32..0x34].copy_from_slice(&marker.to_le_bytes());
buf[0x34..0x36].copy_from_slice(&marker.to_le_bytes());
buf[510..512].copy_from_slice(&marker.to_le_bytes());
buf[1022..1024].copy_from_slice(&marker.to_le_bytes());
buf[0x38..0x3C].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
buf
}
#[test]
fn test_mft_parse_with_corrupt_entry() {
let entry0 = build_raw_mft_entry_buf(1, 0x01);
let mut entry1 = vec![0u8; 1024];
entry1[0..4].copy_from_slice(b"DEAD"); entry1[0x1C..0x20].copy_from_slice(&1024u32.to_le_bytes());
let mut data = Vec::new();
data.extend_from_slice(&entry0);
data.extend_from_slice(&entry1);
let mft_data = MftData::parse(&data).unwrap();
let _ = mft_data.entries.len();
}
#[test]
fn parse_skips_entry_with_fixup_mismatch() {
let mut e = build_mft_entry_bytes(202, 1, 5, 5, "x.txt", 0x01);
e[0x1FE] = 0xFF;
e[0x1FF] = 0xFF;
let m = MftData::parse(&e).unwrap();
assert!(m.entries.is_empty());
}
#[test]
fn parse_covers_filename_namespace_priorities() {
const NS_OFF: usize = 0xF1;
let mut dos = build_mft_entry_bytes(210, 1, 5, 5, "DOS~1.TXT", 0x01);
dos[NS_OFF] = 0x02;
let m = MftData::parse(&dos).unwrap();
assert_eq!(m.entries.len(), 1);
assert_eq!(m.entries[0].filename, "DOS~1.TXT");
let mut posix = build_mft_entry_bytes(211, 1, 5, 5, "posix.txt", 0x01);
posix[NS_OFF] = 0x00;
let m2 = MftData::parse(&posix).unwrap();
assert_eq!(m2.entries[0].filename, "posix.txt");
}
#[test]
fn parse_full_path_stops_on_missing_parent() {
let e = build_mft_entry_bytes(300, 1, 999, 1, "orphan.txt", 0x01);
let m = MftData::parse(&e).unwrap();
assert_eq!(m.entries.len(), 1);
assert!(m.entries[0].full_path.contains("orphan.txt"));
}
fn entry_with_unnamed_data(
entry_num: u32,
name: &str,
non_resident: bool,
size: u64,
) -> Vec<u8> {
let mut buf = build_mft_entry_bytes(entry_num, 1, 5, 5, name, 0x01);
let mut off = 0x38usize;
loop {
let t = u32::from_le_bytes(buf[off..off + 4].try_into().unwrap());
if t == 0xFFFF_FFFF {
break;
}
let sz = u32::from_le_bytes(buf[off + 4..off + 8].try_into().unwrap()) as usize;
off += sz;
}
let total: u32 = if non_resident {
let total = ((0x40 + 8 + 7) & !7) as u32;
buf[off..off + 4].copy_from_slice(&0x80u32.to_le_bytes());
buf[off + 4..off + 8].copy_from_slice(&total.to_le_bytes());
buf[off + 8] = 1; buf[off + 9] = 0; buf[off + 14..off + 16].copy_from_slice(&3u16.to_le_bytes()); buf[off + 0x20..off + 0x22].copy_from_slice(&0x40u16.to_le_bytes()); buf[off + 0x28..off + 0x30].copy_from_slice(&4096u64.to_le_bytes()); buf[off + 0x30..off + 0x38].copy_from_slice(&size.to_le_bytes()); buf[off + 0x38..off + 0x40].copy_from_slice(&size.to_le_bytes()); total
} else {
let content = size as usize;
let total = ((24 + content + 7) & !7) as u32;
buf[off..off + 4].copy_from_slice(&0x80u32.to_le_bytes());
buf[off + 4..off + 8].copy_from_slice(&total.to_le_bytes());
buf[off + 8] = 0; buf[off + 9] = 0; buf[off + 14..off + 16].copy_from_slice(&3u16.to_le_bytes());
buf[off + 16..off + 20].copy_from_slice(&(content as u32).to_le_bytes()); buf[off + 20..off + 22].copy_from_slice(&24u16.to_le_bytes()); total
};
let end = off + total as usize;
buf[end..end + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
buf[0x18..0x1C].copy_from_slice(&((end + 8) as u32).to_le_bytes()); buf
}
#[test]
fn parse_extracts_file_size_from_unnamed_data() {
let res = entry_with_unnamed_data(400, "res.txt", false, 42);
let m = MftData::parse(&res).unwrap();
assert_eq!(m.entries.len(), 1);
assert_eq!(m.entries[0].file_size, 42);
let nr = entry_with_unnamed_data(401, "nr.txt", true, 123_456);
let m2 = MftData::parse(&nr).unwrap();
assert_eq!(m2.entries.len(), 1);
assert_eq!(m2.entries[0].file_size, 123_456);
}
#[test]
fn parse_handles_out_of_bounds_attribute_offset_without_panic() {
let mut e = build_mft_entry_bytes(310, 1, 5, 5, "x.txt", 0x01);
e[0x14..0x16].copy_from_slice(&0x0410u16.to_le_bytes());
let m = MftData::parse(&e).unwrap();
assert!(m.entries.is_empty());
}
#[test]
fn parse_keeps_higher_priority_filename_over_later_lower_priority() {
let base = build_mft_entry_bytes(330, 1, 5, 5, "WIN32.TXT", 0x01);
let mut e = base.clone();
let mut off = 0x38usize;
let mut fn_off = None;
let mut fn_len = 0usize;
loop {
let t = u32::from_le_bytes(e[off..off + 4].try_into().unwrap());
if t == 0xFFFF_FFFF {
break;
}
let sz = u32::from_le_bytes(e[off + 4..off + 8].try_into().unwrap()) as usize;
if t == 0x30 {
fn_off = Some(off);
fn_len = sz;
}
off += sz;
}
let fn_off = fn_off.unwrap();
let end_marker = off;
let mut second: Vec<u8> = e[fn_off..fn_off + fn_len].to_vec();
second[24 + 65] = 0x02; e[end_marker..end_marker + fn_len].copy_from_slice(&second);
let new_end = end_marker + fn_len;
e[new_end..new_end + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
e[0x18..0x1C].copy_from_slice(&((new_end + 8) as u32).to_le_bytes());
let m = MftData::parse(&e).unwrap();
assert_eq!(m.entries.len(), 1);
assert_eq!(m.entries[0].filename, "WIN32.TXT");
}
#[test]
fn parse_skips_entry_when_parse_attributes_errors() {
let mut e = build_mft_entry_bytes(320, 1, 5, 5, "x.txt", 0x01);
e[0x3C..0x40].copy_from_slice(&0x0000_0001u32.to_le_bytes());
let m = MftData::parse(&e).unwrap();
assert!(m.entries.is_empty());
}
}