mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 17:28:08 +00:00
refactor(ntfs): select metadata details from the retained identity index
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
1 parent
09f58bf044
commit
22a3ba263e
7 files changed
+164
-84
No files matched your search
@@ -127,6 +127,7 @@ it("retains both acquisitions through a failed journal close and cleanup retry",
|
||||
{
|
||||
mft_path: mftPath,
|
||||
journal_path: journalPath,
|
||||
mft_record_limit: 1,
|
||||
},
|
||||
resources,
|
||||
);
|
||||
@@ -137,7 +138,10 @@ it("retains both acquisitions through a failed journal close and cleanup retry",
|
||||
kind: "ntfs-journal-partial",
|
||||
mft: {
|
||||
sha256: createHash("sha256").update(mft).digest("hex"),
|
||||
metadata: { record_count: 1 },
|
||||
metadata: {
|
||||
record_count: 1,
|
||||
record_headers: [{ index: 0, attributeCoverage: "complete" }],
|
||||
},
|
||||
},
|
||||
journal_observation: {
|
||||
kind: "file-snapshot-hash",
|
||||
|
||||
@@ -41,7 +41,12 @@ import {
|
||||
ntfsJournalResultSchema,
|
||||
} from "../../domain/windows/ntfsJournalQuery.js";
|
||||
import { composeNtfsJournalTimeline } from "../../domain/windows/ntfsJournalTimeline.js";
|
||||
import { parseMftIndex, type MftIndex } from "../../domain/windows/ntfsMft.js";
|
||||
import {
|
||||
parseMftIndex,
|
||||
selectMftMetadata,
|
||||
type MftIndex,
|
||||
type MftMetadata,
|
||||
} from "../../domain/windows/ntfsMft.js";
|
||||
import { parseAnalysisInput } from "../AnalysisInput.js";
|
||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
|
||||
@@ -163,6 +168,7 @@ const retainCompletedMft = (
|
||||
mft: NtfsAcquisition,
|
||||
image: MftIndex,
|
||||
failure: AnalysisError,
|
||||
metadata: MftMetadata,
|
||||
) => {
|
||||
const prior =
|
||||
failure.partialObservation ??
|
||||
@@ -180,7 +186,7 @@ const retainCompletedMft = (
|
||||
path: input.mft_path,
|
||||
sha256: mft.snapshot.sha256,
|
||||
bytes: mft.snapshot.bytes.length,
|
||||
metadata: image.metadata,
|
||||
metadata,
|
||||
},
|
||||
journal_observation: prior ?? null,
|
||||
},
|
||||
@@ -267,7 +273,7 @@ const inspectAdmittedJournal = async (
|
||||
}
|
||||
const parsed =
|
||||
retained === undefined
|
||||
? parseMftIndex(mft.value.snapshot.bytes)
|
||||
? parseMftIndex(mft.value.snapshot.bytes, 0, 0)
|
||||
: ok(retained.image);
|
||||
if (!parsed.ok)
|
||||
return err(
|
||||
@@ -279,6 +285,12 @@ const inspectAdmittedJournal = async (
|
||||
}),
|
||||
);
|
||||
|
||||
const mftMetadata = selectMftMetadata(
|
||||
mft.value.snapshot.bytes,
|
||||
parsed.value,
|
||||
input.mft_record_start,
|
||||
input.mft_record_limit,
|
||||
);
|
||||
const journal = await readAcquisition(
|
||||
input.journal_path,
|
||||
"journal_path",
|
||||
@@ -288,11 +300,18 @@ const inspectAdmittedJournal = async (
|
||||
);
|
||||
if (!journal.ok) {
|
||||
if (!(journal.error instanceof AnalysisCancelledError)) cache.clear();
|
||||
return retainCompletedMft(input, mft.value, parsed.value, journal.error);
|
||||
return retainCompletedMft(
|
||||
input,
|
||||
mft.value,
|
||||
parsed.value,
|
||||
journal.error,
|
||||
mftMetadata,
|
||||
);
|
||||
}
|
||||
const prepared =
|
||||
retained ?? cache.retain(mft.value, journal.value, parsed.value);
|
||||
const result = ntfsJournalResultSchema.parse({
|
||||
mftMetadata,
|
||||
mft: {
|
||||
path: input.mft_path,
|
||||
bytes: mft.value.snapshot.bytes.length,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { composeNtfsJournalTimeline } from "./ntfsJournalTimeline.js";
|
||||
import { parseMftImage } from "./ntfsMft.js";
|
||||
import { parseMftIndex } from "./ntfsMft.js";
|
||||
|
||||
it("keeps exact USN values and coverage rows on bounded source-order pages", () => {
|
||||
const record = Buffer.alloc(1024);
|
||||
@@ -16,7 +16,7 @@ it("keeps exact USN values and coverage rows on bounded source-order pages", ()
|
||||
record.writeUInt32LE(64, 24);
|
||||
record.writeUInt32LE(1024, 28);
|
||||
record.writeUInt32LE(0xffff_ffff, 56);
|
||||
const mft = parseMftImage(record);
|
||||
const mft = parseMftIndex(record);
|
||||
if (!mft.ok) throw new Error(mft.error.message);
|
||||
|
||||
const journal = Buffer.alloc(72);
|
||||
@@ -36,7 +36,6 @@ it("keeps exact USN values and coverage rows on bounded source-order pages", ()
|
||||
{ kind: "coverage", issue: { kind: "padding", fileOffset: 0, bytes: 8 } },
|
||||
],
|
||||
historyCompleteness: "unknown",
|
||||
mftMetadata: mft.value.metadata,
|
||||
nextFileOffset: 8,
|
||||
mftLimitations: [],
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MftIndex, MftMetadata } from "./ntfsMft.js";
|
||||
import type { MftIndex } from "./ntfsMft.js";
|
||||
import {
|
||||
createUsnMftCorrelator,
|
||||
type UsnMftMatches,
|
||||
@@ -36,7 +36,6 @@ type TimelineRow =
|
||||
export interface NtfsJournalTimeline {
|
||||
readonly rows: readonly TimelineRow[];
|
||||
readonly historyCompleteness: "unknown";
|
||||
readonly mftMetadata: MftMetadata;
|
||||
/** First source offset left out of the delivery window. */
|
||||
readonly nextFileOffset: number | null;
|
||||
readonly mftLimitations: readonly string[];
|
||||
@@ -92,7 +91,6 @@ export const composeNtfsJournalTimeline = (
|
||||
return {
|
||||
rows,
|
||||
historyCompleteness: "unknown",
|
||||
mftMetadata: mft.metadata,
|
||||
nextFileOffset,
|
||||
mftLimitations: mft.limitations,
|
||||
};
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
MFT_DEFAULT_RECORD_BYTES,
|
||||
MFT_MAX_FILE_BYTES,
|
||||
createMftDecoder,
|
||||
parseMftImage,
|
||||
parseMftIndex,
|
||||
selectMftMetadata,
|
||||
} from "./ntfsMft.js";
|
||||
|
||||
interface RecordOptions {
|
||||
@@ -85,7 +85,7 @@ const streamOf = (...records: readonly Uint8Array[]): Uint8Array => {
|
||||
|
||||
describe("MFT header facts", () => {
|
||||
it("inventories a clean record's header and verified fixups", () => {
|
||||
const parsed = parseMftImage(streamOf(recordOf({ sequenceNumber: 5 })));
|
||||
const parsed = parseMftIndex(streamOf(recordOf({ sequenceNumber: 5 })));
|
||||
expect(parsed.ok).toBe(true);
|
||||
if (!parsed.ok) return;
|
||||
expect(parsed.value.metadata).toMatchObject({
|
||||
@@ -121,7 +121,7 @@ describe("MFT header facts", () => {
|
||||
});
|
||||
|
||||
it("identifies unrecognized flags without losing their source bits", () => {
|
||||
const parsed = parseMftImage(recordOf({ flags: 0x8001 }));
|
||||
const parsed = parseMftIndex(recordOf({ flags: 0x8001 }));
|
||||
if (!parsed.ok) throw new Error(parsed.error.message);
|
||||
expect(parsed.value.metadata.record_headers[0]?.flags).toEqual({
|
||||
raw: 0x8001,
|
||||
@@ -142,7 +142,7 @@ describe("MFT header facts", () => {
|
||||
(offset, sequenceNumber) => {
|
||||
const record = recordOf({ sequenceNumber });
|
||||
new DataView(record.buffer).setUint16(20, offset, true);
|
||||
const parsed = parseMftImage(record);
|
||||
const parsed = parseMftIndex(record);
|
||||
if (!parsed.ok) throw new Error(parsed.error.message);
|
||||
expect(parsed.value.records[0]?.extentValid).toBe(false);
|
||||
expect(parsed.value.limitations.join(" ")).toContain("inconsistent");
|
||||
@@ -154,7 +154,7 @@ describe("MFT header facts", () => {
|
||||
(sequenceNumber) => {
|
||||
const bytes = streamOf(recordOf(), recordOf({ sequenceNumber }));
|
||||
new DataView(bytes.buffer).setUint32(1024 + 44, 0, true);
|
||||
const parsed = parseMftImage(bytes);
|
||||
const parsed = parseMftIndex(bytes);
|
||||
if (!parsed.ok) throw new Error(parsed.error.message);
|
||||
expect(parsed.value.records[1]).toMatchObject({
|
||||
index: 1,
|
||||
@@ -174,7 +174,7 @@ describe("MFT header facts", () => {
|
||||
);
|
||||
|
||||
it("keeps the inline header window separate from the parsed record count", () => {
|
||||
const parsed = parseMftImage(
|
||||
const parsed = parseMftIndex(
|
||||
streamOf(...Array.from({ length: 33 }, () => recordOf())),
|
||||
);
|
||||
if (!parsed.ok) throw new Error(parsed.error.message);
|
||||
@@ -202,7 +202,7 @@ describe("MFT header facts", () => {
|
||||
it("preserves a log sequence number above the safe integer range", () => {
|
||||
const record = recordOf();
|
||||
new DataView(record.buffer).setBigUint64(8, 9007199254740993n, true);
|
||||
const parsed = parseMftImage(record);
|
||||
const parsed = parseMftIndex(record);
|
||||
if (!parsed.ok) throw new Error(parsed.error.message);
|
||||
expect(parsed.value.records[0]?.logFileSequenceNumber).toBe(
|
||||
"9007199254740993",
|
||||
@@ -214,7 +214,7 @@ describe("MFT header facts", () => {
|
||||
const newRecord = recordOf();
|
||||
new DataView(newRecord.buffer).setUint32(44, 1, true);
|
||||
|
||||
const parsed = parseMftImage(streamOf(oldRecord, newRecord));
|
||||
const parsed = parseMftIndex(streamOf(oldRecord, newRecord));
|
||||
if (!parsed.ok) throw new Error(parsed.error.message);
|
||||
expect(parsed.value.records).toMatchObject([
|
||||
{
|
||||
@@ -234,7 +234,7 @@ describe("MFT fixup coverage", () => {
|
||||
it.each([0, 0xffff])(
|
||||
"leaves reserved USA sequence %i unverified",
|
||||
(sequenceNumber) => {
|
||||
const parsed = parseMftImage(recordOf({ sequenceNumber }));
|
||||
const parsed = parseMftIndex(recordOf({ sequenceNumber }));
|
||||
if (!parsed.ok) throw new Error(parsed.error.message);
|
||||
expect(parsed.value.records[0]?.fixups).toMatchObject({
|
||||
sequenceNumber,
|
||||
@@ -269,9 +269,9 @@ describe("MFT resident names", () => {
|
||||
view.setUint32(520, 0xffff_ffff, true);
|
||||
const original = bytes.slice();
|
||||
|
||||
const parsed = parseMftImage(bytes);
|
||||
const parsed = parseMftIndex(bytes);
|
||||
if (!parsed.ok) throw new Error(parsed.error.message);
|
||||
expect(parsed.value.records[0]).toMatchObject({
|
||||
expect(parsed.value.metadata.record_headers[0]).toMatchObject({
|
||||
fileNameCoverage: "complete",
|
||||
fileNames: [
|
||||
{
|
||||
@@ -303,7 +303,7 @@ describe("MFT resident names", () => {
|
||||
}
|
||||
view.setUint32(264, 0xffffffff, true);
|
||||
view.setUint32(24, 272, true);
|
||||
const parsed = parseMftImage(bytes);
|
||||
const parsed = parseMftIndex(bytes);
|
||||
if (!parsed.ok) throw new Error(parsed.error.message);
|
||||
expect(parsed.value.metadata.record_headers[0]).toMatchObject({
|
||||
fileNameCoverage: "complete",
|
||||
@@ -329,7 +329,7 @@ describe("MFT resident names", () => {
|
||||
|
||||
describe("MFT fixup failures", () => {
|
||||
it("keeps a record whose sector trailer is not the sequence number, and reports it", () => {
|
||||
const parsed = parseMftImage(
|
||||
const parsed = parseMftIndex(
|
||||
streamOf(recordOf(), recordOf({ sequenceNumber: 9, corruptSector: 1 })),
|
||||
);
|
||||
expect(parsed.ok).toBe(true);
|
||||
@@ -346,7 +346,7 @@ describe("MFT fixup failures", () => {
|
||||
});
|
||||
|
||||
it("treats a missing update sequence array as unverified rather than clean", () => {
|
||||
const parsed = parseMftImage(streamOf(recordOf({ usaEntries: 0 })));
|
||||
const parsed = parseMftIndex(streamOf(recordOf({ usaEntries: 0 })));
|
||||
expect(parsed.ok).toBe(true);
|
||||
if (!parsed.ok) return;
|
||||
expect(parsed.value.records[0]?.fixups).toMatchObject({
|
||||
@@ -373,7 +373,7 @@ describe("MFT fixup failures", () => {
|
||||
view.setUint16(4, offset, true);
|
||||
view.setUint16(6, count, true);
|
||||
|
||||
const parsed = parseMftImage(bytes);
|
||||
const parsed = parseMftIndex(bytes);
|
||||
if (!parsed.ok) throw new Error(parsed.error.message);
|
||||
expect(parsed.value.records[0]?.fixups).toMatchObject({
|
||||
malformed: true,
|
||||
@@ -384,7 +384,7 @@ describe("MFT fixup failures", () => {
|
||||
);
|
||||
|
||||
it("counts uncovered sectors independently of a mismatched trailer", () => {
|
||||
const parsed = parseMftImage(
|
||||
const parsed = parseMftIndex(
|
||||
recordOf({ recordBytes: 4096, usaEntries: 3, corruptSector: 0 }),
|
||||
);
|
||||
if (!parsed.ok) throw new Error(parsed.error.message);
|
||||
@@ -399,7 +399,7 @@ describe("MFT fixup failures", () => {
|
||||
|
||||
it("reports an array that covers fewer sectors than the record has", () => {
|
||||
// A 4 KiB record holds eight sectors; a three-entry array leaves five unverified.
|
||||
const parsed = parseMftImage(
|
||||
const parsed = parseMftIndex(
|
||||
streamOf(recordOf({ recordBytes: 4096, usaEntries: 3 })),
|
||||
);
|
||||
expect(parsed.ok).toBe(true);
|
||||
@@ -416,7 +416,7 @@ describe("MFT fixup failures", () => {
|
||||
|
||||
describe("MFT record slots", () => {
|
||||
it("distinguishes extension records from base records", () => {
|
||||
const parsed = parseMftImage(
|
||||
const parsed = parseMftIndex(
|
||||
streamOf(
|
||||
recordOf(),
|
||||
recordOf({
|
||||
@@ -438,7 +438,7 @@ describe("MFT record slots", () => {
|
||||
});
|
||||
|
||||
it("names directory records without losing the in-use flag", () => {
|
||||
const parsed = parseMftImage(streamOf(recordOf({ flags: 0x0003 })));
|
||||
const parsed = parseMftIndex(streamOf(recordOf({ flags: 0x0003 })));
|
||||
expect(parsed.ok).toBe(true);
|
||||
if (!parsed.ok) return;
|
||||
expect(parsed.value.records[0]).toMatchObject({
|
||||
@@ -457,7 +457,7 @@ describe("MFT record slots", () => {
|
||||
recordOf(),
|
||||
);
|
||||
stream.set(Buffer.from("BAAD", "latin1"), MFT_DEFAULT_RECORD_BYTES);
|
||||
const parsed = parseMftImage(stream);
|
||||
const parsed = parseMftIndex(stream);
|
||||
expect(parsed.ok).toBe(true);
|
||||
if (!parsed.ok) return;
|
||||
expect(parsed.value.metadata.record_count).toBe(2);
|
||||
@@ -475,7 +475,7 @@ describe("MFT record slots", () => {
|
||||
it("keeps a trailing partial record out of the inventory", () => {
|
||||
const stream = new Uint8Array(1024 + 300);
|
||||
stream.set(recordOf(), 0);
|
||||
const parsed = parseMftImage(stream);
|
||||
const parsed = parseMftIndex(stream);
|
||||
expect(parsed.ok).toBe(true);
|
||||
if (!parsed.ok) return;
|
||||
expect(parsed.value.metadata.record_count).toBe(1);
|
||||
@@ -488,7 +488,7 @@ describe("MFT record slots", () => {
|
||||
it("falls back to the default record size when record 0 declares none", () => {
|
||||
const record = recordOf();
|
||||
new DataView(record.buffer).setUint32(28, 0, true);
|
||||
const parsed = parseMftImage(record);
|
||||
const parsed = parseMftIndex(record);
|
||||
expect(parsed.ok).toBe(true);
|
||||
if (!parsed.ok) return;
|
||||
expect(parsed.value.recordBytes).toBe(MFT_DEFAULT_RECORD_BYTES);
|
||||
@@ -498,7 +498,7 @@ describe("MFT record slots", () => {
|
||||
});
|
||||
|
||||
it("reads a 4 KiB record at its declared size", () => {
|
||||
const parsed = parseMftImage(streamOf(recordOf({ recordBytes: 4096 })));
|
||||
const parsed = parseMftIndex(streamOf(recordOf({ recordBytes: 4096 })));
|
||||
expect(parsed.ok).toBe(true);
|
||||
if (!parsed.ok) return;
|
||||
expect(parsed.value.recordBytes).toBe(4096);
|
||||
@@ -506,9 +506,9 @@ describe("MFT record slots", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMftImage refusals and limits", () => {
|
||||
describe("parseMftIndex refusals and limits", () => {
|
||||
it("refuses a file shorter than one record header", () => {
|
||||
const parsed = parseMftImage(new Uint8Array(32));
|
||||
const parsed = parseMftIndex(new Uint8Array(32));
|
||||
expect(parsed.ok).toBe(false);
|
||||
if (parsed.ok) return;
|
||||
expect(parsed.error.message).toMatch(
|
||||
@@ -517,7 +517,7 @@ describe("parseMftImage refusals and limits", () => {
|
||||
});
|
||||
|
||||
it("refuses bytes that do not start with the FILE signature", () => {
|
||||
const parsed = parseMftImage(Buffer.alloc(2048, 0x41));
|
||||
const parsed = parseMftIndex(Buffer.alloc(2048, 0x41));
|
||||
expect(parsed.ok).toBe(false);
|
||||
if (parsed.ok) return;
|
||||
expect(parsed.error.message).toMatch(
|
||||
@@ -562,7 +562,7 @@ describe("parseMftImage refusals and limits", () => {
|
||||
it("returns the same inventory without retaining all the decoded records", () => {
|
||||
const bytes = streamOf(...Array.from({ length: 33 }, () => recordOf()));
|
||||
const decoder = createMftDecoder(bytes.length);
|
||||
const parsed = parseMftImage(bytes);
|
||||
const parsed = parseMftIndex(bytes);
|
||||
if (!decoder.ok || !parsed.ok) throw new Error("invalid fixture");
|
||||
expect(decoder.value.write(bytes).ok).toBe(true);
|
||||
const inventory = decoder.value.finish();
|
||||
@@ -570,12 +570,9 @@ describe("parseMftImage refusals and limits", () => {
|
||||
expect(inventory.value).not.toHaveProperty("records");
|
||||
expect(inventory.value.metadata).toEqual(parsed.value.metadata);
|
||||
expect(inventory.value.limitations).toEqual(parsed.value.limitations);
|
||||
const indexed = parseMftIndex(bytes);
|
||||
if (!indexed.ok) throw new Error(indexed.error.message);
|
||||
expect(indexed.value.metadata).toEqual(parsed.value.metadata);
|
||||
expect(indexed.value.records).toHaveLength(33);
|
||||
expect(indexed.value.records[32]).not.toHaveProperty("attributes");
|
||||
expect(indexed.value.records[32]).not.toHaveProperty("fileNames");
|
||||
expect(parsed.value.records).toHaveLength(33);
|
||||
expect(parsed.value.records[32]).not.toHaveProperty("attributes");
|
||||
expect(parsed.value.records[32]).not.toHaveProperty("fileNames");
|
||||
});
|
||||
|
||||
it("decodes the same stream through the chunked decoder", () => {
|
||||
@@ -593,3 +590,46 @@ describe("parseMftImage refusals and limits", () => {
|
||||
expect(image.value.metadata.directory_record_count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("selects bounded details by source slot from the existing index, including sparse slots", () => {
|
||||
const bytes = streamOf(
|
||||
...Array.from({ length: 40 }, (_, slot) =>
|
||||
slot === 33 ? new Uint8Array(1024) : recordOf(),
|
||||
),
|
||||
);
|
||||
const original = bytes.slice();
|
||||
const indexed = parseMftIndex(bytes, 0, 0);
|
||||
if (!indexed.ok) throw new Error(indexed.error.message);
|
||||
expect(indexed.value.records).toHaveLength(39);
|
||||
expect(selectMftMetadata(bytes, indexed.value, 0, 0)).toMatchObject({
|
||||
record_count: 39,
|
||||
record_headers: [],
|
||||
omitted_record_headers: 39,
|
||||
next_record_header_index: 0,
|
||||
});
|
||||
const page = selectMftMetadata(bytes, indexed.value, 32, 2);
|
||||
expect(
|
||||
page.record_headers.map(({ index, fileOffset }) => [index, fileOffset]),
|
||||
).toEqual([
|
||||
[32, 32768],
|
||||
[34, 34816],
|
||||
]);
|
||||
expect(page).toMatchObject({
|
||||
omitted_record_headers: 37,
|
||||
record_header_start_index: 32,
|
||||
next_record_header_index: 35,
|
||||
});
|
||||
expect(
|
||||
selectMftMetadata(bytes, indexed.value, 33, 1).record_headers[0]?.index,
|
||||
).toBe(34);
|
||||
expect(selectMftMetadata(bytes, indexed.value, 39, 32)).toMatchObject({
|
||||
record_headers: [{ index: 39 }],
|
||||
next_record_header_index: null,
|
||||
});
|
||||
expect(selectMftMetadata(bytes, indexed.value, 40, 1)).toMatchObject({
|
||||
record_headers: [],
|
||||
next_record_header_index: null,
|
||||
});
|
||||
expect(bytes).toEqual(original);
|
||||
expect(indexed.value.metadata.record_headers).toEqual([]);
|
||||
});
|
||||
@@ -124,9 +124,6 @@ export type MftRecordHeader = Omit<MftRecord, keyof MftAttributeScan>;
|
||||
export interface MftIndex extends MftInventory {
|
||||
readonly records: readonly MftRecordHeader[];
|
||||
}
|
||||
export interface MftImage extends MftIndex {
|
||||
readonly records: readonly MftRecord[];
|
||||
}
|
||||
|
||||
export interface MftMetadata {
|
||||
readonly kind: "ntfs-mft";
|
||||
@@ -216,18 +213,6 @@ export const hasMftFileRecordHeader = (prefix: Uint8Array): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse FILE record headers and resident file names. A record with failed
|
||||
* fixups keeps its header facts; its attributes are left unread.
|
||||
*/
|
||||
export const parseMftImage = (
|
||||
bytes: Uint8Array,
|
||||
): Result<MftImage, RegionDecodeFailure> => {
|
||||
const records: MftRecord[] = [];
|
||||
const inventory = scanMft(bytes, (record) => records.push(record));
|
||||
return inventory.ok ? ok({ ...inventory.value, records }) : inventory;
|
||||
};
|
||||
|
||||
/** Retain only identity/header facts outside the fixed inline detail window. */
|
||||
export const parseMftIndex = (
|
||||
bytes: Uint8Array,
|
||||
@@ -250,6 +235,41 @@ export const parseMftIndex = (
|
||||
return inventory.ok ? ok({ ...inventory.value, records }) : inventory;
|
||||
};
|
||||
|
||||
/** Select bounded record details from the owning index and its unchanged acquisition. */
|
||||
export const selectMftMetadata = (
|
||||
bytes: Uint8Array,
|
||||
index: MftIndex,
|
||||
startRecord: number,
|
||||
recordLimit: number,
|
||||
): MftMetadata => {
|
||||
let low = 0;
|
||||
let high = index.records.length;
|
||||
while (low < high) {
|
||||
const middle = low + Math.floor((high - low) / 2);
|
||||
if (index.records[middle]!.index < startRecord) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
const end = Math.min(
|
||||
index.records.length,
|
||||
low + Math.min(recordLimit, MFT_INLINE_HEADER_LIMIT),
|
||||
);
|
||||
const recordHeaders: MftRecord[] = [];
|
||||
if (end > low) {
|
||||
const restored = Buffer.allocUnsafe(index.recordBytes);
|
||||
for (let position = low; position < end; position += 1)
|
||||
recordHeaders.push(
|
||||
readRecord(bytes, index.records[position]!.fileOffset, restored),
|
||||
);
|
||||
}
|
||||
return {
|
||||
...index.metadata,
|
||||
record_headers: recordHeaders,
|
||||
omitted_record_headers: index.metadata.record_count - recordHeaders.length,
|
||||
record_header_start_index: startRecord,
|
||||
next_record_header_index: index.records[end]?.index ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const scanMft = (
|
||||
bytes: Uint8Array,
|
||||
retainRecord?: (record: MftRecord) => void,
|
||||
@@ -431,8 +451,7 @@ const readRecord = (
|
||||
firstAttributeOffset % 8 === 0 &&
|
||||
firstAttributeOffset >= NTFS_MFT_MIN_RECORD_BYTES &&
|
||||
firstAttributeOffset + 4 <= usedBytes &&
|
||||
(!usaLayoutValid ||
|
||||
firstAttributeOffset >= usaOffset + fixups.entries * 2);
|
||||
(!usaLayoutValid || firstAttributeOffset >= usaOffset + fixups.entries * 2);
|
||||
let attributes: MftAttributeScan = {
|
||||
attributeIssue: null,
|
||||
fileNames: [],
|
||||
|
||||
@@ -123,6 +123,30 @@ mcpTest(
|
||||
nextFileOffset: null,
|
||||
});
|
||||
|
||||
const pageCli = await serveCli([
|
||||
"inspect-ntfs-journal",
|
||||
mftPath,
|
||||
journalPath,
|
||||
"--mftRecordStart=32",
|
||||
"--mftRecordLimit=1",
|
||||
"--startFileOffset=224",
|
||||
"--json",
|
||||
]);
|
||||
expect(pageCli.exitCode, pageCli.stdout).toBe(0);
|
||||
const page = ntfsJournalResultSchema.parse(
|
||||
parseEvidence(JSON.parse(pageCli.stdout)).normalized_result,
|
||||
);
|
||||
expect(page).toMatchObject({
|
||||
rows: [],
|
||||
mftMetadata: {
|
||||
record_count: 33,
|
||||
record_header_start_index: 32,
|
||||
next_record_header_index: null,
|
||||
record_headers: [{ index: 32, sequenceNumber: 2 }],
|
||||
},
|
||||
});
|
||||
expect(page.mft.sha256).toBe(expected.mft.sha256);
|
||||
|
||||
const reads = vi.spyOn(stableArtifact, "readStableArtifact");
|
||||
const parses = vi.spyOn(mftDecoder, "parseMftIndex");
|
||||
onTestFinished(() => {
|
||||
@@ -147,29 +171,6 @@ mcpTest(
|
||||
expected,
|
||||
);
|
||||
|
||||
const pageCli = await serveCli([
|
||||
"inspect-ntfs-journal",
|
||||
mftPath,
|
||||
journalPath,
|
||||
"--mftRecordStart=32",
|
||||
"--mftRecordLimit=1",
|
||||
"--startFileOffset=224",
|
||||
"--json",
|
||||
]);
|
||||
expect(pageCli.exitCode, pageCli.stdout).toBe(0);
|
||||
const page = ntfsJournalResultSchema.parse(
|
||||
parseEvidence(JSON.parse(pageCli.stdout)).normalized_result,
|
||||
);
|
||||
expect(page).toMatchObject({
|
||||
rows: [],
|
||||
mftMetadata: {
|
||||
record_count: 33,
|
||||
record_header_start_index: 32,
|
||||
next_record_header_index: null,
|
||||
record_headers: [{ index: 32, sequenceNumber: 2 }],
|
||||
},
|
||||
});
|
||||
expect(page.mft.sha256).toBe(expected.mft.sha256);
|
||||
const pageMcp = await client.callTool({
|
||||
name: "inspect_ntfs_journal",
|
||||
arguments: {
|
||||
|
||||
Reference in new issue
Block a user