diff --git a/src/application/ntfs/NtfsJournalService.test.ts b/src/application/ntfs/NtfsJournalService.test.ts index cb270d23fe..b89598948c 100644 --- a/src/application/ntfs/NtfsJournalService.test.ts +++ b/src/application/ntfs/NtfsJournalService.test.ts @@ -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", diff --git a/src/application/ntfs/NtfsJournalService.ts b/src/application/ntfs/NtfsJournalService.ts index ce80b2d2f8..5b472db699 100644 --- a/src/application/ntfs/NtfsJournalService.ts +++ b/src/application/ntfs/NtfsJournalService.ts @@ -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, diff --git a/src/domain/windows/ntfsJournalTimeline.test.ts b/src/domain/windows/ntfsJournalTimeline.test.ts index 332d4faacc..9b9c3afbb5 100644 --- a/src/domain/windows/ntfsJournalTimeline.test.ts +++ b/src/domain/windows/ntfsJournalTimeline.test.ts @@ -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: [], }); diff --git a/src/domain/windows/ntfsJournalTimeline.ts b/src/domain/windows/ntfsJournalTimeline.ts index d628fc3c51..344ec8a85f 100644 --- a/src/domain/windows/ntfsJournalTimeline.ts +++ b/src/domain/windows/ntfsJournalTimeline.ts @@ -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, }; diff --git a/src/domain/windows/ntfsMft.test.ts b/src/domain/windows/ntfsMft.test.ts index 9d56b54821..e5966a34a8 100644 --- a/src/domain/windows/ntfsMft.test.ts +++ b/src/domain/windows/ntfsMft.test.ts @@ -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([]); +}); diff --git a/src/domain/windows/ntfsMft.ts b/src/domain/windows/ntfsMft.ts index 7264dade58..b8466bf22f 100644 --- a/src/domain/windows/ntfsMft.ts +++ b/src/domain/windows/ntfsMft.ts @@ -124,9 +124,6 @@ export type MftRecordHeader = Omit; 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 => { - 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: [], diff --git a/tests/boundary/mcp/ntfsJournal.test.ts b/tests/boundary/mcp/ntfsJournal.test.ts index 74dedfdd44..6a3e66b710 100644 --- a/tests/boundary/mcp/ntfsJournal.test.ts +++ b/tests/boundary/mcp/ntfsJournal.test.ts @@ -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: {