From 96086e9c1fe17898b313bff073b554ec37a2cd71 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Date: Wed, 11 Nov 2026 03:24:37 +0000 Subject: [PATCH] feat(ntfs): report bounded V4 extents and unknown history coverage Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- src/domain/windows/ntfsJournalQuery.ts | 30 ++++++-- .../windows/ntfsJournalTimeline.test.ts | 1 + src/domain/windows/ntfsJournalTimeline.ts | 35 +++++++-- src/domain/windows/ntfsUsnJournal.test.ts | 51 +++++++++++- src/domain/windows/ntfsUsnJournal.ts | 77 +++++++++++++++++-- tests/boundary/mcp/ntfsJournal.test.ts | 26 ++++++- 6 files changed, 200 insertions(+), 20 deletions(-) diff --git a/src/domain/windows/ntfsJournalQuery.ts b/src/domain/windows/ntfsJournalQuery.ts index 080b21bab5..01b10f6cf4 100644 --- a/src/domain/windows/ntfsJournalQuery.ts +++ b/src/domain/windows/ntfsJournalQuery.ts @@ -1,3 +1,4 @@ +import { USN_INLINE_EXTENT_LIMIT } from "./ntfsUsnJournal.js"; import { z } from "zod"; import { digestSchema } from "../digests.js"; @@ -76,25 +77,44 @@ export const ntfsJournalResultSchema = z.strictObject({ z.strictObject({ kind: z.literal("event"), event: z.strictObject({ - majorVersion: z.union([z.literal(2), z.literal(3)]), + majorVersion: z.union([z.literal(2), z.literal(3), z.literal(4)]), referenceBytes: z.union([z.literal(8), z.literal(16)]), fileOffset: offset, recordBytes: offset, fileReference: unsignedDecimal, parentReference: unsignedDecimal, usn: signedDecimal, - timestampFileTime: unsignedDecimal, + timestampFileTime: unsignedDecimal.nullable(), reasonFlags: offset, sourceFlags: offset, - securityId: offset, - fileAttributes: offset, - name: z.string(), + securityId: offset.nullable(), + fileAttributes: offset.nullable(), + name: z.string().nullable(), + remainingExtents: offset.exactOptional(), + extentSize: offset.exactOptional(), + extentCount: offset.exactOptional(), + omittedExtents: offset.exactOptional(), + extentSample: z + .array( + z.strictObject({ + fileOffset: offset, + offset: signedDecimal, + length: signedDecimal, + }), + ) + .max(USN_INLINE_EXTENT_LIMIT) + .exactOptional(), mft: z.strictObject({ file: matchSchema, parent: matchSchema }), }), }), z.strictObject({ kind: z.literal("coverage"), issue: issueSchema }), ]), ), + historyCompleteness: z + .literal("unknown") + .describe( + "Acquired bytes alone do not establish absent history, journal wrap, or missing captures.", + ), nextFileOffset: offset.nullable(), mftLimitations: z.array(z.string()), }); diff --git a/src/domain/windows/ntfsJournalTimeline.test.ts b/src/domain/windows/ntfsJournalTimeline.test.ts index 2a90005e9c..f20f8ec0e8 100644 --- a/src/domain/windows/ntfsJournalTimeline.test.ts +++ b/src/domain/windows/ntfsJournalTimeline.test.ts @@ -35,6 +35,7 @@ it("keeps exact USN values and coverage rows on bounded source-order pages", () rows: [ { kind: "coverage", issue: { kind: "padding", fileOffset: 0, bytes: 8 } }, ], + historyCompleteness: "unknown", nextFileOffset: 8, mftLimitations: [], }); diff --git a/src/domain/windows/ntfsJournalTimeline.ts b/src/domain/windows/ntfsJournalTimeline.ts index 3b70e4a592..344ec8a85f 100644 --- a/src/domain/windows/ntfsJournalTimeline.ts +++ b/src/domain/windows/ntfsJournalTimeline.ts @@ -11,12 +11,21 @@ import { type JournalEvent = Omit< UsnRecord, - "fileReference" | "parentReference" | "usn" | "timestampFileTime" + | "fileReference" + | "parentReference" + | "usn" + | "timestampFileTime" + | "extentSample" > & { readonly fileReference: string; readonly parentReference: string; readonly usn: string; - readonly timestampFileTime: string; + readonly timestampFileTime: string | null; + readonly extentSample?: readonly { + readonly fileOffset: number; + readonly offset: string; + readonly length: string; + }[]; readonly mft: UsnMftMatches; }; @@ -26,6 +35,7 @@ type TimelineRow = export interface NtfsJournalTimeline { readonly rows: readonly TimelineRow[]; + readonly historyCompleteness: "unknown"; /** First source offset left out of the delivery window. */ readonly nextFileOffset: number | null; readonly mftLimitations: readonly string[]; @@ -55,18 +65,33 @@ export const composeNtfsJournalTimeline = ( } const entry = result.value; + const { extentSample, ...fields } = entry; rows.push({ kind: "event", event: { - ...entry, + ...fields, fileReference: entry.fileReference.toString(), parentReference: entry.parentReference.toString(), usn: entry.usn.toString(), - timestampFileTime: entry.timestampFileTime.toString(), + timestampFileTime: entry.timestampFileTime?.toString() ?? null, + ...(extentSample === undefined + ? {} + : { + extentSample: extentSample.map((extent) => ({ + fileOffset: extent.fileOffset, + offset: extent.offset.toString(), + length: extent.length.toString(), + })), + }), mft: correlate(entry), }, }); } - return { rows, nextFileOffset, mftLimitations: mft.limitations }; + return { + rows, + historyCompleteness: "unknown", + nextFileOffset, + mftLimitations: mft.limitations, + }; }; diff --git a/src/domain/windows/ntfsUsnJournal.test.ts b/src/domain/windows/ntfsUsnJournal.test.ts index 7698ca4415..75821a3f2d 100644 --- a/src/domain/windows/ntfsUsnJournal.test.ts +++ b/src/domain/windows/ntfsUsnJournal.test.ts @@ -46,7 +46,7 @@ it("preserves full references, flags, offsets and UTF-16 names after sparse padd it("uses a validated record length to skip unsupported and malformed records", () => { const unsupported = journalRecord(); - unsupported.writeUInt16LE(4, 4); + unsupported.writeUInt16LE(9, 4); const malformed = journalRecord(); malformed.writeUInt16LE(12, 56); const results = [ @@ -55,7 +55,7 @@ it("uses a validated record length to skip unsupported and malformed records", ( expect(results).toMatchObject([ { ok: false, - error: { kind: "unsupported", fileOffset: 0, major: 4, minor: 0 }, + error: { kind: "unsupported", fileOffset: 0, major: 9, minor: 0 }, }, { ok: false, error: { kind: "malformed", fileOffset: 72 } }, { ok: true, value: { fileOffset: 144 } }, @@ -99,3 +99,50 @@ it("decodes V3 opaque 128-bit identifiers without narrowing to MFT references", { ok: false, error: { kind: "malformed", fileOffset: 0 } }, ]); }); + +it("bounds V4 extent delivery while retaining full coverage counts and continuation state", () => { + const bytes = Buffer.alloc(64 + 17 * 24); + bytes.writeUInt32LE(bytes.length); + bytes.writeUInt16LE(4, 4); + bytes.writeUInt32LE(40, 56); + bytes.writeUInt16LE(17, 60); + bytes.writeUInt16LE(24, 62); + bytes.writeBigInt64LE(9007199254740993n, 64); + bytes.writeBigInt64LE(-1n, 72); + const [result] = readUsnJournal(bytes); + if (!result?.ok) throw new Error("Invalid V4 fixture"); + expect(result.value).toMatchObject({ + majorVersion: 4, + referenceBytes: 16, + name: null, + timestampFileTime: null, + extentCount: 17, + extentSize: 24, + remainingExtents: 40, + omittedExtents: 1, + }); + expect(result.value.extentSample).toHaveLength(16); + expect(result.value.extentSample?.[0]).toEqual({ + fileOffset: 64, + offset: 9007199254740993n, + length: -1n, + }); + expect(result.value.extentSample?.[15]?.fileOffset).toBe(424); +}); + +it.each([ + [15, 1], + [16, 65535], +])( + "refuses V4 stride %i / count %i before extent allocation", + (stride, count) => { + const bytes = Buffer.alloc(80); + bytes.writeUInt32LE(80); + bytes.writeUInt16LE(4, 4); + bytes.writeUInt16LE(count, 60); + bytes.writeUInt16LE(stride, 62); + expect([...readUsnJournal(bytes)]).toEqual([ + { ok: false, error: { kind: "malformed", fileOffset: 0 } }, + ]); + }, +); diff --git a/src/domain/windows/ntfsUsnJournal.ts b/src/domain/windows/ntfsUsnJournal.ts index e23f5fa0b1..02c24adf22 100644 --- a/src/domain/windows/ntfsUsnJournal.ts +++ b/src/domain/windows/ntfsUsnJournal.ts @@ -1,21 +1,32 @@ import { err, ok, type Result } from "../result.js"; const COMMON_HEADER_BYTES = 8; +/** Bound extent objects in each inline event independently of source record length. */ +export const USN_INLINE_EXTENT_LIMIT = 16; export interface UsnRecord { - readonly majorVersion: 2 | 3; + readonly majorVersion: 2 | 3 | 4; readonly referenceBytes: 8 | 16; readonly fileOffset: number; readonly recordBytes: number; readonly fileReference: bigint; readonly parentReference: bigint; readonly usn: bigint; - readonly timestampFileTime: bigint; + readonly timestampFileTime: bigint | null; readonly reasonFlags: number; readonly sourceFlags: number; - readonly securityId: number; - readonly fileAttributes: number; - readonly name: string; + readonly securityId: number | null; + readonly fileAttributes: number | null; + readonly name: string | null; + readonly remainingExtents?: number; + readonly extentSize?: number; + readonly extentCount?: number; + readonly omittedExtents?: number; + readonly extentSample?: readonly { + readonly fileOffset: number; + readonly offset: bigint; + readonly length: bigint; + }[]; } export type UsnRecordIssue = @@ -66,8 +77,13 @@ export function* readUsnJournal( const major = bytes.readUInt16LE(fileOffset + 4); const minor = bytes.readUInt16LE(fileOffset + 6); - if ((major !== 2 && major !== 3) || minor !== 0) { + if ((major !== 2 && major !== 3 && major !== 4) || minor !== 0) { yield err({ kind: "unsupported", fileOffset, major, minor }); + } else if (major === 4) { + yield readExtentRecord( + bytes.subarray(fileOffset, fileOffset + length), + fileOffset, + ); } else { yield readNamedRecord( bytes.subarray(fileOffset, fileOffset + length), @@ -120,3 +136,52 @@ const readNamedRecord = ( name: record.toString("utf16le", nameOffset, nameOffset + nameBytes), }); }; + +const readExtentRecord = ( + record: Buffer, + fileOffset: number, +): Result => { + if (record.length < 64) return err({ kind: "malformed", fileOffset }); + const extentCount = record.readUInt16LE(60), + extentSize = record.readUInt16LE(62); + // Validate the complete declared array before allocating the fixed-size sample. + if ( + extentSize < 16 || + extentCount > Math.floor((record.length - 64) / extentSize) + ) + return err({ kind: "malformed", fileOffset }); + const extentSample: { fileOffset: number; offset: bigint; length: bigint }[] = + []; + for ( + let index = 0; + index < Math.min(extentCount, USN_INLINE_EXTENT_LIMIT); + index++ + ) { + const cursor = 64 + index * extentSize; + extentSample.push({ + fileOffset: fileOffset + cursor, + offset: record.readBigInt64LE(cursor), + length: record.readBigInt64LE(cursor + 8), + }); + } + return ok({ + majorVersion: 4, + referenceBytes: 16, + fileOffset, + recordBytes: record.length, + fileReference: readReference(record, 8, 16), + parentReference: readReference(record, 24, 16), + usn: record.readBigInt64LE(40), + reasonFlags: record.readUInt32LE(48), + sourceFlags: record.readUInt32LE(52), + timestampFileTime: null, + securityId: null, + fileAttributes: null, + name: null, + remainingExtents: record.readUInt32LE(56), + extentCount, + extentSize, + omittedExtents: extentCount - extentSample.length, + extentSample, + }); +}; diff --git a/tests/boundary/mcp/ntfsJournal.test.ts b/tests/boundary/mcp/ntfsJournal.test.ts index 408737e6a6..8da90f8094 100644 --- a/tests/boundary/mcp/ntfsJournal.test.ts +++ b/tests/boundary/mcp/ntfsJournal.test.ts @@ -47,8 +47,16 @@ mcpTest( v3.writeUInt16LE(4, 72); v3.writeUInt16LE(76, 74); v3.write("v3", 76, "utf16le"); + const v4 = Buffer.alloc(80); + v4.writeUInt32LE(80); + v4.writeUInt16LE(4, 4); + v4.writeUInt32LE(2, 56); + v4.writeUInt16LE(1, 60); + v4.writeUInt16LE(16, 62); + v4.writeBigInt64LE(9007199254740993n, 64); + v4.writeBigInt64LE(4096n, 72); await writeFile(mftPath, record); - await writeFile(journalPath, Buffer.concat([journal, v3])); + await writeFile(journalPath, Buffer.concat([journal, v3, v4])); const cli = await serveCli([ "inspect-ntfs-journal", @@ -61,7 +69,7 @@ mcpTest( const expected = ntfsJournalResultSchema.parse(fromCli.normalized_result); expect(expected).toMatchObject({ mft: { path: mftPath, bytes: 1024 }, - journal: { path: journalPath, bytes: 144 }, + journal: { path: journalPath, bytes: 224 }, rows: [ { kind: "event", @@ -82,7 +90,21 @@ mcpTest( }, }, }, + { + kind: "event", + event: { + majorVersion: 4, + name: null, + timestampFileTime: null, + remainingExtents: 2, + extentSample: [ + { fileOffset: 208, offset: "9007199254740993", length: "4096" }, + ], + omittedExtents: 0, + }, + }, ], + historyCompleteness: "unknown", nextFileOffset: null, });