From c0b3c18b8a959ea39c73d10f2cd8fddc43e7dc14 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:35:43 +0000 Subject: [PATCH] feat(ntfs): page correlated journal observations by source offset Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- .../windows/ntfsJournalTimeline.test.ts | 60 +++++++++++++++ src/domain/windows/ntfsJournalTimeline.ts | 73 +++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 src/domain/windows/ntfsJournalTimeline.test.ts create mode 100644 src/domain/windows/ntfsJournalTimeline.ts diff --git a/src/domain/windows/ntfsJournalTimeline.test.ts b/src/domain/windows/ntfsJournalTimeline.test.ts new file mode 100644 index 0000000000..76998de04e --- /dev/null +++ b/src/domain/windows/ntfsJournalTimeline.test.ts @@ -0,0 +1,60 @@ +import { expect, it } from "vitest"; + +import { composeNtfsJournalTimeline } from "./ntfsJournalTimeline.js"; +import { parseMftImage } from "./ntfsMft.js"; + +it("keeps exact USN values and coverage rows on bounded source-order pages", () => { + const record = Buffer.alloc(1024); + record.write("FILE"); + record.writeUInt16LE(48, 4); + record.writeUInt16LE(3, 6); + record.writeUInt16LE(2, 16); + record.writeUInt16LE(56, 20); + record.writeUInt32LE(64, 24); + record.writeUInt32LE(1024, 28); + record.writeUInt32LE(0xffff_ffff, 56); + const mft = parseMftImage(record); + if (!mft.ok) throw new Error(mft.error.message); + + const journal = Buffer.alloc(72); + journal.writeUInt32LE(64, 8); + journal.writeUInt16LE(2, 12); + journal.writeBigUInt64LE(0x0002_0000_0000_0000n, 16); + journal.writeBigInt64LE(9007199254740993n, 32); + journal.writeBigUInt64LE(133000000000000001n, 40); + journal.writeUInt16LE(60, 66); + + const first = composeNtfsJournalTimeline(mft.value, journal, { + startFileOffset: 0, + maxRows: 1, + }); + expect(first).toEqual({ + rows: [ + { kind: "coverage", issue: { kind: "padding", fileOffset: 0, bytes: 8 } }, + ], + nextFileOffset: 8, + mftLimitations: [], + }); + + const second = composeNtfsJournalTimeline(mft.value, journal, { + startFileOffset: first.nextFileOffset ?? 0, + maxRows: 1, + }); + expect(second).toMatchObject({ + rows: [ + { + kind: "event", + event: { + fileOffset: 8, + usn: "9007199254740993", + timestampFileTime: "133000000000000001", + mft: { + file: { kind: "matched", record: { index: 0, sequenceNumber: 2 } }, + parent: { kind: "unresolved" }, + }, + }, + }, + ], + nextFileOffset: null, + }); +}); diff --git a/src/domain/windows/ntfsJournalTimeline.ts b/src/domain/windows/ntfsJournalTimeline.ts new file mode 100644 index 0000000000..0bb2898457 --- /dev/null +++ b/src/domain/windows/ntfsJournalTimeline.ts @@ -0,0 +1,73 @@ +import type { MftImage } from "./ntfsMft.js"; +import { + createUsnMftCorrelator, + type UsnMftMatches, +} from "./ntfsJournalCorrelation.js"; +import { + readUsnJournal, + type UsnRecordIssue, + type UsnRecordV2, +} from "./ntfsUsnJournal.js"; + +type JournalEvent = Omit< + UsnRecordV2, + "fileReference" | "parentReference" | "usn" | "timestampFileTime" +> & { + readonly fileReference: string; + readonly parentReference: string; + readonly usn: string; + readonly timestampFileTime: string; + readonly mft: UsnMftMatches; +}; + +type TimelineRow = + | { readonly kind: "event"; readonly event: JournalEvent } + | { readonly kind: "coverage"; readonly issue: UsnRecordIssue }; + +export interface NtfsJournalTimeline { + readonly rows: readonly TimelineRow[]; + /** First source offset left out of the delivery window. */ + readonly nextFileOffset: number | null; + readonly mftLimitations: readonly string[]; +} + +/** Compose a bounded source-order page for an explicitly paired acquisition. */ +export const composeNtfsJournalTimeline = ( + mft: MftImage, + journal: Buffer, + window: { readonly startFileOffset: number; readonly maxRows: number }, +): NtfsJournalTimeline => { + const rows: TimelineRow[] = []; + const correlate = createUsnMftCorrelator(mft.records); + let nextFileOffset: number | null = null; + + for (const result of readUsnJournal(journal)) { + const fileOffset = result.ok + ? result.value.fileOffset + : result.error.fileOffset; + if (fileOffset < window.startFileOffset) continue; + if (rows.length >= window.maxRows) { + nextFileOffset = fileOffset; + break; + } + if (!result.ok) { + rows.push({ kind: "coverage", issue: result.error }); + continue; + } + + const entry = result.value; + rows.push({ + kind: "event", + event: { + ...entry, + fileReference: entry.fileReference.toString(), + parentReference: entry.parentReference.toString(), + usn: entry.usn.toString(), + timestampFileTime: entry.timestampFileTime.toString(), + mft: correlate(entry), + }, + }); + } + + return { rows, nextFileOffset, mftLimitations: mft.limitations }; +};