mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 01:41:21 +00:00
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:
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" },
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } },
|
||||
]);
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user