use std::collections::HashMap;
use std::fmt;
use crate::usn::UsnRecord;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct RefsFileId(pub u128);
impl RefsFileId {
pub fn from_u128(value: u128) -> Self {
Self(value)
}
pub fn high(&self) -> u64 {
(self.0 >> 64) as u64
}
pub fn low(&self) -> u64 {
self.0 as u64
}
}
impl fmt::Display for RefsFileId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "0x{:016x}:0x{:016x}", self.high(), self.low())
}
}
#[derive(Debug, Clone)]
pub struct RefsRecord {
pub record: UsnRecord,
pub file_id: RefsFileId,
pub parent_id: RefsFileId,
}
impl RefsRecord {
pub fn new(record: UsnRecord, file_id: RefsFileId, parent_id: RefsFileId) -> Self {
Self {
record,
file_id,
parent_id,
}
}
}
pub struct RefsAnalyzer {
records: Vec<RefsRecord>,
}
impl RefsAnalyzer {
pub fn new(records: Vec<RefsRecord>) -> Self {
Self { records }
}
pub fn is_likely_refs(&self) -> bool {
if self.records.is_empty() {
return false;
}
let all_v3 = self.records.iter().all(|r| r.record.major_version == 3);
if !all_v3 {
return false;
}
self.records
.iter()
.any(|r| r.file_id.high() != 0 || r.parent_id.high() != 0)
}
pub fn group_by_file_id(&self) -> HashMap<RefsFileId, Vec<&RefsRecord>> {
let mut groups: HashMap<RefsFileId, Vec<&RefsRecord>> = HashMap::new();
for rec in &self.records {
groups.entry(rec.file_id).or_default().push(rec);
}
groups
}
pub fn reconstruct_paths(&self) -> HashMap<RefsFileId, String> {
let mut lookup: HashMap<RefsFileId, (String, RefsFileId)> = HashMap::new();
for rec in &self.records {
lookup.insert(rec.file_id, (rec.record.filename.clone(), rec.parent_id));
}
let root_ids: std::collections::HashSet<RefsFileId> = self
.records
.iter()
.map(|r| r.parent_id)
.filter(|pid| !lookup.contains_key(pid))
.collect();
let mut paths: HashMap<RefsFileId, String> = HashMap::new();
for &file_id in lookup.keys() {
if root_ids.contains(&file_id) {
continue; }
let mut components = Vec::new();
let mut current = file_id;
let mut visited = std::collections::HashSet::new();
loop {
if !visited.insert(current) {
break;
}
if let Some((name, parent)) = lookup.get(¤t) {
components.push(name.clone());
if root_ids.contains(parent) || !lookup.contains_key(parent) {
break;
}
current = *parent;
} else {
break; }
}
components.reverse();
if !components.is_empty() {
paths.insert(file_id, components.join("\\"));
}
}
paths
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::usn::{FileAttributes, UsnReason, UsnRecord};
use chrono::DateTime;
fn make_v3_record(
mft_entry: u64,
parent_mft_entry: u64,
reason: UsnReason,
filename: &str,
) -> UsnRecord {
UsnRecord {
mft_entry,
mft_sequence: 0,
parent_mft_entry,
parent_mft_sequence: 0,
usn: 1000,
timestamp: DateTime::from_timestamp(1_700_000_000, 0).unwrap(),
reason,
filename: filename.to_string(),
file_attributes: FileAttributes::from_bits_retain(0x20), source_info: 0,
security_id: 0,
major_version: 3,
}
}
#[test]
fn test_refs_file_id_from_u128() {
let value: u128 = 0x0000_0000_0000_0001_0000_0000_0000_0064;
let id = RefsFileId::from_u128(value);
assert_eq!(id.0, value);
assert_eq!(id.high(), 0x0000_0000_0000_0001);
assert_eq!(id.low(), 0x0000_0000_0000_0064);
let zero_id = RefsFileId::from_u128(0);
assert_eq!(zero_id.0, 0);
assert_eq!(zero_id.high(), 0);
assert_eq!(zero_id.low(), 0);
let max_id = RefsFileId::from_u128(u128::MAX);
assert_eq!(max_id.high(), u64::MAX);
assert_eq!(max_id.low(), u64::MAX);
}
#[test]
fn test_refs_file_id_display() {
let id = RefsFileId::from_u128(0x0000_0000_0000_0001_0000_0000_0000_0064);
let display = format!("{id}");
assert_eq!(display, "0x0000000000000001:0x0000000000000064");
let zero_id = RefsFileId::from_u128(0);
assert_eq!(
format!("{zero_id}"),
"0x0000000000000000:0x0000000000000000"
);
let large_id = RefsFileId::from_u128(0xDEAD_BEEF_CAFE_BABE_1234_5678_9ABC_DEF0);
assert_eq!(
format!("{large_id}"),
"0xdeadbeefcafebabe:0x123456789abcdef0"
);
}
#[test]
fn test_refs_volume_detection() {
let rec1 = make_v3_record(100, 5, UsnReason::FILE_CREATE, "file.txt");
let refs_rec1 = RefsRecord::new(
rec1,
RefsFileId::from_u128(0x0000_0000_0000_0001_0000_0000_0000_0064),
RefsFileId::from_u128(0x0000_0000_0000_0001_0000_0000_0000_0005),
);
let analyzer = RefsAnalyzer::new(vec![refs_rec1]);
assert!(analyzer.is_likely_refs());
let rec2 = make_v3_record(200, 5, UsnReason::FILE_CREATE, "ntfs_file.txt");
let refs_rec2 = RefsRecord::new(
rec2,
RefsFileId::from_u128(0x0000_0000_0000_0000_0000_0000_0000_00C8),
RefsFileId::from_u128(0x0000_0000_0000_0000_0000_0000_0000_0005),
);
let analyzer2 = RefsAnalyzer::new(vec![refs_rec2]);
assert!(!analyzer2.is_likely_refs());
let analyzer3 = RefsAnalyzer::new(vec![]);
assert!(!analyzer3.is_likely_refs());
}
#[test]
fn test_refs_record_grouping() {
let file_id_a = RefsFileId::from_u128(0x0000_0000_0000_0001_0000_0000_0000_000A);
let file_id_b = RefsFileId::from_u128(0x0000_0000_0000_0001_0000_0000_0000_000B);
let parent_id = RefsFileId::from_u128(0x0000_0000_0000_0001_0000_0000_0000_0005);
let rec1 = RefsRecord::new(
make_v3_record(10, 5, UsnReason::FILE_CREATE, "alpha.txt"),
file_id_a,
parent_id,
);
let rec2 = RefsRecord::new(
make_v3_record(10, 5, UsnReason::DATA_EXTEND, "alpha.txt"),
file_id_a,
parent_id,
);
let rec3 = RefsRecord::new(
make_v3_record(11, 5, UsnReason::FILE_CREATE, "beta.txt"),
file_id_b,
parent_id,
);
let analyzer = RefsAnalyzer::new(vec![rec1, rec2, rec3]);
let groups = analyzer.group_by_file_id();
assert_eq!(groups.len(), 2);
assert_eq!(groups.get(&file_id_a).map(std::vec::Vec::len), Some(2));
assert_eq!(groups.get(&file_id_b).map(std::vec::Vec::len), Some(1));
let a_records = groups.get(&file_id_a).unwrap();
assert!(a_records.iter().all(|r| r.record.filename == "alpha.txt"));
}
#[test]
fn test_refs_path_reconstruction_without_mft() {
let root_id = RefsFileId::from_u128(5);
let docs_id = RefsFileId::from_u128(100);
let file_id = RefsFileId::from_u128(200);
let dir_create = RefsRecord::new(
{
let mut r = make_v3_record(100, 5, UsnReason::FILE_CREATE, "Documents");
r.file_attributes = FileAttributes::from_bits_retain(0x10); r
},
docs_id,
root_id,
);
let file_create = RefsRecord::new(
make_v3_record(200, 100, UsnReason::FILE_CREATE, "report.docx"),
file_id,
docs_id,
);
let analyzer = RefsAnalyzer::new(vec![dir_create, file_create]);
let paths = analyzer.reconstruct_paths();
assert_eq!(
paths.get(&file_id).map(std::string::String::as_str),
Some("Documents\\report.docx")
);
assert_eq!(
paths.get(&docs_id).map(std::string::String::as_str),
Some("Documents")
);
assert!(!paths.contains_key(&root_id));
}
#[test]
fn test_refs_path_cycle_detection() {
let id_a = RefsFileId::from_u128(10);
let id_b = RefsFileId::from_u128(20);
let rec_a = RefsRecord::new(
make_v3_record(10, 20, UsnReason::FILE_CREATE, "dir_a"),
id_a,
id_b,
);
let rec_b = RefsRecord::new(
make_v3_record(20, 10, UsnReason::FILE_CREATE, "dir_b"),
id_b,
id_a,
);
let analyzer = RefsAnalyzer::new(vec![rec_a, rec_b]);
let paths = analyzer.reconstruct_paths();
assert!(paths.len() <= 2);
}
#[test]
fn test_refs_empty_analyzer() {
let analyzer = RefsAnalyzer::new(vec![]);
let groups = analyzer.group_by_file_id();
assert!(groups.is_empty());
let paths = analyzer.reconstruct_paths();
assert!(paths.is_empty());
}
#[test]
fn test_refs_file_id_equality() {
let id1 = RefsFileId::from_u128(42);
let id2 = RefsFileId::from_u128(42);
let id3 = RefsFileId::from_u128(43);
assert_eq!(id1, id2);
assert_ne!(id1, id3);
}
#[test]
fn test_refs_reconstruct_paths_root_id_skipped() {
let root_id = RefsFileId::from_u128(5);
let root_record = RefsRecord::new(
make_v3_record(5, 999, UsnReason::FILE_CREATE, "root_dir"),
root_id,
RefsFileId::from_u128(999),
);
let analyzer = RefsAnalyzer::new(vec![root_record.clone()]);
let paths = analyzer.reconstruct_paths();
assert_eq!(
paths.get(&root_id).map(std::string::String::as_str),
Some("root_dir")
);
}
#[test]
fn test_refs_reconstruct_paths_single_orphan() {
let orphan_id = RefsFileId::from_u128(42);
let unknown_parent = RefsFileId::from_u128(999);
let rec = RefsRecord::new(
make_v3_record(42, 999, UsnReason::FILE_CREATE, "orphan.txt"),
orphan_id,
unknown_parent,
);
let analyzer = RefsAnalyzer::new(vec![rec]);
let paths = analyzer.reconstruct_paths();
assert_eq!(
paths.get(&orphan_id).map(std::string::String::as_str),
Some("orphan.txt")
);
}
#[test]
fn test_refs_reconstruct_deep_chain_with_missing_ancestor() {
let id_a = RefsFileId::from_u128(10);
let id_b = RefsFileId::from_u128(20);
let id_c = RefsFileId::from_u128(30);
let id_d = RefsFileId::from_u128(40);
let rec_a = RefsRecord::new(
make_v3_record(10, 20, UsnReason::FILE_CREATE, "file.txt"),
id_a,
id_b,
);
let rec_b = RefsRecord::new(
make_v3_record(20, 30, UsnReason::FILE_CREATE, "subdir"),
id_b,
id_c,
);
let rec_c = RefsRecord::new(
make_v3_record(30, 40, UsnReason::FILE_CREATE, "topdir"),
id_c,
id_d,
);
let analyzer = RefsAnalyzer::new(vec![rec_a, rec_b, rec_c]);
let paths = analyzer.reconstruct_paths();
assert_eq!(
paths.get(&id_a).map(std::string::String::as_str),
Some("topdir\\subdir\\file.txt")
);
assert_eq!(
paths.get(&id_b).map(std::string::String::as_str),
Some("topdir\\subdir")
);
assert_eq!(
paths.get(&id_c).map(std::string::String::as_str),
Some("topdir")
);
}
#[test]
fn test_refs_mixed_v2_and_v3_not_refs() {
let v2_record = UsnRecord {
mft_entry: 100,
mft_sequence: 0,
parent_mft_entry: 5,
parent_mft_sequence: 0,
usn: 1000,
timestamp: DateTime::from_timestamp(1_700_000_000, 0).unwrap(),
reason: UsnReason::FILE_CREATE,
filename: "v2file.txt".to_string(),
file_attributes: FileAttributes::from_bits_retain(0x20),
source_info: 0,
security_id: 0,
major_version: 2, };
let refs_rec = RefsRecord::new(
v2_record,
RefsFileId::from_u128(0x0000_0000_0000_0001_0000_0000_0000_0064),
RefsFileId::from_u128(0x0000_0000_0000_0001_0000_0000_0000_0005),
);
let analyzer = RefsAnalyzer::new(vec![refs_rec]);
assert!(!analyzer.is_likely_refs());
}
#[test]
fn test_refs_reconstruct_paths_parent_not_in_lookup() {
let id_a = RefsFileId::from_u128(10);
let id_b = RefsFileId::from_u128(20);
let id_c = RefsFileId::from_u128(30);
let rec_a = RefsRecord::new(
make_v3_record(10, 20, UsnReason::FILE_CREATE, "file_a"),
id_a,
id_b,
);
let rec_b = RefsRecord::new(
make_v3_record(20, 30, UsnReason::FILE_CREATE, "dir_b"),
id_b,
id_c,
);
let analyzer = RefsAnalyzer::new(vec![rec_a, rec_b]);
let paths = analyzer.reconstruct_paths();
assert_eq!(
paths.get(&id_a).map(std::string::String::as_str),
Some("dir_b\\file_a")
);
assert_eq!(
paths.get(&id_b).map(std::string::String::as_str),
Some("dir_b")
);
}
}