mirror of
https://github.com/morluto/rea.git
synced 2026-11-13 10:12:50 +00:00
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>
This commit is contained in:
1 parent
fc9585a44f
commit
ffac2e0c99
6 files changed
+582
-94
No files matched your search
@@ -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<typeof createUsnMftCorrelator>;
|
||||
}
|
||||
|
||||
/** 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<ArtifactResourceScope, NtfsJournalCache>();
|
||||
|
||||
/** 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;
|
||||
};
|
||||
@@ -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<void>(),
|
||||
release = createDeferred<void>();
|
||||
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();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Result<NtfsAcquisition, AnalysisError>> => {
|
||||
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<typeof ntfsJournalInputSchema.parse>;
|
||||
|
||||
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<Result<Evidence, AnalysisError>> => {
|
||||
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,
|
||||
{
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user