diff --git a/src/artifacts/NtfsMftArtifactReader.test.ts b/src/artifacts/NtfsMftArtifactReader.test.ts index 5dcc2891cb..944af32087 100644 --- a/src/artifacts/NtfsMftArtifactReader.test.ts +++ b/src/artifacts/NtfsMftArtifactReader.test.ts @@ -9,6 +9,7 @@ import { NtfsMftArtifactReader } from "./NtfsMftArtifactReader.js"; import { MFT_DEFAULT_RECORD_BYTES } from "../domain/windows/ntfsMft.js"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { linkedMftFixture } from "../../tests/fixtures/ntfsMftExtension.js"; import { scanCanonicalArtifactInventory } from "../../tests/fixtures/artifactInventory.js"; /** A 1 KiB FILE record whose update sequence array covers both sectors. */ @@ -77,7 +78,7 @@ it("reports a $MFT stream's record headers as the root occurrence", async () => /attributes, filenames.*not decoded|no path, file size/iu, ); expect(limitations).toContain( - "Nonresident payloads and extension records are not assembled", + "Nonresident list values and file contents are not recovered", ); // A record stream declares no members, so the root is its whole inventory. expect(inventory.occurrences.map((row) => row.logical_path)).toEqual(["."]); @@ -240,3 +241,35 @@ it("revalidates the source before reusing a completed header inventory", async ( await reader.close(); } }); + +it("delivers acquired extension metadata through the canonical inventory schema", async () => { + const inventory = await scanCanonicalArtifactInventory( + await writeMft("linked", linkedMftFixture()), + ); + expect(inventory.occurrences[0]).toMatchObject({ + artifact_format: "ntfs-mft", + format_metadata: { + record_count: 2, + extension_record_count: 1, + file_name_count: 2, + record_headers: [ + { + index: 0, + fileNames: [{ name: "local" }], + attributeListResolution: { + coverage: "complete", + references: [ + { result: { kind: "resolved", attributeFileOffset: 1080 } }, + ], + linkedFileNames: [{ name: "linked", fileOffset: 1104 }], + }, + }, + { + index: 1, + baseRecordReference: { recordNumber: 0, sequenceNumber: 2 }, + fileNames: [{ name: "linked" }], + }, + ], + }, + }); +}); diff --git a/src/artifacts/artifactFormats.ts b/src/artifacts/artifactFormats.ts index 78e815bdeb..d9b3fdcc2a 100644 --- a/src/artifacts/artifactFormats.ts +++ b/src/artifacts/artifactFormats.ts @@ -470,7 +470,7 @@ const NON_ZIP_FORMATS = { ...plain, retainClassifiedSource: true, readerLimitation: - "MFT inventory reports whole-stream counts and up to 32 FILE record details with fixup coverage, supported filenames, timestamps, declared sizes and attribute metadata; omitted details are counted. Nonresident payloads and extension records are not assembled, full paths are not reconstructed, and MFT inventory alone does not establish change history. Use inspect_ntfs_journal with caller-paired acquisitions for bounded journal events.", + "MFT inventory reports whole-stream counts and up to 32 physical FILE record details, including fixup coverage, supported names, timestamps, declared sizes and attributes. Bounded attribute-list links expose verified metadata from acquired extension records, with explicit reference outcomes and omissions. Nonresident list values and file contents are not recovered; full paths and complete change history are not established. Use inspect_ntfs_journal for caller-paired journal events.", createReader: (path, options) => new NtfsMftArtifactReader(path, sourceOf(options)), }), diff --git a/tests/boundary/mcp/ntfsJournal.test.ts b/tests/boundary/mcp/ntfsJournal.test.ts index 6a3e66b710..d7b78280e4 100644 --- a/tests/boundary/mcp/ntfsJournal.test.ts +++ b/tests/boundary/mcp/ntfsJournal.test.ts @@ -2,6 +2,8 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, vi } from "vitest"; +import { z } from "zod"; +import { linkedMftFixture } from "../../fixtures/ntfsMftExtension.js"; import * as stableArtifact from "../../../src/artifacts/readStableArtifact.js"; import * as mftDecoder from "../../../src/domain/windows/ntfsMft.js"; @@ -157,7 +159,8 @@ mcpTest( if (!config.ok) throw config.error; const session = createBinarySession(config.value, silentLogger, {}); onTestFinished(async () => { - await session.close(); + const closed = await session.close(); + if (!closed.ok) throw closed.error; }); const client = await mcp.connect( createServer({ kind: "session", session }), @@ -198,14 +201,9 @@ mcpTest( }); expect(refused.isError).toBe(true); const { error } = parseMcpToolError(refused); - const reference = error.details?.partial_observation; - if ( - typeof reference !== "object" || - reference === null || - !("evidence_id" in reference) || - typeof reference.evidence_id !== "string" - ) - throw new Error("Missing retained MFT observation"); + const reference = z + .object({ evidence_id: z.string() }) + .parse(error.details?.partial_observation); const partial = parseEvidence(session.evidenceById(reference.evidence_id)); expect(partial.normalized_result).toMatchObject({ kind: "ntfs-journal-partial", @@ -219,3 +217,102 @@ mcpTest( }); }, ); + +mcpTest( + "CLI/MCP retain extension facts through independent detail pages and later journal failure", + async ({ mcp, onTestFinished }) => { + const directory = await createTestTempDirectory("rea-ntfs-linked-"); + const mftPath = join(directory, "mft"), + journalPath = join(directory, "journal"); + const journal = Buffer.alloc(64); + journal.writeUInt32LE(64); + journal.writeUInt16LE(2, 4); + journal.writeBigUInt64LE(2n << 48n, 8); + journal.writeUInt16LE(60, 58); + await writeFile(mftPath, linkedMftFixture()); + await writeFile(journalPath, journal); + const cli = await serveCli([ + "inspect-ntfs-journal", + mftPath, + journalPath, + "--mftRecordLimit=1", + "--json", + ]); + expect(cli.exitCode, cli.stdout).toBe(0); + const expected = ntfsJournalResultSchema.parse( + parseEvidence(JSON.parse(cli.stdout)).normalized_result, + ); + expect(expected.mftMetadata).toMatchObject({ + record_count: 2, + file_name_count: 2, + next_record_header_index: 1, + record_headers: [ + { + fileNames: [{ name: "local" }], + attributeListResolution: { + coverage: "complete", + linkedFileNames: [{ name: "linked", fileOffset: 1104 }], + }, + }, + ], + }); + const config = parseConfig({}); + if (!config.ok) throw config.error; + const session = createBinarySession(config.value, silentLogger, {}); + onTestFinished(async () => { + const closed = await session.close(); + if (!closed.ok) throw closed.error; + }); + const client = await mcp.connect( + createServer({ kind: "session", session }), + ); + const reads = vi.spyOn(stableArtifact, "readStableArtifact"), + parses = vi.spyOn(mftDecoder, "parseMftIndex"); + onTestFinished(() => { + reads.mockRestore(); + parses.mockRestore(); + }); + const input = { + mft_path: mftPath, + journal_path: journalPath, + mft_record_limit: 1, + }; + const reply = await client.callTool({ + name: "inspect_ntfs_journal", + arguments: input, + }); + expect(reply.isError).not.toBe(true); + expect(parseEvidence(reply.structuredContent).normalized_result).toEqual( + expected, + ); + const next = await client.callTool({ + name: "inspect_ntfs_journal", + arguments: { ...input, mft_record_start: 1, start_file_offset: 64 }, + }); + expect(next.isError).not.toBe(true); + expect( + parseEvidence(next.structuredContent).normalized_result, + ).toMatchObject({ + rows: [], + mftMetadata: { + record_headers: [{ index: 1, fileNames: [{ name: "linked" }] }], + next_record_header_index: null, + }, + }); + expect(reads).toHaveBeenCalledTimes(2); + expect(parses).toHaveBeenCalledTimes(1); + const refused = await client.callTool({ + name: "inspect_ntfs_journal", + arguments: { ...input, journal_path: join(directory, "missing") }, + }); + expect(refused.isError).toBe(true); + const { error } = parseMcpToolError(refused); + const reference = z + .object({ evidence_id: z.string() }) + .parse(error.details?.partial_observation); + expect( + parseEvidence(session.evidenceById(reference.evidence_id)) + .normalized_result, + ).toMatchObject({ mft: { metadata: expected.mftMetadata } }); + }, +); diff --git a/tests/fixtures/ntfsMftExtension.ts b/tests/fixtures/ntfsMftExtension.ts index c1f133958a..be0e033ea0 100644 --- a/tests/fixtures/ntfsMftExtension.ts +++ b/tests/fixtures/ntfsMftExtension.ts @@ -74,6 +74,13 @@ export const mftFileRecord = ( record.writeUInt16LE(1, 22); record.writeUInt32LE(recordBytes, 28); record.writeBigUInt64LE(baseReference, 32); + record.writeUInt16LE( + attributes.reduce( + (next, attribute) => Math.max(next, attribute.readUInt16LE(14) + 1), + 0, + ), + 40, + ); record.writeUInt32LE(index, 44); let cursor = first; for (const attribute of attributes) {