feat(ntfs): decode V3 journal identities without false MFT joins

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:13:09 +08:00
1 parent 699cb72c48
commit f8ed4d16a0
7 files changed
+121 -29

No files matched your search

@@ -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" },
});
});
+12 -6
View File
@@ -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<UsnRecordV2, "fileReference" | "parentReference">;
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),
};
};
+2
View File
@@ -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,
+2 -2
View File
@@ -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;
+34
View File
@@ -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 } },
]);
});
+34 -19
View File
@@ -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<Result<UsnRecordV2, UsnRecordIssue>> {
): Generator<Result<UsnRecord, UsnRecordIssue>> {
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<UsnRecordV2, UsnRecordIssue> => {
if (record.length < V2_HEADER_BYTES)
majorVersion: 2 | 3,
): Result<UsnRecord, UsnRecordIssue> => {
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),
});
};
+23 -2
View File
@@ -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,
});