test(ntfs): retain extension facts across inventory and paired workflows

Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
N0zoM1z0andgensokyo-dev authored and gensokyo-dev committed 2026-11-11 14:10:25 +08:00
1 parent b3bab01b22
commit 5934ba1bf9
4 files changed
+148 -11

No files matched your search

+34 -1
View File
@@ -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" }],
},
],
},
});
});
+1 -1
View File
@@ -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)),
}),
+106 -9
View File
@@ -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 } });
},
);
+7
View File
@@ -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) {