From ffac2e0c99beaf2a253cc006c26abc4923b1c305 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Date: Wed, 11 Nov 2026 04:32:55 +0000 Subject: [PATCH] perf(ntfs): reuse validated acquisitions and MFT identity lookups Retain one completed pair per resource scope, revalidate selected file state on every page, invalidate failures, and clear retained bytes at shutdown. Keep cold CLI process costs explicit. Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- src/application/ntfs/NtfsJournalCache.ts | 74 +++++ .../ntfs/NtfsJournalPaging.test.ts | 291 ++++++++++++++++++ .../ntfs/NtfsJournalService.test.ts | 67 ++-- src/application/ntfs/NtfsJournalService.ts | 213 +++++++++---- src/domain/windows/ntfsJournalTimeline.ts | 2 +- tests/boundary/mcp/ntfsJournal.test.ts | 29 +- 6 files changed, 582 insertions(+), 94 deletions(-) create mode 100644 src/application/ntfs/NtfsJournalCache.ts create mode 100644 src/application/ntfs/NtfsJournalPaging.test.ts 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: {