diff --git a/src/domain/windows/ntfsJournalCorrelation.test.ts b/src/domain/windows/ntfsJournalCorrelation.test.ts index 701991d807..6ab22114b5 100644 --- a/src/domain/windows/ntfsJournalCorrelation.test.ts +++ b/src/domain/windows/ntfsJournalCorrelation.test.ts @@ -96,3 +96,17 @@ it("keeps duplicates ambiguous and skips extensions and unverified records", () }), ).toEqual({ file: { kind: "ambiguous" }, parent: { kind: "unresolved" } }); }); + +it("leaves opaque 128-bit IDs unresolved even when their numeric value resembles a 64-bit MFT reference", () => { + const correlate = createUsnMftCorrelator([baseRecord]); + expect( + correlate({ + fileReference: 0x0002_0000_0000_0007n, + parentReference: 0x0002_0000_0000_0007n, + referenceBytes: 16, + }), + ).toEqual({ + file: { kind: "unresolved" }, + parent: { kind: "unresolved" }, + }); +}); diff --git a/src/domain/windows/ntfsJournalCorrelation.ts b/src/domain/windows/ntfsJournalCorrelation.ts index 767add19e3..8f918b3e16 100644 --- a/src/domain/windows/ntfsJournalCorrelation.ts +++ b/src/domain/windows/ntfsJournalCorrelation.ts @@ -1,5 +1,5 @@ import { FIXUP_SECTOR_BYTES, type MftRecord } from "./ntfsMft.js"; -import type { UsnRecordV2 } from "./ntfsUsnJournal.js"; +import type { UsnRecord } from "./ntfsUsnJournal.js"; type RecordIdentity = Pick< MftRecord, @@ -16,7 +16,10 @@ type CorrelatableRecord = RecordIdentity & | "totalBytes" | "extentValid" >; -type JournalReferences = Pick; +type JournalReferences = Pick< + UsnRecord, + "fileReference" | "parentReference" +> & { readonly referenceBytes?: 8 | 16 }; export type MftJournalMatch = | { readonly kind: "matched"; readonly record: RecordIdentity } @@ -65,8 +68,11 @@ export const createUsnMftCorrelator = ( return { kind: "matched", record }; }; - return (entry) => ({ - file: lookup(entry.fileReference), - parent: lookup(entry.parentReference), - }); + return (entry) => + entry.referenceBytes === 16 + ? { file: { kind: "unresolved" }, parent: { kind: "unresolved" } } + : { + file: lookup(entry.fileReference), + parent: lookup(entry.parentReference), + }; }; diff --git a/src/domain/windows/ntfsJournalQuery.ts b/src/domain/windows/ntfsJournalQuery.ts index 51cf5ac5f1..080b21bab5 100644 --- a/src/domain/windows/ntfsJournalQuery.ts +++ b/src/domain/windows/ntfsJournalQuery.ts @@ -76,6 +76,8 @@ export const ntfsJournalResultSchema = z.strictObject({ z.strictObject({ kind: z.literal("event"), event: z.strictObject({ + majorVersion: z.union([z.literal(2), z.literal(3)]), + referenceBytes: z.union([z.literal(8), z.literal(16)]), fileOffset: offset, recordBytes: offset, fileReference: unsignedDecimal, diff --git a/src/domain/windows/ntfsJournalTimeline.ts b/src/domain/windows/ntfsJournalTimeline.ts index dad39b6b39..3b70e4a592 100644 --- a/src/domain/windows/ntfsJournalTimeline.ts +++ b/src/domain/windows/ntfsJournalTimeline.ts @@ -6,11 +6,11 @@ import { import { readUsnJournal, type UsnRecordIssue, - type UsnRecordV2, + type UsnRecord, } from "./ntfsUsnJournal.js"; type JournalEvent = Omit< - UsnRecordV2, + UsnRecord, "fileReference" | "parentReference" | "usn" | "timestampFileTime" > & { readonly fileReference: string; diff --git a/src/domain/windows/ntfsUsnJournal.test.ts b/src/domain/windows/ntfsUsnJournal.test.ts index 8d40b6debe..7698ca4415 100644 --- a/src/domain/windows/ntfsUsnJournal.test.ts +++ b/src/domain/windows/ntfsUsnJournal.test.ts @@ -26,6 +26,8 @@ it("preserves full references, flags, offsets and UTF-16 names after sparse padd { ok: true, value: { + majorVersion: 2, + referenceBytes: 8, fileOffset: 16, recordBytes: 72, fileReference: 0xabcd_1234_5678_9abcn, @@ -65,3 +67,35 @@ it("stops at a truncated record", () => { { ok: false, error: { kind: "truncated", fileOffset: 0 } }, ]); }); + +it("decodes V3 opaque 128-bit identifiers without narrowing to MFT references", () => { + const bytes = Buffer.alloc(88); + bytes.writeUInt32LE(bytes.length); + bytes.writeUInt16LE(3, 4); + bytes.writeBigUInt64LE(0x0002_0000_0000_0007n, 8); + bytes.writeBigUInt64LE(0xfedcba9876543210n, 16); + bytes.writeBigUInt64LE(9007199254740993n, 24); + bytes.writeBigInt64LE(9007199254740993n, 40); + bytes.writeBigUInt64LE(133000000000000001n, 48); + bytes.writeUInt16LE(6, 72); + bytes.writeUInt16LE(76, 74); + bytes.write("new", 76, "utf16le"); + expect([...readUsnJournal(bytes)]).toMatchObject([ + { + ok: true, + value: { + majorVersion: 3, + referenceBytes: 16, + name: "new", + fileReference: 0xfedcba98765432100002000000000007n, + parentReference: 9007199254740993n, + usn: 9007199254740993n, + timestampFileTime: 133000000000000001n, + }, + }, + ]); + bytes.writeUInt16LE(60, 74); + 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 02bb09ec48..e23f5fa0b1 100644 --- a/src/domain/windows/ntfsUsnJournal.ts +++ b/src/domain/windows/ntfsUsnJournal.ts @@ -1,9 +1,10 @@ import { err, ok, type Result } from "../result.js"; const COMMON_HEADER_BYTES = 8; -const V2_HEADER_BYTES = 60; -export interface UsnRecordV2 { +export interface UsnRecord { + readonly majorVersion: 2 | 3; + readonly referenceBytes: 8 | 16; readonly fileOffset: number; readonly recordBytes: number; readonly fileReference: bigint; @@ -35,7 +36,7 @@ export type UsnRecordIssue = export function* readUsnJournal( bytes: Buffer, startFileOffset = 0, -): Generator> { +): Generator> { let fileOffset = startFileOffset; while (fileOffset + COMMON_HEADER_BYTES <= bytes.length) { const length = bytes.readUInt32LE(fileOffset); @@ -65,26 +66,38 @@ export function* readUsnJournal( const major = bytes.readUInt16LE(fileOffset + 4); const minor = bytes.readUInt16LE(fileOffset + 6); - if (major !== 2 || minor !== 0) { + if ((major !== 2 && major !== 3) || minor !== 0) { yield err({ kind: "unsupported", fileOffset, major, minor }); } else { - yield readV2(bytes.subarray(fileOffset, fileOffset + length), fileOffset); + yield readNamedRecord( + bytes.subarray(fileOffset, fileOffset + length), + fileOffset, + major, + ); } fileOffset += length; } if (fileOffset < bytes.length) yield err({ kind: "truncated", fileOffset }); } -const readV2 = ( +const readReference = (record: Buffer, offset: number, width: 8 | 16): bigint => + record.readBigUInt64LE(offset) | + (width === 16 ? record.readBigUInt64LE(offset + 8) << 64n : 0n); + +const readNamedRecord = ( record: Buffer, fileOffset: number, -): Result => { - if (record.length < V2_HEADER_BYTES) + majorVersion: 2 | 3, +): Result => { + const referenceBytes = majorVersion === 2 ? 8 : 16; + const body = COMMON_HEADER_BYTES + referenceBytes * 2; + const headerBytes = body + 36; + if (record.length < headerBytes) return err({ kind: "malformed", fileOffset }); - const nameBytes = record.readUInt16LE(56); - const nameOffset = record.readUInt16LE(58); + const nameBytes = record.readUInt16LE(body + 32); + const nameOffset = record.readUInt16LE(body + 34); if ( - nameOffset < V2_HEADER_BYTES || + nameOffset < headerBytes || nameOffset % 2 !== 0 || nameBytes % 2 !== 0 || nameOffset + nameBytes > record.length @@ -92,16 +105,18 @@ const readV2 = ( return err({ kind: "malformed", fileOffset }); return ok({ + majorVersion, + referenceBytes, fileOffset, recordBytes: record.length, - fileReference: record.readBigUInt64LE(8), - parentReference: record.readBigUInt64LE(16), - usn: record.readBigInt64LE(24), - timestampFileTime: record.readBigUInt64LE(32), - reasonFlags: record.readUInt32LE(40), - sourceFlags: record.readUInt32LE(44), - securityId: record.readUInt32LE(48), - fileAttributes: record.readUInt32LE(52), + fileReference: readReference(record, 8, referenceBytes), + parentReference: readReference(record, 8 + referenceBytes, referenceBytes), + usn: record.readBigInt64LE(body), + timestampFileTime: record.readBigUInt64LE(body + 8), + reasonFlags: record.readUInt32LE(body + 16), + sourceFlags: record.readUInt32LE(body + 20), + securityId: record.readUInt32LE(body + 24), + fileAttributes: record.readUInt32LE(body + 28), name: record.toString("utf16le", nameOffset, nameOffset + nameBytes), }); }; diff --git a/tests/boundary/mcp/ntfsJournal.test.ts b/tests/boundary/mcp/ntfsJournal.test.ts index d40facb563..408737e6a6 100644 --- a/tests/boundary/mcp/ntfsJournal.test.ts +++ b/tests/boundary/mcp/ntfsJournal.test.ts @@ -39,8 +39,16 @@ mcpTest( journal.writeUInt16LE(2, 4); journal.writeBigUInt64LE(0x0002_0000_0000_0000n, 8); journal.writeUInt16LE(60, 58); + const v3 = Buffer.alloc(80); + v3.writeUInt32LE(80); + v3.writeUInt16LE(3, 4); + v3.writeBigUInt64LE(0x0002_0000_0000_0000n, 8); + v3.writeBigInt64LE(9007199254740993n, 40); + v3.writeUInt16LE(4, 72); + v3.writeUInt16LE(76, 74); + v3.write("v3", 76, "utf16le"); await writeFile(mftPath, record); - await writeFile(journalPath, journal); + await writeFile(journalPath, Buffer.concat([journal, v3])); const cli = await serveCli([ "inspect-ntfs-journal", @@ -53,7 +61,7 @@ mcpTest( const expected = ntfsJournalResultSchema.parse(fromCli.normalized_result); expect(expected).toMatchObject({ mft: { path: mftPath, bytes: 1024 }, - journal: { path: journalPath, bytes: 64 }, + journal: { path: journalPath, bytes: 144 }, rows: [ { kind: "event", @@ -61,6 +69,19 @@ mcpTest( mft: { file: { kind: "matched", record: { sequenceNumber: 2 } } }, }, }, + { + kind: "event", + event: { + majorVersion: 3, + referenceBytes: 16, + name: "v3", + usn: "9007199254740993", + mft: { + file: { kind: "unresolved" }, + parent: { kind: "unresolved" }, + }, + }, + }, ], nextFileOffset: null, });