feat(ntfs): report bounded V4 extents and unknown history coverage

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 f8ed4d16a0
commit 96086e9c1f
6 files changed
+200 -20

No files matched your search

+25 -5
View File
@@ -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()),
});
@@ -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: [],
});
+30 -5
View File
@@ -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,
};
};
+49 -2
View File
@@ -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 } },
]);
},
);
+71 -6
View File
@@ -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<UsnRecord, UsnRecordIssue> => {
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,
});
};
+24 -2
View File
@@ -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,
});