feat(ntfs): correlate USN entries by full MFT identity

Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
N0zoM1z0andgensokyo-dev authored and gensokyo-dev committed 2026-11-11 13:03:32 +08:00
1 parent 2be301f894
commit a44d457745
3 files changed
+132 -1

No files matched your search

@@ -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" } });
});
@@ -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<UsnRecordV2, "fileReference" | "parentReference">;
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<CorrelatableRecord>,
): ((entry: JournalReferences) => UsnMftMatches) => {
const byReference = new Map<bigint, RecordIdentity | null>();
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),
});
};
+1 -1
View File
@@ -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