1use std::collections::HashMap;
13use std::fmt;
14
15use crate::usn::UsnRecord;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25pub struct RefsFileId(pub u128);
26
27impl RefsFileId {
28 pub fn from_u128(value: u128) -> Self {
30 Self(value)
31 }
32
33 pub fn high(&self) -> u64 {
35 (self.0 >> 64) as u64
36 }
37
38 pub fn low(&self) -> u64 {
40 self.0 as u64
41 }
42}
43
44impl fmt::Display for RefsFileId {
45 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46 write!(f, "0x{:016x}:0x{:016x}", self.high(), self.low())
47 }
48}
49
50#[derive(Debug, Clone)]
56pub struct RefsRecord {
57 pub record: UsnRecord,
59 pub file_id: RefsFileId,
61 pub parent_id: RefsFileId,
63}
64
65impl RefsRecord {
66 pub fn new(record: UsnRecord, file_id: RefsFileId, parent_id: RefsFileId) -> Self {
68 Self {
69 record,
70 file_id,
71 parent_id,
72 }
73 }
74}
75
76pub struct RefsAnalyzer {
81 records: Vec<RefsRecord>,
82}
83
84impl RefsAnalyzer {
85 pub fn new(records: Vec<RefsRecord>) -> Self {
87 Self { records }
88 }
89
90 pub fn is_likely_refs(&self) -> bool {
96 if self.records.is_empty() {
97 return false;
98 }
99
100 let all_v3 = self.records.iter().all(|r| r.record.major_version == 3);
101 if !all_v3 {
102 return false;
103 }
104
105 self.records
108 .iter()
109 .any(|r| r.file_id.high() != 0 || r.parent_id.high() != 0)
110 }
111
112 pub fn group_by_file_id(&self) -> HashMap<RefsFileId, Vec<&RefsRecord>> {
116 let mut groups: HashMap<RefsFileId, Vec<&RefsRecord>> = HashMap::new();
117 for rec in &self.records {
118 groups.entry(rec.file_id).or_default().push(rec);
119 }
120 groups
121 }
122
123 pub fn reconstruct_paths(&self) -> HashMap<RefsFileId, String> {
131 let mut lookup: HashMap<RefsFileId, (String, RefsFileId)> = HashMap::new();
134
135 for rec in &self.records {
136 lookup.insert(rec.file_id, (rec.record.filename.clone(), rec.parent_id));
137 }
138
139 let root_ids: std::collections::HashSet<RefsFileId> = self
142 .records
143 .iter()
144 .map(|r| r.parent_id)
145 .filter(|pid| !lookup.contains_key(pid))
146 .collect();
147
148 let mut paths: HashMap<RefsFileId, String> = HashMap::new();
150
151 for &file_id in lookup.keys() {
152 if root_ids.contains(&file_id) {
153 continue; }
155
156 let mut components = Vec::new();
157 let mut current = file_id;
158 let mut visited = std::collections::HashSet::new();
159
160 loop {
161 if !visited.insert(current) {
162 break;
164 }
165
166 if let Some((name, parent)) = lookup.get(¤t) {
167 components.push(name.clone());
168 if root_ids.contains(parent) || !lookup.contains_key(parent) {
169 break;
170 }
171 current = *parent;
172 } else {
173 break; }
175 }
176
177 components.reverse();
178 if !components.is_empty() {
179 paths.insert(file_id, components.join("\\"));
180 }
181 }
182
183 paths
184 }
185}
186
187#[cfg(test)]
190mod tests {
191 use super::*;
192 use crate::usn::{FileAttributes, UsnReason, UsnRecord};
193 use chrono::DateTime;
194
195 fn make_v3_record(
197 mft_entry: u64,
198 parent_mft_entry: u64,
199 reason: UsnReason,
200 filename: &str,
201 ) -> UsnRecord {
202 UsnRecord {
203 mft_entry,
204 mft_sequence: 0,
205 parent_mft_entry,
206 parent_mft_sequence: 0,
207 usn: 1000,
208 timestamp: DateTime::from_timestamp(1_700_000_000, 0).unwrap(),
209 reason,
210 filename: filename.to_string(),
211 file_attributes: FileAttributes::from_bits_retain(0x20), source_info: 0,
213 security_id: 0,
214 major_version: 3,
215 }
216 }
217
218 #[test]
219 fn test_refs_file_id_from_u128() {
220 let value: u128 = 0x0000_0000_0000_0001_0000_0000_0000_0064;
222 let id = RefsFileId::from_u128(value);
223 assert_eq!(id.0, value);
224
225 assert_eq!(id.high(), 0x0000_0000_0000_0001);
227 assert_eq!(id.low(), 0x0000_0000_0000_0064);
228
229 let zero_id = RefsFileId::from_u128(0);
231 assert_eq!(zero_id.0, 0);
232 assert_eq!(zero_id.high(), 0);
233 assert_eq!(zero_id.low(), 0);
234
235 let max_id = RefsFileId::from_u128(u128::MAX);
237 assert_eq!(max_id.high(), u64::MAX);
238 assert_eq!(max_id.low(), u64::MAX);
239 }
240
241 #[test]
242 fn test_refs_file_id_display() {
243 let id = RefsFileId::from_u128(0x0000_0000_0000_0001_0000_0000_0000_0064);
245 let display = format!("{id}");
246 assert_eq!(display, "0x0000000000000001:0x0000000000000064");
247
248 let zero_id = RefsFileId::from_u128(0);
250 assert_eq!(
251 format!("{zero_id}"),
252 "0x0000000000000000:0x0000000000000000"
253 );
254
255 let large_id = RefsFileId::from_u128(0xDEAD_BEEF_CAFE_BABE_1234_5678_9ABC_DEF0);
257 assert_eq!(
258 format!("{large_id}"),
259 "0xdeadbeefcafebabe:0x123456789abcdef0"
260 );
261 }
262
263 #[test]
264 fn test_refs_volume_detection() {
265 let rec1 = make_v3_record(100, 5, UsnReason::FILE_CREATE, "file.txt");
267 let refs_rec1 = RefsRecord::new(
268 rec1,
269 RefsFileId::from_u128(0x0000_0000_0000_0001_0000_0000_0000_0064),
270 RefsFileId::from_u128(0x0000_0000_0000_0001_0000_0000_0000_0005),
271 );
272
273 let analyzer = RefsAnalyzer::new(vec![refs_rec1]);
274 assert!(analyzer.is_likely_refs());
275
276 let rec2 = make_v3_record(200, 5, UsnReason::FILE_CREATE, "ntfs_file.txt");
278 let refs_rec2 = RefsRecord::new(
279 rec2,
280 RefsFileId::from_u128(0x0000_0000_0000_0000_0000_0000_0000_00C8),
281 RefsFileId::from_u128(0x0000_0000_0000_0000_0000_0000_0000_0005),
282 );
283
284 let analyzer2 = RefsAnalyzer::new(vec![refs_rec2]);
285 assert!(!analyzer2.is_likely_refs());
286
287 let analyzer3 = RefsAnalyzer::new(vec![]);
289 assert!(!analyzer3.is_likely_refs());
290 }
291
292 #[test]
293 fn test_refs_record_grouping() {
294 let file_id_a = RefsFileId::from_u128(0x0000_0000_0000_0001_0000_0000_0000_000A);
296 let file_id_b = RefsFileId::from_u128(0x0000_0000_0000_0001_0000_0000_0000_000B);
297 let parent_id = RefsFileId::from_u128(0x0000_0000_0000_0001_0000_0000_0000_0005);
298
299 let rec1 = RefsRecord::new(
300 make_v3_record(10, 5, UsnReason::FILE_CREATE, "alpha.txt"),
301 file_id_a,
302 parent_id,
303 );
304 let rec2 = RefsRecord::new(
305 make_v3_record(10, 5, UsnReason::DATA_EXTEND, "alpha.txt"),
306 file_id_a,
307 parent_id,
308 );
309 let rec3 = RefsRecord::new(
310 make_v3_record(11, 5, UsnReason::FILE_CREATE, "beta.txt"),
311 file_id_b,
312 parent_id,
313 );
314
315 let analyzer = RefsAnalyzer::new(vec![rec1, rec2, rec3]);
316 let groups = analyzer.group_by_file_id();
317
318 assert_eq!(groups.len(), 2);
319 assert_eq!(groups.get(&file_id_a).map(std::vec::Vec::len), Some(2));
320 assert_eq!(groups.get(&file_id_b).map(std::vec::Vec::len), Some(1));
321
322 let a_records = groups.get(&file_id_a).unwrap();
324 assert!(a_records.iter().all(|r| r.record.filename == "alpha.txt"));
325 }
326
327 #[test]
328 fn test_refs_path_reconstruction_without_mft() {
329 let root_id = RefsFileId::from_u128(5);
335 let docs_id = RefsFileId::from_u128(100);
336 let file_id = RefsFileId::from_u128(200);
337
338 let dir_create = RefsRecord::new(
340 {
341 let mut r = make_v3_record(100, 5, UsnReason::FILE_CREATE, "Documents");
342 r.file_attributes = FileAttributes::from_bits_retain(0x10); r
344 },
345 docs_id,
346 root_id,
347 );
348
349 let file_create = RefsRecord::new(
351 make_v3_record(200, 100, UsnReason::FILE_CREATE, "report.docx"),
352 file_id,
353 docs_id,
354 );
355
356 let analyzer = RefsAnalyzer::new(vec![dir_create, file_create]);
357 let paths = analyzer.reconstruct_paths();
358
359 assert_eq!(
361 paths.get(&file_id).map(std::string::String::as_str),
362 Some("Documents\\report.docx")
363 );
364
365 assert_eq!(
367 paths.get(&docs_id).map(std::string::String::as_str),
368 Some("Documents")
369 );
370
371 assert!(!paths.contains_key(&root_id));
373 }
374
375 #[test]
376 fn test_refs_path_cycle_detection() {
377 let id_a = RefsFileId::from_u128(10);
379 let id_b = RefsFileId::from_u128(20);
380
381 let rec_a = RefsRecord::new(
382 make_v3_record(10, 20, UsnReason::FILE_CREATE, "dir_a"),
383 id_a,
384 id_b,
385 );
386 let rec_b = RefsRecord::new(
387 make_v3_record(20, 10, UsnReason::FILE_CREATE, "dir_b"),
388 id_b,
389 id_a,
390 );
391
392 let analyzer = RefsAnalyzer::new(vec![rec_a, rec_b]);
393 let paths = analyzer.reconstruct_paths();
394 assert!(paths.len() <= 2);
397 }
398
399 #[test]
400 fn test_refs_empty_analyzer() {
401 let analyzer = RefsAnalyzer::new(vec![]);
402 let groups = analyzer.group_by_file_id();
403 assert!(groups.is_empty());
404 let paths = analyzer.reconstruct_paths();
405 assert!(paths.is_empty());
406 }
407
408 #[test]
409 fn test_refs_file_id_equality() {
410 let id1 = RefsFileId::from_u128(42);
411 let id2 = RefsFileId::from_u128(42);
412 let id3 = RefsFileId::from_u128(43);
413 assert_eq!(id1, id2);
414 assert_ne!(id1, id3);
415 }
416
417 #[test]
418 fn test_refs_reconstruct_paths_root_id_skipped() {
419 let root_id = RefsFileId::from_u128(5);
425
426 let root_record = RefsRecord::new(
427 make_v3_record(5, 999, UsnReason::FILE_CREATE, "root_dir"),
428 root_id,
429 RefsFileId::from_u128(999),
430 );
431
432 let analyzer = RefsAnalyzer::new(vec![root_record.clone()]);
440 let paths = analyzer.reconstruct_paths();
441 assert_eq!(
442 paths.get(&root_id).map(std::string::String::as_str),
443 Some("root_dir")
444 );
445 }
446
447 #[test]
448 fn test_refs_reconstruct_paths_single_orphan() {
449 let orphan_id = RefsFileId::from_u128(42);
453 let unknown_parent = RefsFileId::from_u128(999);
454
455 let rec = RefsRecord::new(
456 make_v3_record(42, 999, UsnReason::FILE_CREATE, "orphan.txt"),
457 orphan_id,
458 unknown_parent,
459 );
460
461 let analyzer = RefsAnalyzer::new(vec![rec]);
462 let paths = analyzer.reconstruct_paths();
463 assert_eq!(
465 paths.get(&orphan_id).map(std::string::String::as_str),
466 Some("orphan.txt")
467 );
468 }
469
470 #[test]
471 fn test_refs_reconstruct_deep_chain_with_missing_ancestor() {
472 let id_a = RefsFileId::from_u128(10);
475 let id_b = RefsFileId::from_u128(20);
476 let id_c = RefsFileId::from_u128(30);
477 let id_d = RefsFileId::from_u128(40); let rec_a = RefsRecord::new(
480 make_v3_record(10, 20, UsnReason::FILE_CREATE, "file.txt"),
481 id_a,
482 id_b,
483 );
484 let rec_b = RefsRecord::new(
485 make_v3_record(20, 30, UsnReason::FILE_CREATE, "subdir"),
486 id_b,
487 id_c,
488 );
489 let rec_c = RefsRecord::new(
490 make_v3_record(30, 40, UsnReason::FILE_CREATE, "topdir"),
491 id_c,
492 id_d,
493 );
494
495 let analyzer = RefsAnalyzer::new(vec![rec_a, rec_b, rec_c]);
496 let paths = analyzer.reconstruct_paths();
497
498 assert_eq!(
499 paths.get(&id_a).map(std::string::String::as_str),
500 Some("topdir\\subdir\\file.txt")
501 );
502 assert_eq!(
503 paths.get(&id_b).map(std::string::String::as_str),
504 Some("topdir\\subdir")
505 );
506 assert_eq!(
507 paths.get(&id_c).map(std::string::String::as_str),
508 Some("topdir")
509 );
510 }
511
512 #[test]
513 fn test_refs_mixed_v2_and_v3_not_refs() {
514 let v2_record = UsnRecord {
516 mft_entry: 100,
517 mft_sequence: 0,
518 parent_mft_entry: 5,
519 parent_mft_sequence: 0,
520 usn: 1000,
521 timestamp: DateTime::from_timestamp(1_700_000_000, 0).unwrap(),
522 reason: UsnReason::FILE_CREATE,
523 filename: "v2file.txt".to_string(),
524 file_attributes: FileAttributes::from_bits_retain(0x20),
525 source_info: 0,
526 security_id: 0,
527 major_version: 2, };
529 let refs_rec = RefsRecord::new(
530 v2_record,
531 RefsFileId::from_u128(0x0000_0000_0000_0001_0000_0000_0000_0064),
532 RefsFileId::from_u128(0x0000_0000_0000_0001_0000_0000_0000_0005),
533 );
534
535 let analyzer = RefsAnalyzer::new(vec![refs_rec]);
536 assert!(!analyzer.is_likely_refs());
537 }
538
539 #[test]
540 fn test_refs_reconstruct_paths_parent_not_in_lookup() {
541 let id_a = RefsFileId::from_u128(10);
554 let id_b = RefsFileId::from_u128(20);
555 let id_c = RefsFileId::from_u128(30); let rec_a = RefsRecord::new(
558 make_v3_record(10, 20, UsnReason::FILE_CREATE, "file_a"),
559 id_a,
560 id_b,
561 );
562 let rec_b = RefsRecord::new(
563 make_v3_record(20, 30, UsnReason::FILE_CREATE, "dir_b"),
564 id_b,
565 id_c,
566 );
567 let analyzer = RefsAnalyzer::new(vec![rec_a, rec_b]);
570 let paths = analyzer.reconstruct_paths();
571
572 assert_eq!(
574 paths.get(&id_a).map(std::string::String::as_str),
575 Some("dir_b\\file_a")
576 );
577 assert_eq!(
579 paths.get(&id_b).map(std::string::String::as_str),
580 Some("dir_b")
581 );
582 }
583}