Skip to main content

ntfs_forensic/
lib.rs

1//! Forensic Tier-2: the artifacts a "clean" reader hides — timestomping
2//! indicators, alternate data streams, MFT-record slack, and deleted records.
3//!
4//! These are pure analyses over already-parsed structures, so they are exact
5//! and side-effect free.
6
7#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
8
9pub mod analysis;
10pub mod correlation;
11pub mod rules;
12pub mod triage;
13
14use forensicnomicon::ntfs::{attr_types, SIGNATURE_BAAD, SIGNATURE_FILE};
15
16use ntfs_core::attribute::Attribute;
17use ntfs_core::file_name::FileName;
18use ntfs_core::record::MftRecordHeader;
19use ntfs_core::standard_information::StandardInformation;
20use ntfs_core::time::Filetime;
21
22/// `FILETIME` ticks per second (100-ns intervals).
23const TICKS_PER_SECOND: u64 = 10_000_000;
24
25/// Indicators that a file's `$STANDARD_INFORMATION` timestamps were forged.
26///
27/// `$FN` timestamps are harder to forge than `$SI`, so divergence between the
28/// two — or `$SI` times landing on a whole second — is suspicious.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub struct TimestompIndicators {
31    /// `$SI` creation time predates `$FN` creation time.
32    pub si_created_before_fn: bool,
33    /// `$SI` creation time differs from `$FN` creation time.
34    pub created_mismatch: bool,
35    /// One or more `$SI` timestamps fall exactly on a whole second (no
36    /// sub-second precision — a common timestomp artifact).
37    pub si_whole_second: bool,
38}
39
40impl TimestompIndicators {
41    /// `true` if any strong indicator fired.
42    #[must_use]
43    pub fn is_suspicious(&self) -> bool {
44        self.si_created_before_fn || self.si_whole_second
45    }
46}
47
48/// Compare a file's `$STANDARD_INFORMATION` against one of its `$FILE_NAME`
49/// attributes for timestomping indicators.
50#[must_use]
51pub fn detect_timestomp(si: &StandardInformation, file_name: &FileName) -> TimestompIndicators {
52    TimestompIndicators {
53        si_created_before_fn: si.created.0 < file_name.created.0,
54        created_mismatch: si.created.0 != file_name.created.0,
55        si_whole_second: whole_second(si.created)
56            || whole_second(si.modified)
57            || whole_second(si.mft_modified)
58            || whole_second(si.accessed),
59    }
60}
61
62/// `true` when a timestamp is non-zero yet lands exactly on a whole second.
63fn whole_second(ft: Filetime) -> bool {
64    ft.0 != 0 && ft.0 % TICKS_PER_SECOND == 0
65}
66
67/// The named `$DATA` attributes of a file — its alternate data streams.
68#[must_use]
69pub fn alternate_data_streams(attributes: &[Attribute]) -> Vec<&Attribute> {
70    attributes
71        .iter()
72        .filter(|a| a.type_code == attr_types::DATA && a.name.is_some())
73        .collect()
74}
75
76/// The slack of an MFT record: the bytes from the record's used size to its end,
77/// which may hold residue from a previously-resident attribute.
78#[must_use]
79pub fn record_slack<'a>(record: &'a [u8], header: &MftRecordHeader) -> &'a [u8] {
80    let used = header.used_size as usize;
81    record.get(used..).unwrap_or(&[])
82}
83
84/// `true` if the record is not currently allocated (a deleted file).
85#[must_use]
86pub fn is_deleted(header: &MftRecordHeader) -> bool {
87    !header.is_in_use()
88}
89
90/// Scan a raw MFT byte region for `FILE`/`BAAD` records at record-size
91/// boundaries, returning the offset of each.
92#[must_use]
93pub fn carve_file_records(mft: &[u8], record_size: usize) -> Vec<usize> {
94    if record_size == 0 {
95        return Vec::new();
96    }
97    let mut offsets = Vec::new();
98    let mut pos = 0;
99    while pos + 4 <= mft.len() {
100        let sig = &mft[pos..pos + 4];
101        if sig == SIGNATURE_FILE || sig == SIGNATURE_BAAD {
102            offsets.push(pos);
103        }
104        pos += record_size;
105    }
106    offsets
107}
108
109// ── Tier-2 anomaly auditor (findings → forensicnomicon::report) ──────────────
110//
111// The primitives above answer "what does this record show?"; the auditor grades
112// those observations into severity-ranked findings on the shared
113// `forensicnomicon::report` model, so an NTFS volume's anomalies aggregate
114// uniformly with the partition/container layers. Each anomaly is an
115// *observation* ("consistent with …"); the examiner draws the conclusions.
116
117/// The canonical 5-level severity scale, shared across every `SecurityRonin`
118/// analyzer via [`forensicnomicon::report`].
119pub use forensicnomicon::report::Severity;
120
121/// Classification of an NTFS forensic anomaly. Each variant carries the MFT
122/// record it was observed in plus the evidence to reproduce it.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub enum AnomalyKind {
125    /// `$STANDARD_INFORMATION` timestamps show forgery tells relative to the
126    /// harder-to-forge `$FILE_NAME` times (or land on whole seconds).
127    Timestomp {
128        /// MFT record number.
129        record: u64,
130        /// The specific tell that fired.
131        signal: &'static str,
132    },
133    /// A named `$DATA` attribute — an alternate data stream, a common place to
134    /// carry hidden payloads (also used benignly, e.g. `Zone.Identifier`).
135    AlternateDataStream {
136        /// MFT record number.
137        record: u64,
138        /// The stream name.
139        stream: String,
140    },
141    /// The MFT record is not in use — a recoverable deleted file.
142    DeletedRecord {
143        /// MFT record number.
144        record: u64,
145    },
146    /// Non-zero residue in the record's slack (past `used_size`).
147    RecordSlackResidue {
148        /// MFT record number.
149        record: u64,
150        /// Count of non-zero bytes in the slack.
151        residue_len: usize,
152    },
153}
154
155impl AnomalyKind {
156    /// The MFT record this anomaly was observed in.
157    #[must_use]
158    pub fn record(&self) -> u64 {
159        match self {
160            AnomalyKind::Timestomp { record, .. }
161            | AnomalyKind::AlternateDataStream { record, .. }
162            | AnomalyKind::DeletedRecord { record }
163            | AnomalyKind::RecordSlackResidue { record, .. } => *record,
164        }
165    }
166
167    /// Severity — the single source of truth for this kind.
168    #[must_use]
169    pub fn severity(&self) -> Severity {
170        match self {
171            AnomalyKind::Timestomp { .. } => Severity::High,
172            AnomalyKind::AlternateDataStream { .. } | AnomalyKind::RecordSlackResidue { .. } => {
173                Severity::Low
174            }
175            AnomalyKind::DeletedRecord { .. } => Severity::Info,
176        }
177    }
178
179    /// Stable machine-readable code.
180    #[must_use]
181    pub fn code(&self) -> &'static str {
182        match self {
183            AnomalyKind::Timestomp { .. } => "NTFS-TIMESTOMP",
184            AnomalyKind::AlternateDataStream { .. } => "NTFS-ADS",
185            AnomalyKind::DeletedRecord { .. } => "NTFS-DELETED-RECORD",
186            AnomalyKind::RecordSlackResidue { .. } => "NTFS-SLACK-RESIDUE",
187        }
188    }
189
190    /// Human-readable, "consistent with" note.
191    #[must_use]
192    pub fn note(&self) -> String {
193        match self {
194            AnomalyKind::Timestomp { record, signal } => format!(
195                "record {record}: $STANDARD_INFORMATION timestamps consistent with tampering ({signal})"
196            ),
197            AnomalyKind::AlternateDataStream { record, stream } => format!(
198                "record {record}: named $DATA stream `{stream}` — consistent with data carried in an alternate data stream"
199            ),
200            AnomalyKind::DeletedRecord { record } => {
201                format!("record {record}: MFT entry not in use — a recoverable deleted file")
202            }
203            AnomalyKind::RecordSlackResidue { record, residue_len } => format!(
204                "record {record}: {residue_len} non-zero byte(s) in MFT-record slack — consistent with residue from an overwritten resident attribute"
205            ),
206        }
207    }
208}
209
210/// An NTFS forensic anomaly: an observation graded by severity, with a stable
211/// code and note derived from its [`AnomalyKind`] so they cannot drift.
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct Anomaly {
214    /// Severity, derived from `kind`.
215    pub severity: Severity,
216    /// Stable machine-readable code, derived from `kind`.
217    pub code: &'static str,
218    /// The classified anomaly with its evidence.
219    pub kind: AnomalyKind,
220    /// Human-readable note, derived from `kind`.
221    pub note: String,
222}
223
224impl Anomaly {
225    /// Build an [`Anomaly`], deriving severity/code/note from `kind`.
226    #[must_use]
227    pub fn new(kind: AnomalyKind) -> Self {
228        Anomaly {
229            severity: kind.severity(),
230            code: kind.code(),
231            note: kind.note(),
232            kind,
233        }
234    }
235}
236
237impl forensicnomicon::report::Observation for Anomaly {
238    fn severity(&self) -> Option<Severity> {
239        Some(self.severity)
240    }
241    fn code(&self) -> &'static str {
242        self.code
243    }
244    fn note(&self) -> String {
245        self.note.clone()
246    }
247    fn evidence(&self) -> Vec<forensicnomicon::report::Evidence> {
248        let record = self.kind.record();
249        vec![forensicnomicon::report::Evidence {
250            field: "mft record".to_string(),
251            value: record.to_string(),
252            location: Some(forensicnomicon::report::Location::RecordId(record)),
253        }]
254    }
255}
256
257/// Audit a parsed MFT record's components for anomalies. The caller supplies the
258/// already-parsed pieces, so this is exact and side-effect free; see
259/// [`audit_record`] for the convenience that parses raw bytes.
260#[must_use]
261pub fn audit_components(
262    record_number: u64,
263    header: &MftRecordHeader,
264    record: &[u8],
265    attributes: &[Attribute],
266    standard_information: Option<&StandardInformation>,
267    primary_file_name: Option<&FileName>,
268) -> Vec<Anomaly> {
269    let mut out = Vec::new();
270
271    if is_deleted(header) {
272        out.push(Anomaly::new(AnomalyKind::DeletedRecord {
273            record: record_number,
274        }));
275    }
276
277    let residue = record_slack(record, header)
278        .iter()
279        .filter(|&&b| b != 0)
280        .count();
281    if residue > 0 {
282        out.push(Anomaly::new(AnomalyKind::RecordSlackResidue {
283            record: record_number,
284            residue_len: residue,
285        }));
286    }
287
288    for ads in alternate_data_streams(attributes) {
289        out.push(Anomaly::new(AnomalyKind::AlternateDataStream {
290            record: record_number,
291            stream: ads.name.clone().unwrap_or_default(),
292        }));
293    }
294
295    if let (Some(si), Some(fname)) = (standard_information, primary_file_name) {
296        let ind = detect_timestomp(si, fname);
297        if ind.si_created_before_fn {
298            out.push(Anomaly::new(AnomalyKind::Timestomp {
299                record: record_number,
300                signal: "$SI created before $FN",
301            }));
302        }
303        if ind.si_whole_second {
304            out.push(Anomaly::new(AnomalyKind::Timestomp {
305                record: record_number,
306                signal: "$SI timestamp on a whole second",
307            }));
308        }
309    }
310
311    out
312}
313
314/// Audit a single raw MFT record's bytes: parse the header and attributes,
315/// extract `$STANDARD_INFORMATION`/`$FILE_NAME`, and delegate to
316/// [`audit_components`]. A record whose header does not parse yields no
317/// anomalies (structural corruption is surfaced by the reader/carver).
318#[must_use]
319pub fn audit_record(record: &[u8]) -> Vec<Anomaly> {
320    let Ok(header) = MftRecordHeader::parse(record) else {
321        return Vec::new();
322    };
323    let attributes =
324        ntfs_core::attribute::parse_attributes(record, header.first_attribute_offset as usize)
325            .unwrap_or_default();
326
327    let resident = |type_code: u32| {
328        attributes
329            .iter()
330            .find(|a| a.type_code == type_code)
331            .and_then(|a| a.resident_content(record))
332    };
333    let si =
334        resident(attr_types::STANDARD_INFORMATION).and_then(|c| StandardInformation::parse(c).ok());
335    let fname = resident(attr_types::FILE_NAME).and_then(|c| FileName::parse(c).ok());
336
337    audit_components(
338        u64::from(header.record_number),
339        &header,
340        record,
341        &attributes,
342        si.as_ref(),
343        fname.as_ref(),
344    )
345}
346
347// ── Volume-level metadata-artifact auditor ($MFTMirr, $LogFile) ───────────────
348//
349// The record auditor above grades per-MFT-record anomalies; these grade
350// volume-scoped artifacts whose parsers live in `ntfs_core` (`mftmirr`,
351// `logfile`). Each is an observation — the examiner draws the conclusions.
352
353/// Names of the four system records mirrored in `$MFTMirr`.
354const MIRROR_NAMES: [&str; 4] = ["$MFT", "$MFTMirr", "$LogFile", "$Volume"];
355
356/// Render mismatched mirror-entry indices as a human-readable system-file list.
357fn mismatched_names(entries: &[usize]) -> String {
358    entries
359        .iter()
360        .map(|&i| MIRROR_NAMES.get(i).copied().unwrap_or("?"))
361        .collect::<Vec<_>>()
362        .join(", ")
363}
364
365/// A volume-level NTFS metadata-artifact anomaly — scoped to a metadata file
366/// rather than a single MFT record.
367#[derive(Debug, Clone, PartialEq, Eq)]
368pub enum ArtifactAnomaly {
369    /// One or more of the first four system records in `$MFTMirr` differ from
370    /// the live `$MFT` — consistent with MFT tampering or corruption.
371    MftMirrorMismatch {
372        /// Indices (`0..4`) of the mirrored system records that differ.
373        mismatched_entries: Vec<usize>,
374    },
375    /// `$LogFile` shows page gaps or restart-area anomalies — consistent with
376    /// the NTFS transaction journal having been cleared.
377    LogFileCleared,
378}
379
380impl ArtifactAnomaly {
381    /// Severity — the single source of truth for this kind.
382    #[must_use]
383    pub fn severity(&self) -> Severity {
384        match self {
385            ArtifactAnomaly::MftMirrorMismatch { .. } => Severity::High,
386            ArtifactAnomaly::LogFileCleared => Severity::Medium,
387        }
388    }
389
390    /// Stable machine-readable code.
391    #[must_use]
392    pub fn code(&self) -> &'static str {
393        match self {
394            ArtifactAnomaly::MftMirrorMismatch { .. } => "NTFS-MFTMIRR-MISMATCH",
395            ArtifactAnomaly::LogFileCleared => "NTFS-LOGFILE-CLEARED",
396        }
397    }
398
399    /// Human-readable, "consistent with" note.
400    #[must_use]
401    pub fn note(&self) -> String {
402        match self {
403            ArtifactAnomaly::MftMirrorMismatch { mismatched_entries } => format!(
404                "$MFTMirr differs from $MFT for {} — consistent with MFT tampering or corruption",
405                mismatched_names(mismatched_entries)
406            ),
407            ArtifactAnomaly::LogFileCleared => "$LogFile shows gaps/restart-area anomalies — consistent with the transaction journal having been cleared".to_string(),
408        }
409    }
410}
411
412impl forensicnomicon::report::Observation for ArtifactAnomaly {
413    fn severity(&self) -> Option<Severity> {
414        Some(self.severity())
415    }
416    fn code(&self) -> &'static str {
417        self.code()
418    }
419    fn note(&self) -> String {
420        self.note()
421    }
422    fn evidence(&self) -> Vec<forensicnomicon::report::Evidence> {
423        use forensicnomicon::report::{Evidence, Location};
424        match self {
425            ArtifactAnomaly::MftMirrorMismatch { mismatched_entries } => vec![Evidence {
426                field: "mismatched system records".to_string(),
427                value: mismatched_names(mismatched_entries),
428                location: Some(Location::Field("$MFTMirr".to_string())),
429            }],
430            ArtifactAnomaly::LogFileCleared => vec![Evidence {
431                field: "$LogFile".to_string(),
432                value: "gaps/restart-area anomalies consistent with journal clearing".to_string(),
433                location: Some(Location::Field("$LogFile".to_string())),
434            }],
435        }
436    }
437}
438
439/// Audit the `$MFTMirr` against the live `$MFT`, flagging any of the first four
440/// system records that differ. Malformed input yields no findings.
441#[must_use]
442pub fn audit_mft_mirror(mft_data: &[u8], mftmirr_data: &[u8]) -> Vec<ArtifactAnomaly> {
443    match ntfs_core::mftmirr::compare_mft_mirror(mft_data, mftmirr_data) {
444        Ok(cmp) if !cmp.is_consistent => {
445            let mismatched_entries = cmp
446                .matches
447                .iter()
448                .enumerate()
449                .filter_map(|(i, &m)| (!m).then_some(i))
450                .collect();
451            vec![ArtifactAnomaly::MftMirrorMismatch { mismatched_entries }]
452        }
453        _ => Vec::new(),
454    }
455}
456
457/// Audit a raw `$LogFile` for journal-clearing indicators. Malformed input
458/// yields no findings.
459#[must_use]
460pub fn audit_logfile(logfile_data: &[u8]) -> Vec<ArtifactAnomaly> {
461    match ntfs_core::logfile::parse_logfile(logfile_data) {
462        Ok(summary) if ntfs_core::logfile::detect_journal_clearing(&summary) => {
463            vec![ArtifactAnomaly::LogFileCleared]
464        }
465        _ => Vec::new(),
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use ntfs_core::attribute::AttributeBody;
473
474    fn si(created: u64, modified: u64, mft_modified: u64, accessed: u64) -> StandardInformation {
475        StandardInformation {
476            created: Filetime(created),
477            modified: Filetime(modified),
478            mft_modified: Filetime(mft_modified),
479            accessed: Filetime(accessed),
480            file_attributes: 0,
481            security_id: None,
482            usn: None,
483        }
484    }
485
486    fn fname(created: u64) -> FileName {
487        use ntfs_core::file_name::FileReference;
488        FileName {
489            parent: FileReference::from_u64(5),
490            created: Filetime(created),
491            modified: Filetime(created),
492            mft_modified: Filetime(created),
493            accessed: Filetime(created),
494            allocated_size: 0,
495            real_size: 0,
496            flags: 0,
497            namespace: 1,
498            name: "f".to_string(),
499        }
500    }
501
502    fn data_attr(name: Option<&str>) -> Attribute {
503        Attribute {
504            type_code: attr_types::DATA,
505            length: 0,
506            non_resident: false,
507            name: name.map(str::to_string),
508            flags: 0,
509            attribute_id: 0,
510            offset: 0,
511            body: AttributeBody::Resident {
512                content_offset: 0,
513                content_length: 0,
514            },
515        }
516    }
517
518    #[test]
519    fn timestomp_si_before_fn_is_suspicious() {
520        // $SI created well before $FN created → timestomp.
521        let ind = detect_timestomp(&si(1_000, 1_000, 1_000, 1_000), &fname(2_000_000_000));
522        assert!(ind.si_created_before_fn);
523        assert!(ind.is_suspicious());
524    }
525
526    #[test]
527    fn timestomp_whole_second_is_suspicious() {
528        // $SI times all on whole seconds (multiples of 10^7) → timestomp tell.
529        let t = 5 * TICKS_PER_SECOND;
530        let ind = detect_timestomp(&si(t, t, t, t), &fname(t));
531        assert!(ind.si_whole_second);
532        assert!(ind.is_suspicious());
533    }
534
535    #[test]
536    fn matching_subsecond_times_are_clean() {
537        let t = 129_067_776_000_000_123; // has sub-second precision
538        let ind = detect_timestomp(&si(t, t, t, t), &fname(t));
539        assert!(!ind.is_suspicious());
540        assert!(!ind.created_mismatch);
541    }
542
543    #[test]
544    fn finds_alternate_data_streams() {
545        let attrs = [
546            data_attr(None),
547            data_attr(Some("Zone.Identifier")),
548            data_attr(Some("evil")),
549        ];
550        let ads = alternate_data_streams(&attrs);
551        assert_eq!(ads.len(), 2);
552        assert_eq!(ads[0].name.as_deref(), Some("Zone.Identifier"));
553    }
554
555    #[test]
556    fn slack_is_the_tail_after_used_size() {
557        let mut record = vec![0u8; 1024];
558        record[600..610].copy_from_slice(b"RESIDUEXYZ");
559        let header = MftRecordHeader {
560            signature: *b"FILE",
561            usa_offset: 0x30,
562            usa_count: 3,
563            lsn: 0,
564            sequence_number: 1,
565            hard_link_count: 1,
566            first_attribute_offset: 0x38,
567            flags: 0x01,
568            used_size: 600,
569            allocated_size: 1024,
570            base_record: 0,
571            next_attr_id: 1,
572            record_number: 0,
573        };
574        let slack = record_slack(&record, &header);
575        assert_eq!(slack.len(), 1024 - 600);
576        assert_eq!(&slack[0..10], b"RESIDUEXYZ");
577    }
578
579    #[test]
580    fn deleted_when_not_in_use() {
581        let mut header = MftRecordHeader {
582            signature: *b"FILE",
583            usa_offset: 0x30,
584            usa_count: 3,
585            lsn: 0,
586            sequence_number: 1,
587            hard_link_count: 1,
588            first_attribute_offset: 0x38,
589            flags: 0x00, // not in use
590            used_size: 0x100,
591            allocated_size: 1024,
592            base_record: 0,
593            next_attr_id: 1,
594            record_number: 0,
595        };
596        assert!(is_deleted(&header));
597        header.flags = 0x01;
598        assert!(!is_deleted(&header));
599    }
600
601    #[test]
602    fn carve_with_zero_record_size_is_empty() {
603        // A zero stride would loop forever; it is refused with an empty result.
604        assert!(carve_file_records(b"FILE....", 0).is_empty());
605    }
606
607    #[test]
608    fn carves_file_records_at_boundaries() {
609        let rec = 1024usize;
610        let mut mft = vec![0u8; rec * 4];
611        mft[0..4].copy_from_slice(b"FILE"); // record 0
612        mft[2 * rec..2 * rec + 4].copy_from_slice(b"BAAD"); // record 2 (corrupt)
613                                                            // record 1 and 3 are zeroed (no signature)
614        let offsets = carve_file_records(&mft, rec);
615        assert_eq!(offsets, vec![0, 2 * rec]);
616    }
617
618    // ── Anomaly auditor (Tier-2 findings → forensicnomicon::report) ──────────
619
620    fn hdr(flags: u16, used_size: u32, record_number: u32) -> MftRecordHeader {
621        MftRecordHeader {
622            signature: *b"FILE",
623            usa_offset: 0x30,
624            usa_count: 3,
625            lsn: 0,
626            sequence_number: 1,
627            hard_link_count: 1,
628            first_attribute_offset: 0x38,
629            flags,
630            used_size,
631            allocated_size: 1024,
632            base_record: 0,
633            next_attr_id: 1,
634            record_number,
635        }
636    }
637
638    #[test]
639    fn audit_flags_deleted_record() {
640        let header = hdr(0x00, 0x100, 42); // not in use
641        let an = audit_components(42, &header, &vec![0u8; 1024], &[], None, None);
642        assert!(an
643            .iter()
644            .any(|a| matches!(a.kind, AnomalyKind::DeletedRecord { record: 42 })));
645    }
646
647    #[test]
648    fn audit_flags_timestomp() {
649        let header = hdr(0x01, 0x100, 7);
650        let si = si(1_000, 1_000, 1_000, 1_000);
651        let fnm = fname(2_000_000_000);
652        let an = audit_components(7, &header, &vec![0u8; 1024], &[], Some(&si), Some(&fnm));
653        assert!(an
654            .iter()
655            .any(|a| matches!(a.kind, AnomalyKind::Timestomp { .. })));
656    }
657
658    #[test]
659    fn audit_flags_alternate_data_stream() {
660        let header = hdr(0x01, 0x100, 9);
661        let attrs = [data_attr(None), data_attr(Some("evil"))];
662        let an = audit_components(9, &header, &vec![0u8; 1024], &attrs, None, None);
663        assert!(an.iter().any(
664            |a| matches!(&a.kind, AnomalyKind::AlternateDataStream { stream, .. } if stream == "evil")
665        ));
666    }
667
668    #[test]
669    fn audit_flags_slack_residue() {
670        let header = hdr(0x01, 600, 3);
671        let mut record = vec![0u8; 1024];
672        record[700] = 0xAA;
673        let an = audit_components(3, &header, &record, &[], None, None);
674        assert!(an
675            .iter()
676            .any(|a| matches!(a.kind, AnomalyKind::RecordSlackResidue { .. })));
677    }
678
679    #[test]
680    fn audit_clean_record_has_no_anomalies() {
681        let header = hdr(0x01, 1024, 1); // in use, no slack, no attrs
682        let an = audit_components(1, &header, &vec![0u8; 1024], &[], None, None);
683        assert!(an.is_empty(), "clean record: {an:?}");
684    }
685
686    #[test]
687    fn audit_record_on_non_record_bytes_is_empty_not_panic() {
688        // A header that does not parse yields no anomalies (no panic).
689        assert!(audit_record(&[0u8; 16]).is_empty());
690        assert!(audit_record(b"not even a FILE record").is_empty());
691    }
692
693    // ── Builders for an audit_record() end-to-end test (parse → extract → audit) ─
694
695    /// A resident attribute with no name: 24-byte header, content at 0x18.
696    fn resident_attr(type_code: u32, content: &[u8]) -> Vec<u8> {
697        let content_offset = 0x18usize;
698        let length = (content_offset + content.len() + 7) & !7;
699        let mut a = vec![0u8; length];
700        a[0..4].copy_from_slice(&type_code.to_le_bytes()); // TYPE
701        a[4..8].copy_from_slice(&(length as u32).to_le_bytes()); // LENGTH
702        a[8] = 0; // resident
703        a[0x0A..0x0C].copy_from_slice(&(content_offset as u16).to_le_bytes()); // NAME_OFFSET
704        a[0x0E..0x10].copy_from_slice(&1u16.to_le_bytes()); // ATTRIBUTE_ID
705        a[0x10..0x14].copy_from_slice(&(content.len() as u32).to_le_bytes()); // content length
706        a[0x14..0x16].copy_from_slice(&(content_offset as u16).to_le_bytes()); // content offset
707        a[content_offset..content_offset + content.len()].copy_from_slice(content);
708        a
709    }
710
711    fn si_content(created: u64) -> Vec<u8> {
712        let mut c = vec![0u8; 0x30];
713        c[0x00..0x08].copy_from_slice(&created.to_le_bytes()); // $SI created
714        c
715    }
716
717    fn fn_content(created: u64) -> Vec<u8> {
718        let mut c = vec![0u8; 0x44]; // FN_MIN (0x42) + one UTF-16 char
719        c[0x00..0x08].copy_from_slice(&5u64.to_le_bytes()); // parent ref
720        c[0x08..0x10].copy_from_slice(&created.to_le_bytes()); // $FN created
721        c[0x40] = 1; // name length (chars)
722        c[0x41] = 1; // namespace
723        c[0x42..0x44].copy_from_slice(&u16::from(b'f').to_le_bytes());
724        c
725    }
726
727    #[test]
728    fn audit_record_parses_and_flags_timestomp_end_to_end() {
729        // Exercises the full audit_record() path: header parse, attribute parse,
730        // $SI/$FN resident-content extraction, then timestomp detection.
731        // $SI created (1000) far predates $FN created (2e9) → NTFS-TIMESTOMP.
732        let si = resident_attr(attr_types::STANDARD_INFORMATION, &si_content(1_000));
733        let fnm = resident_attr(attr_types::FILE_NAME, &fn_content(2_000_000_000));
734        let first_attr = 0x30usize;
735
736        let mut rec = vec![0u8; 1024];
737        rec[0..4].copy_from_slice(b"FILE");
738        rec[0x04..0x06].copy_from_slice(&0x30u16.to_le_bytes()); // usa_offset
739        rec[0x06..0x08].copy_from_slice(&3u16.to_le_bytes()); // usa_count
740        rec[0x14..0x16].copy_from_slice(&(first_attr as u16).to_le_bytes()); // first attr
741        rec[0x16..0x18].copy_from_slice(&0x01u16.to_le_bytes()); // flags = in use
742        rec[0x1C..0x20].copy_from_slice(&1024u32.to_le_bytes()); // allocated_size
743        rec[0x2C..0x30].copy_from_slice(&7u32.to_le_bytes()); // record_number
744
745        let mut pos = first_attr;
746        rec[pos..pos + si.len()].copy_from_slice(&si);
747        pos += si.len();
748        rec[pos..pos + fnm.len()].copy_from_slice(&fnm);
749        pos += fnm.len();
750        rec[pos..pos + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); // end marker
751        rec[0x18..0x1C].copy_from_slice(&((pos + 4) as u32).to_le_bytes()); // used_size
752
753        let anomalies = audit_record(&rec);
754        let timestomped = anomalies
755            .iter()
756            .any(|a| matches!(a.kind, AnomalyKind::Timestomp { record: 7, .. }));
757        assert!(timestomped, "{anomalies:?}");
758    }
759
760    #[test]
761    fn audit_components_flags_whole_second_timestomp() {
762        // $SI times on whole seconds (no sub-second precision) → timestomp tell,
763        // exercising the si_whole_second branch of audit_components.
764        let header = hdr(0x01, 1024, 4);
765        let whole = 5 * 10_000_000; // 5s in FILETIME ticks
766        let si = si(whole, whole, whole, whole);
767        let fnm = fname(whole);
768        let an = audit_components(4, &header, &vec![0u8; 1024], &[], Some(&si), Some(&fnm));
769        assert!(an
770            .iter()
771            .any(|a| matches!(&a.kind, AnomalyKind::Timestomp { signal, .. } if signal.contains("whole second"))));
772    }
773
774    #[test]
775    fn every_anomaly_kind_carries_its_record_as_evidence() {
776        use forensicnomicon::report::{Location, Observation};
777        // Drives AnomalyKind::record() for every variant via evidence().
778        let kinds = [
779            AnomalyKind::Timestomp {
780                record: 1,
781                signal: "x",
782            },
783            AnomalyKind::AlternateDataStream {
784                record: 2,
785                stream: "s".to_string(),
786            },
787            AnomalyKind::DeletedRecord { record: 3 },
788            AnomalyKind::RecordSlackResidue {
789                record: 4,
790                residue_len: 5,
791            },
792        ];
793        for (i, k) in kinds.into_iter().enumerate() {
794            let ev = Anomaly::new(k).evidence();
795            let rec = (i + 1) as u64;
796            assert!(ev
797                .iter()
798                .any(|e| matches!(e.location, Some(Location::RecordId(r)) if r == rec)));
799        }
800    }
801
802    #[test]
803    fn anomaly_converts_to_canonical_finding() {
804        use forensicnomicon::report::{Observation, Source};
805        let a = Anomaly::new(AnomalyKind::Timestomp {
806            record: 5,
807            signal: "test",
808        });
809        let f = a.to_finding(Source {
810            analyzer: "ntfs-forensic".to_string(),
811            scope: "NTFS".to_string(),
812            version: None,
813        });
814        assert!(f.code.starts_with("NTFS-"));
815        assert!(f.severity.is_some());
816    }
817
818    // ── Volume-level artifact auditor ─────────────────────────────────────
819
820    fn rstr_page() -> Vec<u8> {
821        let mut p = vec![0u8; 0x1000];
822        p[0..4].copy_from_slice(b"RSTR");
823        p[0x10..0x12].copy_from_slice(&1u16.to_le_bytes());
824        p[0x20..0x24].copy_from_slice(&4096u32.to_le_bytes());
825        p[0x24..0x28].copy_from_slice(&4096u32.to_le_bytes());
826        p
827    }
828
829    fn rcrd_page(lsn: u64) -> Vec<u8> {
830        let mut p = vec![0u8; 0x1000];
831        p[0..4].copy_from_slice(b"RCRD");
832        p[0x18..0x20].copy_from_slice(&lsn.to_le_bytes());
833        p
834    }
835
836    #[test]
837    fn audit_mft_mirror_consistent_yields_no_findings() {
838        let mft = vec![0xAAu8; 1024 * 4];
839        assert!(audit_mft_mirror(&mft, &mft).is_empty());
840    }
841
842    #[test]
843    fn audit_mft_mirror_flags_each_differing_system_record() {
844        let mft = vec![0xAAu8; 1024 * 4];
845        let mut mirr = mft.clone();
846        mirr[0] = 0xBB; // record 0 ($MFT) differs
847        mirr[1024 * 2] = 0xCC; // record 2 ($LogFile) differs
848        let anomalies = audit_mft_mirror(&mft, &mirr);
849        assert_eq!(anomalies.len(), 1);
850        assert_eq!(
851            anomalies[0],
852            ArtifactAnomaly::MftMirrorMismatch {
853                mismatched_entries: vec![0, 2]
854            }
855        );
856        assert_eq!(anomalies[0].severity(), Severity::High);
857        assert_eq!(anomalies[0].code(), "NTFS-MFTMIRR-MISMATCH");
858        let note = anomalies[0].note();
859        assert!(note.contains("$MFT") && note.contains("$LogFile"));
860    }
861
862    #[test]
863    fn audit_logfile_flags_cleared_journal() {
864        // Empty $LogFile → no restart areas → treated as cleared.
865        let anomalies = audit_logfile(&[]);
866        assert_eq!(anomalies.len(), 1);
867        assert_eq!(anomalies[0], ArtifactAnomaly::LogFileCleared);
868        assert_eq!(anomalies[0].severity(), Severity::Medium);
869        assert_eq!(anomalies[0].code(), "NTFS-LOGFILE-CLEARED");
870        assert!(anomalies[0].note().contains("$LogFile"));
871    }
872
873    #[test]
874    fn audit_logfile_normal_journal_yields_no_findings() {
875        // Two restart areas + a record page, no gaps → not cleared.
876        let mut data = Vec::new();
877        data.extend_from_slice(&rstr_page());
878        data.extend_from_slice(&rstr_page());
879        data.extend_from_slice(&rcrd_page(3000));
880        assert!(audit_logfile(&data).is_empty());
881    }
882
883    #[test]
884    fn artifact_anomalies_convert_to_canonical_findings() {
885        use forensicnomicon::report::{Evidence, Observation, Source};
886        let src = || Source {
887            analyzer: "ntfs-forensic".to_string(),
888            scope: "volume".to_string(),
889            version: None,
890        };
891
892        let mirror = ArtifactAnomaly::MftMirrorMismatch {
893            mismatched_entries: vec![1],
894        };
895        let f = mirror.to_finding(src());
896        assert_eq!(f.code, "NTFS-MFTMIRR-MISMATCH");
897        assert_eq!(f.severity, Some(Severity::High));
898        let ev: &Evidence = &f.evidence[0];
899        assert_eq!(ev.value, "$MFTMirr");
900
901        let cleared = ArtifactAnomaly::LogFileCleared;
902        let f = cleared.to_finding(src());
903        assert_eq!(f.code, "NTFS-LOGFILE-CLEARED");
904        assert!(!f.evidence.is_empty());
905    }
906
907    #[test]
908    fn mismatched_names_handles_out_of_range_index() {
909        // The field is public, so guard the name lookup defensively.
910        assert_eq!(mismatched_names(&[99]), "?");
911    }
912}