mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 17:28:08 +00:00
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:
1 parent
f8ed4d16a0
commit
96086e9c1f
6 files changed
+200
-20
No files matched your search
@@ -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: [],
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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 } },
|
||||
]);
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user