diff --git a/src/domain/windows/ntfsJournalCorrelation.test.ts b/src/domain/windows/ntfsJournalCorrelation.test.ts new file mode 100644 index 0000000000..eb45dac66d --- /dev/null +++ b/src/domain/windows/ntfsJournalCorrelation.test.ts @@ -0,0 +1,61 @@ +import { expect, it } from "vitest"; + +import { createUsnMftCorrelator } from "./ntfsJournalCorrelation.js"; + +const baseRecord = { + index: 7, + sequenceNumber: 2, + fileOffset: 7168, + totalBytes: 1024, + baseRecordReference: null, + declaredRecordNumber: 7, + fixups: { + sequenceNumber: 5, + entries: 3, + verifiedSectors: 2, + coveredSectors: 2, + mismatchSector: null, + malformed: false, + }, +}; + +it("keeps a reused record number unresolved while resolving a full parent reference", () => { + const correlate = createUsnMftCorrelator([baseRecord]); + expect( + correlate({ + fileReference: 0x0001_0000_0000_0007n, + parentReference: 0x0002_0000_0000_0007n, + }), + ).toEqual({ + file: { kind: "unresolved" }, + parent: { + kind: "matched", + record: { index: 7, sequenceNumber: 2, fileOffset: 7168 }, + }, + }); +}); + +it("keeps duplicates ambiguous and skips extensions and unverified records", () => { + const correlate = createUsnMftCorrelator([ + baseRecord, + { ...baseRecord, fileOffset: 8192 }, + { + ...baseRecord, + index: 9, + declaredRecordNumber: 9, + baseRecordReference: { recordNumber: 7, sequenceNumber: 2 }, + }, + { + ...baseRecord, + index: 9, + declaredRecordNumber: 9, + fixups: { ...baseRecord.fixups, verifiedSectors: 1, mismatchSector: 1 }, + }, + ]); + expect( + correlate({ + fileReference: 0x0002_0000_0000_0007n, + parentReference: 0x0002_0000_0000_0009n, + }), + ).toEqual({ file: { kind: "ambiguous" }, parent: { kind: "unresolved" } }); +}); diff --git a/src/domain/windows/ntfsJournalCorrelation.ts b/src/domain/windows/ntfsJournalCorrelation.ts new file mode 100644 index 0000000000..9664a7b50e --- /dev/null +++ b/src/domain/windows/ntfsJournalCorrelation.ts @@ -0,0 +1,70 @@ +import { FIXUP_SECTOR_BYTES, type MftRecord } from "./ntfsMft.js"; +import type { UsnRecordV2 } from "./ntfsUsnJournal.js"; + +type RecordIdentity = Pick< + MftRecord, + "index" | "sequenceNumber" | "fileOffset" +>; + +type CorrelatableRecord = RecordIdentity & + Pick< + MftRecord, + "baseRecordReference" | "declaredRecordNumber" | "fixups" | "totalBytes" + >; +type JournalReferences = Pick; + +export type MftJournalMatch = + | { readonly kind: "matched"; readonly record: RecordIdentity } + | { readonly kind: "unresolved" | "ambiguous" }; + +export interface UsnMftMatches { + readonly file: MftJournalMatch; + readonly parent: MftJournalMatch; +} + +/** Build one lookup for a selected MFT, then reuse it for each journal entry. */ +export const createUsnMftCorrelator = ( + records: Iterable, +): ((entry: JournalReferences) => UsnMftMatches) => { + const byReference = new Map(); + for (const record of records) { + // The stream ordinal is the segment number in an acquisition of the + // logical MFT stream. Extension records do not identify separate files. + if ( + record.baseRecordReference !== null || + record.fixups.verifiedSectors * FIXUP_SECTOR_BYTES !== record.totalBytes + ) + continue; + if ( + record.declaredRecordNumber !== null && + record.declaredRecordNumber !== 0 && + record.declaredRecordNumber !== record.index + ) + continue; + + const reference = + (BigInt(record.sequenceNumber) << 48n) | BigInt(record.index); + byReference.set( + reference, + byReference.has(reference) + ? null + : { + index: record.index, + sequenceNumber: record.sequenceNumber, + fileOffset: record.fileOffset, + }, + ); + } + + const lookup = (reference: bigint): MftJournalMatch => { + const record = byReference.get(reference); + if (record === undefined) return { kind: "unresolved" }; + if (record === null) return { kind: "ambiguous" }; + return { kind: "matched", record }; + }; + + return (entry) => ({ + file: lookup(entry.fileReference), + parent: lookup(entry.parentReference), + }); +}; diff --git a/src/domain/windows/ntfsMft.ts b/src/domain/windows/ntfsMft.ts index cec24d8054..507bf097ed 100644 --- a/src/domain/windows/ntfsMft.ts +++ b/src/domain/windows/ntfsMft.ts @@ -22,7 +22,7 @@ const MFT_MAX_RECORD_BYTES = 65536; /** An update sequence array holds at least the sequence number and one fixup word. */ const USA_MIN_ENTRIES = 2; /** NTFS fixes up one 512-byte sector at a time. */ -const FIXUP_SECTOR_BYTES = 512; +export const FIXUP_SECTOR_BYTES = 512; /** * Fixed whole-file ceiling. Records are inventoried from their headers only; * attributes and their payloads are not decoded, so this is deliberately