mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 17:28:08 +00:00
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:
1 parent
b3bab01b22
commit
5934ba1bf9
4 files changed
+148
-11
No files matched your search
@@ -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" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
}),
|
||||
|
||||
@@ -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 } });
|
||||
},
|
||||
);
|
||||
Vendored
+7
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user