diff --git a/src/application/ntfs/NtfsJournalCache.ts b/src/application/ntfs/NtfsJournalCache.ts new file mode 100644 index 0000000000..00568ca4bc --- /dev/null +++ b/src/application/ntfs/NtfsJournalCache.ts @@ -0,0 +1,74 @@ +import type { BigIntStats } from "node:fs"; +import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import type { StableArtifactSnapshot } from "../../artifacts/readStableArtifact.js"; +import type { MftImage } from "../../domain/windows/ntfsMft.js"; +import { createUsnMftCorrelator } from "../../domain/windows/ntfsJournalCorrelation.js"; + +export interface NtfsAcquisition { + readonly path: string; + readonly snapshot: StableArtifactSnapshot; + readonly state: BigIntStats; +} +export interface NtfsJournalPair { + readonly mft: NtfsAcquisition; + readonly journal: NtfsAcquisition; + readonly image: MftImage; + readonly correlate: ReturnType; +} + +/** One completed digest-bound pair per resource scope; no descriptors or negative entries. */ +export class NtfsJournalCache { + #pair: NtfsJournalPair | undefined; + #active = false; + + begin(): boolean { + if (this.#active) return false; + this.#active = true; + return true; + } + end(): void { + this.#active = false; + } + clear(): void { + this.#pair = undefined; + } + select(mftPath: string, journalPath: string): NtfsJournalPair | undefined { + if ( + this.#pair?.mft.path !== mftPath || + this.#pair.journal.path !== journalPath + ) + this.clear(); + return this.#pair; + } + retain( + mft: NtfsAcquisition, + journal: NtfsAcquisition, + image: MftImage, + ): NtfsJournalPair { + const pair = { + mft, + journal, + image, + correlate: createUsnMftCorrelator(image.records), + }; + this.#pair = pair; + return pair; + } +} +const scopedCaches = new WeakMap(); + +/** Scope shutdown clears retained bytes after admitted operations and cleanup settle. */ +export const ntfsJournalCacheFor = ( + resources: ArtifactResourceScope, +): NtfsJournalCache => { + let cache = scopedCaches.get(resources); + if (cache === undefined) { + cache = new NtfsJournalCache(); + const owned = cache; + resources.addCloseHook(async () => { + owned.clear(); + }); + scopedCaches.set(resources, cache); + } + return cache; +}; diff --git a/src/application/ntfs/NtfsJournalPaging.test.ts b/src/application/ntfs/NtfsJournalPaging.test.ts new file mode 100644 index 0000000000..92596affeb --- /dev/null +++ b/src/application/ntfs/NtfsJournalPaging.test.ts @@ -0,0 +1,291 @@ +import { + lstat, + open, + realpath, + rename, + utimes, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import * as stableArtifact from "../../artifacts/readStableArtifact.js"; +import * as mftDecoder from "../../domain/windows/ntfsMft.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { parseEvidence } from "../../domain/evidence.js"; +import { ntfsJournalResultSchema } from "../../domain/windows/ntfsJournalQuery.js"; +import { inspectNtfsJournalEvidence } from "./NtfsJournalService.js"; +import { createDeferred } from "../../../tests/fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; + +const acquisitions = async () => { + const root = await createTestTempDirectory("rea-ntfs-cache-"); + const mftPath = join(root, "mft"), + journalPath = join(root, "journal"); + const mft = Buffer.alloc(1024); + mft.write("FILE"); + mft.writeUInt16LE(48, 4); + mft.writeUInt16LE(3, 6); + mft.writeUInt16LE(2, 16); + mft.writeUInt16LE(56, 20); + mft.writeUInt32LE(64, 24); + mft.writeUInt32LE(1024, 28); + mft.writeUInt32LE(0xffffffff, 56); + for (const offset of [48, 510, 1022]) mft.writeUInt16LE(1, offset); + const journal = Buffer.alloc(192); + for (let offset = 0; offset < 192; offset += 64) { + journal.writeUInt32LE(64, offset); + journal.writeUInt16LE(2, offset + 4); + journal.writeBigUInt64LE(2n << 48n, offset + 8); + journal.writeBigInt64LE(BigInt(offset), offset + 24); + journal.writeUInt16LE(60, offset + 58); + } + await writeFile(mftPath, mft); + await writeFile(journalPath, journal); + return { + root, + mftPath, + journalPath, + mft, + journal, + input: { mft_path: mftPath, journal_path: journalPath, max_rows: 1 }, + }; +}; + +it("walks source-order pages with one physical acquisition and one MFT interpretation per scope", async () => { + const fixture = await acquisitions(), + resources = new ArtifactResourceScope(); + const reads = vi.spyOn(stableArtifact, "readStableArtifact"); + const parses = vi.spyOn(mftDecoder, "parseMftImage"); + try { + const values: string[] = []; + for (const offset of [0, 64, 128]) { + const result = await inspectNtfsJournalEvidence( + { ...fixture.input, start_file_offset: offset }, + resources, + ); + if (!result.ok) throw result.error; + const normalized = ntfsJournalResultSchema.parse( + result.value.normalized_result, + ); + const row = normalized.rows[0]; + if (row?.kind !== "event") throw new Error("Missing selected event"); + values.push(row.event.usn); + } + expect(values).toEqual(["0", "64", "128"]); + expect(reads).toHaveBeenCalledTimes(2); + expect(parses).toHaveBeenCalledTimes(1); + } finally { + reads.mockRestore(); + parses.mockRestore(); + await resources.close(); + } +}); + +it("refuses changed MFT bytes even after mtime restoration, then admits an explicit retry with a new identity", async () => { + const fixture = await acquisitions(), + resources = new ArtifactResourceScope(); + const reads = vi.spyOn(stableArtifact, "readStableArtifact"); + try { + const first = await inspectNtfsJournalEvidence(fixture.input, resources); + if (!first.ok) throw first.error; + const original = await lstat(fixture.mftPath); + fixture.mft.writeUInt16LE(3, 16); + await writeFile(fixture.mftPath, fixture.mft); + await utimes(fixture.mftPath, original.atime, original.mtime); + const changed = await inspectNtfsJournalEvidence(fixture.input, resources); + if (changed.ok) throw new Error("returned stale MFT facts"); + expect(projectAnalysisError(changed.error).code).toBe("artifact_changed"); + expect(reads).toHaveBeenCalledTimes(2); + const retry = await inspectNtfsJournalEvidence(fixture.input, resources); + if (!retry.ok) throw retry.error; + expect(retry.value.normalized_result).toMatchObject({ + rows: [{ event: { mft: { file: { kind: "unresolved" } } } }], + }); + expect(reads).toHaveBeenCalledTimes(4); + } finally { + reads.mockRestore(); + await resources.close(); + } +}); + +it("rejects same-content journal replacement and retains the completed MFT observation", async () => { + const fixture = await acquisitions(), + resources = new ArtifactResourceScope(); + try { + const first = await inspectNtfsJournalEvidence(fixture.input, resources); + if (!first.ok) throw first.error; + const replacement = join(fixture.root, "replacement"); + await writeFile(replacement, fixture.journal); + await rename(replacement, fixture.journalPath); + const changed = await inspectNtfsJournalEvidence(fixture.input, resources); + if (changed.ok) throw new Error("accepted a replaced selected file"); + expect(projectAnalysisError(changed.error).code).toBe("artifact_changed"); + expect( + parseEvidence(changed.error.partialObservation).normalized_result, + ).toMatchObject({ + kind: "ntfs-journal-partial", + mft: { metadata: { record_count: 1 } }, + journal_observation: { kind: "file-snapshot-hash" }, + }); + } finally { + await resources.close(); + } +}); + +it("evicts the prior pair instead of accumulating buffers and refuses admission after close", async () => { + const a = await acquisitions(), + b = await acquisitions(), + resources = new ArtifactResourceScope(); + const reads = vi.spyOn(stableArtifact, "readStableArtifact"); + try { + for (const input of [a.input, b.input, a.input]) + expect((await inspectNtfsJournalEvidence(input, resources)).ok).toBe( + true, + ); + expect(reads).toHaveBeenCalledTimes(6); + await resources.close(); + expect((await inspectNtfsJournalEvidence(a.input, resources)).ok).toBe( + false, + ); + expect(reads).toHaveBeenCalledTimes(6); + } finally { + reads.mockRestore(); + await resources.close(); + } +}); + +it("keeps an active acquisition independent of a cancelled peer, bounds concurrent admission, and joins close", async () => { + const fixture = await acquisitions(), + resources = new ArtifactResourceScope(); + const entered = createDeferred(), + release = createDeferred(); + const read = stableArtifact.readStableArtifact; + const reads = vi + .spyOn(stableArtifact, "readStableArtifact") + .mockImplementationOnce(async (...args) => { + entered.resolve(); + await release.promise; + return read(...args); + }); + try { + const active = inspectNtfsJournalEvidence(fixture.input, resources); + await entered.promise; + const cancelled = new AbortController(); + cancelled.abort("peer cancelled"); + const peer = await inspectNtfsJournalEvidence( + fixture.input, + resources, + cancelled.signal, + ); + if (peer.ok) throw new Error("accepted cancelled peer"); + expect(projectAnalysisError(peer.error).code).toBe("cancelled"); + const busy = await inspectNtfsJournalEvidence(fixture.input, resources); + if (busy.ok) throw new Error("exceeded acquisition capacity"); + expect(projectAnalysisError(busy.error).code).toBe("resource_constraint"); + let closed = false; + const closing = resources.close().then(() => { + closed = true; + }); + expect(closed).toBe(false); + release.resolve(); + expect((await active).ok).toBe(true); + await closing; + expect(reads).toHaveBeenCalledTimes(2); + } finally { + release.resolve(); + reads.mockRestore(); + await resources.close(); + } +}); + +it("does not cache acquisitions cancelled during the final journal close", async () => { + const fixture = await acquisitions(), + resources = new ArtifactResourceScope(); + const controller = new AbortController(); + const read = stableArtifact.readStableArtifact; + let cancelOnClose = true; + const reads = vi + .spyOn(stableArtifact, "readStableArtifact") + .mockImplementation((path, maximum, signal, fs, scope, state) => + read( + path, + maximum, + signal, + path === fixture.journalPath + ? { + lstat: (selected) => lstat(selected, { bigint: true }), + realpath, + open: async (selected, flags) => { + const file = await open(selected, flags), + close = file.close.bind(file); + file.close = async () => { + if (cancelOnClose) { + cancelOnClose = false; + controller.abort("final cleanup"); + } + await close(); + }; + return file; + }, + } + : fs, + scope, + state, + ), + ); + try { + const cancelled = await inspectNtfsJournalEvidence( + fixture.input, + resources, + controller.signal, + ); + if (cancelled.ok) throw new Error("ignored finalization cancellation"); + expect(projectAnalysisError(cancelled.error).code).toBe("cancelled"); + expect( + parseEvidence(cancelled.error.partialObservation).normalized_result, + ).toMatchObject({ kind: "ntfs-journal-partial" }); + expect( + (await inspectNtfsJournalEvidence(fixture.input, resources)).ok, + ).toBe(true); + expect(reads).toHaveBeenCalledTimes(4); + } finally { + reads.mockRestore(); + await resources.close(); + } +}); + +it.each([ + ["EACCES", "access_denied"], + ["EIO", "artifact_operation_failed"], +])( + "preserves %s during cached source validation and allows a fresh retry", + async (systemCode, expectedCode) => { + const fixture = await acquisitions(), + resources = new ArtifactResourceScope(); + const reads = vi.spyOn(stableArtifact, "readStableArtifact"); + const validate = vi.spyOn(stableArtifact, "assertStableArtifactState"); + try { + expect( + (await inspectNtfsJournalEvidence(fixture.input, resources)).ok, + ).toBe(true); + validate.mockRejectedValueOnce( + Object.assign(new Error("source validation failed"), { + code: systemCode, + }), + ); + const failed = await inspectNtfsJournalEvidence(fixture.input, resources); + if (failed.ok) throw new Error("returned an unverified cache entry"); + expect(projectAnalysisError(failed.error).code).toBe(expectedCode); + expect(reads).toHaveBeenCalledTimes(2); + expect( + (await inspectNtfsJournalEvidence(fixture.input, resources)).ok, + ).toBe(true); + expect(reads).toHaveBeenCalledTimes(4); + } finally { + reads.mockRestore(); + validate.mockRestore(); + await resources.close(); + } + }, +); diff --git a/src/application/ntfs/NtfsJournalService.test.ts b/src/application/ntfs/NtfsJournalService.test.ts index f87e3a9976..cb270d23fe 100644 --- a/src/application/ntfs/NtfsJournalService.test.ts +++ b/src/application/ntfs/NtfsJournalService.test.ts @@ -46,14 +46,15 @@ it.each([ it("preserves the completed file identity when MFT interpretation fails", async () => { const resources = new ArtifactResourceScope(); const bytes = Buffer.from("not an MFT"); - const sha256 = "a".repeat(64); - const spy = vi - .spyOn(stableArtifact, "readStableArtifact") - .mockResolvedValueOnce({ bytes, sha256 }); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const directory = await createTestTempDirectory("rea-ntfs-format-refusal-"); + const mftPath = join(directory, "mft"); + await writeFile(mftPath, bytes); + const spy = vi.spyOn(stableArtifact, "readStableArtifact"); try { const result = await inspectNtfsJournalEvidence( { - mft_path: "/fixture/mft", + mft_path: mftPath, journal_path: "/fixture/journal", }, resources, @@ -61,7 +62,7 @@ it("preserves the completed file identity when MFT interpretation fails", async if (result.ok) throw new Error("expected format failure"); expect(result.error.partialObservation).toEqual({ kind: "file-snapshot-hash", - path: "/fixture/mft", + path: mftPath, sha256, bytes: bytes.length, }); @@ -91,29 +92,35 @@ it("retains both acquisitions through a failed journal close and cleanup retry", const resources = new ArtifactResourceScope(); const read = stableArtifact.readStableArtifact; let restoreClose: (() => void) | undefined; + let failNextClose = true; const spy = vi .spyOn(stableArtifact, "readStableArtifact") - .mockImplementation((path, maximumBytes, signal, fileSystem, scope) => - read( - path, - maximumBytes, - signal, - path === journalPath - ? { - lstat: (selected) => lstat(selected, { bigint: true }), - realpath, - open: async (selected, flags) => { - const handle = await open(selected, flags); - const close = vi - .spyOn(handle, "close") - .mockRejectedValueOnce(new Error("close failed")); - restoreClose = () => close.mockRestore(); - return handle; - }, - } - : fileSystem, - scope, - ), + .mockImplementation( + (path, maximumBytes, signal, fileSystem, scope, observed) => + read( + path, + maximumBytes, + signal, + path === journalPath + ? { + lstat: (selected) => lstat(selected, { bigint: true }), + realpath, + open: async (selected, flags) => { + const handle = await open(selected, flags); + if (failNextClose) { + failNextClose = false; + const close = vi + .spyOn(handle, "close") + .mockRejectedValueOnce(new Error("close failed")); + restoreClose = () => close.mockRestore(); + } + return handle; + }, + } + : fileSystem, + scope, + observed, + ), ); try { const result = await inspectNtfsJournalEvidence( @@ -139,6 +146,12 @@ it("retains both acquisitions through a failed journal close and cleanup retry", bytes: journal.length, }, }); + const retry = await inspectNtfsJournalEvidence( + { mft_path: mftPath, journal_path: journalPath }, + resources, + ); + if (!retry.ok) throw retry.error; + expect(spy).toHaveBeenCalledTimes(4); await resources.close(); expect(result.error.cleanupIncomplete).toBe(false); expect( diff --git a/src/application/ntfs/NtfsJournalService.ts b/src/application/ntfs/NtfsJournalService.ts index ab16258631..f06866960d 100644 --- a/src/application/ntfs/NtfsJournalService.ts +++ b/src/application/ntfs/NtfsJournalService.ts @@ -1,10 +1,19 @@ +import type { BigIntStats } from "node:fs"; +import { + ntfsJournalCacheFor, + type NtfsJournalCache, + type NtfsAcquisition, +} from "./NtfsJournalCache.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { artifactFileCancellationCause, artifactFilePrimaryFacts, } from "../../artifacts/ArtifactFileOperation.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + readStableArtifact, + assertStableArtifactState, +} from "../../artifacts/readStableArtifact.js"; import { projectStableArtifactFailure, stableArtifactInputRecovery, @@ -32,7 +41,7 @@ import { ntfsJournalResultSchema, } from "../../domain/windows/ntfsJournalQuery.js"; import { composeNtfsJournalTimeline } from "../../domain/windows/ntfsJournalTimeline.js"; -import { parseMftImage } from "../../domain/windows/ntfsMft.js"; +import { parseMftImage, type MftImage } from "../../domain/windows/ntfsMft.js"; import { parseAnalysisInput } from "../AnalysisInput.js"; import { completeProviderEvidence } from "../ProviderOperationExecution.js"; @@ -110,19 +119,30 @@ const readAcquisition = async ( parameter: PathParameter, resources: ArtifactResourceScope, signal?: AbortSignal, -) => { + cached?: NtfsAcquisition, +): Promise> => { try { - return ok( - await resources.run(() => - readStableArtifact( - path, - NTFS_JOURNAL_FILE_BYTES, - signal, - undefined, - resources, - ), - ), + if (cached !== undefined) { + await assertStableArtifactState(path, cached.state, signal); + return ok(cached); + } + let state: BigIntStats | undefined; + const snapshot = await readStableArtifact( + path, + NTFS_JOURNAL_FILE_BYTES, + signal, + undefined, + resources, + (observed) => { + state = observed; + }, ); + if (state === undefined) + throw new ArtifactReaderFailure( + "unavailable", + "NTFS acquisition source state was not retained", + ); + return ok({ path, snapshot, state }); } catch (cause) { return err( projectStableArtifactFailure( @@ -131,10 +151,58 @@ const readAcquisition = async ( path, NTFS_JOURNAL_PROVIDER.id, operation, + cached?.snapshot, ), ); } }; +type NtfsInput = ReturnType; + +const retainCompletedMft = ( + input: NtfsInput, + mft: NtfsAcquisition, + image: MftImage, + failure: AnalysisError, +) => { + const prior = + failure.partialObservation ?? + primaryAnalysisError(failure).partialObservation; + const observation = createEvidence( + { path: input.mft_path, format: "ntfs-mft", sha256: mft.snapshot.sha256 }, + NTFS_JOURNAL_PROVIDER, + { + operation, + predicateType: "rea.ntfs-journal-partial", + parameters: input, + result: { + kind: "ntfs-journal-partial", + mft: { + path: input.mft_path, + sha256: mft.snapshot.sha256, + bytes: mft.snapshot.bytes.length, + metadata: image.metadata, + }, + journal_observation: prior ?? null, + }, + confidence: "derived", + limitations: [ + ...image.limitations, + "Journal acquisition did not complete.", + ], + locations: [{ kind: "artifact-path", path: input.mft_path }], + }, + ); + let current: AnalysisError | undefined = failure; + while (current !== undefined) { + if ( + current.partialObservation === prior || + current.partialObservation === undefined + ) + current.partialObservation = observation; + current = current.cleanupPrimary; + } + return err(failure); +}; export const inspectNtfsJournalEvidence = async ( rawInput: unknown, @@ -149,21 +217,65 @@ export const inspectNtfsJournalEvidence = async ( ); if (!admitted.ok) return admitted; const input = admitted.value; + const cache = ntfsJournalCacheFor(resources); + if (!cache.begin()) + return err( + new AnalysisResourceConstraintError( + operation, + "capacity", + "One NTFS inspection is already active in this scope; retry after it completes.", + { maximum_concurrent_inspections: 1 }, + { retryable: true }, + ), + ); + try { + return await resources.run(() => + inspectAdmittedJournal(input, resources, cache, signal), + ); + } catch (cause) { + return err( + projectStableArtifactFailure( + cause, + readFailure(cause, input.mft_path, "mft_path"), + input.mft_path, + NTFS_JOURNAL_PROVIDER.id, + operation, + ), + ); + } finally { + cache.end(); + } +}; + +const inspectAdmittedJournal = async ( + input: NtfsInput, + resources: ArtifactResourceScope, + cache: NtfsJournalCache, + signal?: AbortSignal, +): Promise> => { + const retained = cache.select(input.mft_path, input.journal_path); const mft = await readAcquisition( input.mft_path, "mft_path", resources, signal, + retained?.mft, ); - if (!mft.ok) return mft; - const parsed = parseMftImage(mft.value.bytes); + if (!mft.ok) { + if (!(mft.error instanceof AnalysisCancelledError)) cache.clear(); + return mft; + } + const parsed = + retained === undefined + ? parseMftImage(mft.value.snapshot.bytes) + : ok(retained.image); if (!parsed.ok) return err( inputFailure("mft_path", parsed.error.message).retainPartialObservation({ kind: "file-snapshot-hash", path: input.mft_path, - sha256: mft.value.sha256, - bytes: mft.value.bytes.length, + sha256: mft.value.snapshot.sha256, + bytes: mft.value.snapshot.bytes.length, }), ); @@ -172,70 +284,41 @@ export const inspectNtfsJournalEvidence = async ( "journal_path", resources, signal, + retained?.journal, ); if (!journal.ok) { - const failure = journal.error; - const prior = - failure.partialObservation ?? - primaryAnalysisError(failure).partialObservation; - const observation = createEvidence( - { path: input.mft_path, format: "ntfs-mft", sha256: mft.value.sha256 }, - NTFS_JOURNAL_PROVIDER, - { - operation, - predicateType: "rea.ntfs-journal-partial", - parameters: input, - result: { - kind: "ntfs-journal-partial", - mft: { - path: input.mft_path, - sha256: mft.value.sha256, - bytes: mft.value.bytes.length, - metadata: parsed.value.metadata, - }, - journal_observation: prior ?? null, - }, - confidence: "derived", - limitations: [ - ...parsed.value.limitations, - "Journal acquisition did not complete.", - ], - locations: [{ kind: "artifact-path", path: input.mft_path }], - }, - ); - let current: AnalysisError | undefined = failure; - while (current !== undefined) { - if ( - current.partialObservation === prior || - current.partialObservation === undefined - ) - current.partialObservation = observation; - current = current.cleanupPrimary; - } - return journal; + if (!(journal.error instanceof AnalysisCancelledError)) cache.clear(); + return retainCompletedMft(input, mft.value, parsed.value, journal.error); } + const prepared = + retained ?? cache.retain(mft.value, journal.value, parsed.value); const result = ntfsJournalResultSchema.parse({ mft: { path: input.mft_path, - bytes: mft.value.bytes.length, - sha256: mft.value.sha256, + bytes: mft.value.snapshot.bytes.length, + sha256: mft.value.snapshot.sha256, }, journal: { path: input.journal_path, - bytes: journal.value.bytes.length, - sha256: journal.value.sha256, + bytes: journal.value.snapshot.bytes.length, + sha256: journal.value.snapshot.sha256, }, - ...composeNtfsJournalTimeline(parsed.value, journal.value.bytes, { - startFileOffset: input.start_file_offset, - maxRows: input.max_rows, - }), + ...composeNtfsJournalTimeline( + prepared.image, + journal.value.snapshot.bytes, + { + startFileOffset: input.start_file_offset, + maxRows: input.max_rows, + }, + prepared.correlate, + ), }); return completeProviderEvidence( createEvidence( { path: input.journal_path, format: "file", - sha256: journal.value.sha256, + sha256: journal.value.snapshot.sha256, }, NTFS_JOURNAL_PROVIDER, { diff --git a/src/domain/windows/ntfsJournalTimeline.ts b/src/domain/windows/ntfsJournalTimeline.ts index e84d543d9a..c0b3eb35a0 100644 --- a/src/domain/windows/ntfsJournalTimeline.ts +++ b/src/domain/windows/ntfsJournalTimeline.ts @@ -36,9 +36,9 @@ export const composeNtfsJournalTimeline = ( mft: MftImage, journal: Buffer, window: { readonly startFileOffset: number; readonly maxRows: number }, + correlate = createUsnMftCorrelator(mft.records), ): NtfsJournalTimeline => { const rows: TimelineRow[] = []; - const correlate = createUsnMftCorrelator(mft.records); let nextFileOffset: number | null = null; for (const result of readUsnJournal(journal, window.startFileOffset)) { diff --git a/tests/boundary/mcp/ntfsJournal.test.ts b/tests/boundary/mcp/ntfsJournal.test.ts index d5c8ed2250..b4aa22be34 100644 --- a/tests/boundary/mcp/ntfsJournal.test.ts +++ b/tests/boundary/mcp/ntfsJournal.test.ts @@ -1,7 +1,9 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { expect } from "vitest"; +import { expect, vi } from "vitest"; +import * as stableArtifact from "../../../src/artifacts/readStableArtifact.js"; +import * as mftDecoder from "../../../src/domain/windows/ntfsMft.js"; import { createBinarySession } from "../../../src/composition/binary.js"; import { parseConfig } from "../../../src/config/parseConfig.js"; @@ -63,6 +65,12 @@ mcpTest( nextFileOffset: null, }); + const reads = vi.spyOn(stableArtifact, "readStableArtifact"); + const parses = vi.spyOn(mftDecoder, "parseMftImage"); + onTestFinished(() => { + reads.mockRestore(); + parses.mockRestore(); + }); const config = parseConfig({}); if (!config.ok) throw config.error; const session = createBinarySession(config.value, silentLogger, {}); @@ -81,6 +89,25 @@ mcpTest( expected, ); + const warm = await client.callTool({ + name: "inspect_ntfs_journal", + arguments: { + mft_path: mftPath, + journal_path: journalPath, + start_file_offset: 64, + }, + }); + expect(warm.isError).not.toBe(true); + expect( + parseEvidence(warm.structuredContent).normalized_result, + ).toMatchObject({ + mft: { sha256: expected.mft.sha256 }, + journal: { sha256: expected.journal.sha256 }, + rows: [], + }); + expect(reads).toHaveBeenCalledTimes(2); + expect(parses).toHaveBeenCalledTimes(1); + const refused = await client.callTool({ name: "inspect_ntfs_journal", arguments: {