feat(artifacts): select extraction entries and output destination (#4031)

* feat(artifacts): select extraction entries and output destination

Refs morluto/rea#3490

* fix(artifacts): omit absent selection from execution parameters

* fix(artifacts): protect source directories and verify public selection

* fix(artifacts): keep optional selection inputs JSON-safe

* fix(artifacts): separate reader ownership from selected destinations

Validate complete reader inventory independently from collisions among selected output entries. Use occurrence-parent ownership instead of filename prefixes so independently owned PyInstaller entries remain selectable while nested-reader members remain refused. Validate advertised MCP schemas through the shared acceptance helper and add real PyInstaller/ASAR and excluded-entry regressions.

* fix(artifacts): advertise absolute extraction destinations

Reuse the shared host-absolute local path schema instead of an opaque refinement so SDK-converted JSON Schema preserves the runtime constraint. Extend the advertised-path and MCP-handler regression matrix for extract_artifact.output_root.

* fix(contracts): advertise unique extraction occurrence IDs

---------

Co-authored-by: gensokyo-dev <[email protected]>
This commit is contained in:
APZNandgensokyo-dev authored and GitHub committed 2026-11-06 03:28:05 +08:00
1 parent 8084916859
commit ad518cff65
13 files changed
+1129 -57

No files matched your search

+20
View File
@@ -1599,6 +1599,26 @@ canonical `integrity_contradictions` records and marks application graph
coverage partial when any mismatch is continued. Contradicted nested ASARs
remain opaque.
`extract_artifact` accepts optional `output_root` and `occurrence_ids` inputs.
The CLI equivalents are `--output-root` and repeatable `--occurrence-id`.
`output_root` is an absolute path on the REA host: its parent must exist and the
root must not exist, even as an empty directory or a symbolic link. Omitting it
keeps the fresh `rea-extracted-<uuid>` directory under Node's temporary directory
(`TMPDIR` on POSIX). Successful output persists after the call and client close;
the caller owns its later removal. A failed extraction rolls back only its own
output and reports any cleanup failure.
An output root inside the source directory, including a symlink alias, is refused.
Obtain exact IDs from the active artifact's inventory. Omitting `occurrence_ids`
extracts all regular entries exposed by the active reader. An explicit selection
must be nonempty and unique; unknown IDs, directories, links, the root and nested
archive members are rejected before output creation. Open the containing archive
separately to extract its nested members. Selection is by occurrence, not content
hash, so equal bytes at different paths remain independently selectable. Only
selected files face destination name collisions; input path and integrity checks
still cover the full inventory. The extraction manifest records exactly which
occurrences were materialized and retains the source manifest identity.
`extract_artifact` accepts the same policy in MCP and as `--integrity-policy`
on `rea extract-artifact`. With `record-and-continue` it writes the observed
bytes and returns the `integrity_contradictions` records for the extracted
@@ -2,6 +2,8 @@ import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { absoluteCallerPath } from "../../filesystem/callerPath.js";
/** Choose a fresh temporary destination for an artifact extraction. */
export const createArtifactExtractionDestination = (): string =>
absoluteCallerPath(`${tmpdir()}/rea-extracted-${randomUUID()}`);
/** Preserve an explicit destination; otherwise choose a fresh temporary root. */
export const createArtifactExtractionDestination = (
outputRoot?: string,
): string =>
outputRoot ?? absoluteCallerPath(`${tmpdir()}/rea-extracted-${randomUUID()}`);
+3
View File
@@ -339,6 +339,9 @@ class ArtifactClient implements AnalysisClient {
inputFormat: this.target.format,
espFlashSelection: espFlashSelection(this.target),
outputRoot: parsed.output_root,
...(parsed.occurrence_ids === undefined
? {}
: { occurrenceIds: parsed.occurrence_ids }),
environment: this.environment,
asarHeaderAdmission: runtimeAsarHeaderAdmission(
this.environment,
+59 -41
View File
@@ -1,3 +1,4 @@
import { selectArtifactExtractionOccurrences } from "./ArtifactExtractionSelection.js";
import { assertAdmittedRootDigest } from "../inventory/classify.js";
import { assertArtifactFileSelectedSource } from "../ArtifactFileSource.js";
import {
@@ -72,12 +73,13 @@ export interface ArtifactExtractionInput {
readonly selectedPath?: string | undefined;
readonly inputFormat: BinaryTarget["format"];
readonly outputRoot: string;
readonly occurrenceIds?: readonly string[];
readonly environment: Readonly<NodeJS.ProcessEnv>;
readonly integrityPolicy: ArtifactIntegrityPolicyName;
readonly resourceScope: ArtifactResourceScope;
}
/** Extract every regular inventory occurrence into an exclusively owned absent root. */
/** Extract selected regular inventory occurrences into an exclusively owned absent root. */
export const extractArtifact = async (
input: ArtifactExtractionInput,
signal?: AbortSignal,
@@ -224,33 +226,11 @@ export const materializeArtifactInventoryInScope = async (
input.selectedPath ?? input.inputPath,
`wxapkg directory is ${containerMetadata.directory_status}; its complete contents cannot be extracted`,
);
const selectedOccurrences = snapshot.occurrences.filter(
(occurrence) =>
(occurrence.entry_kind === "file" || occurrence.entry_kind === "slice") &&
occurrence.logical_path !== ".",
);
const regularPaths = new Set(
selectedOccurrences.map(({ logical_path: path }) => path),
);
const directoryPaths = new Set(
snapshot.occurrences
.filter(({ entry_kind }) => entry_kind === "directory")
.map(({ logical_path }) => logical_path),
);
const activeOccurrences = selectedOccurrences.filter(({ logical_path }) => {
// Nested members remain represented by their containing regular file;
// only entries exposed by the active reader are materialized.
let path = logical_path;
for (
let slash = path.lastIndexOf("/");
slash >= 0;
slash = path.lastIndexOf("/")
) {
path = path.slice(0, slash);
if (regularPaths.has(path) && !directoryPaths.has(path)) return false;
}
return true;
});
const { available, selected: activeOccurrences } =
selectArtifactExtractionOccurrences(
snapshot.occurrences,
input.occurrenceIds,
);
const neededNodes = new Set(
activeOccurrences.map(({ artifact_id: id }) => id),
);
@@ -308,6 +288,7 @@ export const materializeArtifactInventoryInScope = async (
sourcePath,
inventory: snapshot,
selected,
available,
signal,
transferredSource,
});
@@ -331,6 +312,7 @@ const materializeSelection = async ({
sourcePath,
inventory,
selected,
available,
signal,
transferredSource,
}: {
@@ -338,6 +320,7 @@ const materializeSelection = async ({
readonly sourcePath: string;
readonly inventory: ArtifactInventorySnapshot;
readonly selected: readonly SelectedOccurrence[];
readonly available: readonly ArtifactOccurrence[];
readonly signal: AbortSignal | undefined;
readonly transferredSource: ActiveArtifactInventorySource | undefined;
}): Promise<ArtifactExtractionResult> => {
@@ -389,7 +372,10 @@ const materializeSelection = async ({
await writeSelectedEntries({
reader,
output,
byPath,
selected: new Map(
selected.map((item) => [item.occurrence.occurrence_id, item]),
),
available,
extracted,
traversal,
signal,
@@ -423,6 +409,7 @@ const materializeSelection = async ({
extracted,
traversal.snapshot(),
reader.provenance(),
output.outputRoot,
);
if (input.expectedRootSha256 !== undefined)
await assertArtifactSelectedSource(sourcePath, input.selectedPath);
@@ -453,32 +440,60 @@ const materializeSelection = async ({
const writeSelectedEntries = async ({
reader,
output,
byPath,
selected,
available,
extracted,
traversal,
signal,
}: {
readonly reader: ArtifactReader;
readonly output: SafeOutputTree;
readonly byPath: Map<string, SelectedOccurrence>;
readonly selected: Map<string, SelectedOccurrence>;
readonly available: readonly ArtifactOccurrence[];
readonly extracted: ExtractedOccurrence[];
readonly traversal: ArtifactTraversalObservation;
readonly signal: AbortSignal | undefined;
}): Promise<void> => {
// Inventory sorting is stable within each path. Preserve that reader order:
// a path can name several independently selectable ZIP/TOC occurrences.
const expected = new Map<
string,
{ occurrences: ArtifactOccurrence[]; next: number }
>();
for (const occurrence of available) {
const queue = expected.get(occurrence.logical_path);
if (queue === undefined)
expected.set(occurrence.logical_path, {
occurrences: [occurrence],
next: 0,
});
else queue.occurrences.push(occurrence);
}
const registry = new ArtifactPathRegistry();
for await (const entry of reader.entries(signal, traversal)) {
const path = normalizeArtifactPath(entry.path);
registry.add(path, entry.kind);
// Logical declarations may share a query path with a real file. They do
// not consume that file's selection or participate in materialization.
if (entry.kind !== "file" && entry.kind !== "slice") continue;
const selectedItem = byPath.get(path);
if (selectedItem === undefined) {
const queue = expected.get(path);
const occurrence = queue?.occurrences[queue.next];
if (
queue === undefined ||
occurrence === undefined ||
occurrence.entry_kind !== entry.kind
) {
throw new ArtifactReaderFailure(
"integrity",
`Regular artifact entry is missing from inventory: ${path}`,
);
}
queue.next += 1;
if (queue.next === queue.occurrences.length) expected.delete(path);
const selectedItem = selected.get(occurrence.occurrence_id);
if (selectedItem === undefined) continue;
// Validate the full reader inventory above, but only selected regular
// entries claim destination paths. Excluded declarations may collide.
registry.add(path, entry.kind);
preflight(entry);
const stream = await reader.open(entry, signal);
const written = await output.write(
@@ -497,12 +512,12 @@ const writeSelectedEntries = async ({
bytes_written: written.bytesWritten,
created: true,
});
byPath.delete(path);
selected.delete(occurrence.occurrence_id);
}
if (byPath.size > 0)
if (expected.size > 0)
throw new ArtifactReaderFailure(
"integrity",
`Inventoried regular artifact entries were not materialized: ${[...byPath.keys()].sort(compareUnicodeCodePoints).join(", ")}`,
`Inventoried regular artifact entries were not materialized: ${[...expected.keys()].sort(compareUnicodeCodePoints).join(", ")}`,
);
};
@@ -539,6 +554,7 @@ const createExtractionResult = (
extracted: readonly ExtractedOccurrence[],
traversal: ArtifactReaderTraversal,
materializationProvenance: readonly ArtifactCommand[],
outputRoot: string,
): ArtifactExtractionResult => {
const selectedIds = new Set(
selected.map(({ occurrence }) => occurrence.occurrence_id),
@@ -569,7 +585,7 @@ const createExtractionResult = (
...extractionSemantic,
extraction_id: `aex_${digestCanonicalValue(extractionSemantic, "Artifact")}`,
},
output_root: input.outputRoot,
output_root: outputRoot,
artifacts: extracted,
containment_verified: true,
cleanup: { attempted: false, verified: true, residual_paths: [] },
@@ -586,9 +602,11 @@ const createExtractionResult = (
(limitation) => `${occurrence.logical_path}: ${limitation}`,
),
),
traversal.status === "complete"
? "All regular files exposed by the active artifact reader were materialized."
: "All inventoried regular files exposed by the active reader were materialized; source traversal coverage is unknown, so the output is not established as a complete source copy.",
input.occurrenceIds !== undefined
? "Only caller-selected regular occurrences exposed by the active artifact reader were materialized; unselected entries and nested archive members were not extracted. Inventory integrity checks still cover the full input."
: traversal.status === "complete"
? "All regular files exposed by the active artifact reader were materialized."
: "All inventoried regular files exposed by the active reader were materialized; source traversal coverage is unknown, so the output is not established as a complete source copy.",
"Publication verifies the selected inventoried regular files; it does not establish complete content decoding.",
...(extractedContradictions === 0
? []
@@ -0,0 +1,77 @@
import type { ArtifactOccurrence } from "../../domain/artifactGraph.js";
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
/** The active reader's complete inventory, distinct from the requested output. */
export interface ArtifactExtractionSelection {
readonly available: readonly ArtifactOccurrence[];
readonly selected: readonly ArtifactOccurrence[];
}
/**
* Determine reader ownership before applying the caller's filter. Otherwise
* excluding an archive could incorrectly promote its nested members into the
* outer reader. Preserve inventory order so request order cannot change output.
*/
export const selectArtifactExtractionOccurrences = (
occurrences: readonly ArtifactOccurrence[],
requestedIds?: readonly string[],
): ArtifactExtractionSelection => {
const byId = new Map(
occurrences.map((occurrence) => [occurrence.occurrence_id, occurrence]),
);
const available = occurrences.filter((occurrence) => {
if (
(occurrence.entry_kind !== "file" && occurrence.entry_kind !== "slice") ||
occurrence.logical_path === "."
)
return false;
// Directories preserve reader ownership; a regular container ancestor
// crosses into a nested reader. A shared name prefix alone proves neither.
let parentId = occurrence.parent_occurrence_id;
while (parentId !== null) {
const parent = byId.get(parentId);
if (parent === undefined) return false;
if (parent.logical_path === ".") break;
if (parent.entry_kind !== "directory") return false;
parentId = parent.parent_occurrence_id;
}
return true;
});
if (requestedIds === undefined) return { available, selected: available };
if (requestedIds.length === 0)
throw invalidSelection(
"An explicit occurrence_ids selection must not be empty.",
);
const requested = new Set(requestedIds);
if (requested.size !== requestedIds.length)
throw invalidSelection("Occurrence IDs must be unique.");
const activeIds = new Set(
available.map(({ occurrence_id }) => occurrence_id),
);
for (const id of requested) {
const occurrence = byId.get(id);
if (occurrence === undefined)
throw invalidSelection(
`Occurrence is not in the current artifact inventory: ${id}. Inspect the active artifact again before selecting entries.`,
);
if (!activeIds.has(id))
throw invalidSelection(
`Occurrence is not a regular file or slice exposed by the active reader: ${occurrence.logical_path} (${id}). Open a containing archive separately to extract its nested members.`,
);
}
return {
available,
selected: available.filter(({ occurrence_id }) =>
requested.has(occurrence_id),
),
};
};
const invalidSelection = (message: string): AnalysisInputError =>
new AnalysisInputError("extract_artifact", undefined, [
{
path: ["occurrence_ids"],
reason: "invalid_value",
message,
},
]);
+20 -2
View File
@@ -17,6 +17,8 @@ import { localPathStringSchema } from "../domain/localPath.js";
import { z } from "incur";
import { cliDecimalField } from "./options.js";
import { artifactExtractionInputSchema } from "../contracts/artifactToolContracts.js";
import type { DirectAnalysis } from "../composition/directAnalysis.js";
import { createArtifactExtractionDestination } from "../application/artifacts/ArtifactExtractionDestination.js";
import { registerArtifactComparisonCommand } from "./artifactComparisonCommand.js";
@@ -246,11 +248,22 @@ const registerExtractionCommand = (
): void => {
cli.command(CLI_COMMANDS.extractArtifact, {
description:
"Extract regular contents of supported artifacts; DMG extraction is unavailable",
"Extract all or selected regular artifact contents safely; DMG extraction is unavailable",
args: z.object({
path: localPathStringSchema.describe("Application or package path"),
}),
options: z.object({
// Incur needs an ordinary optional; keep the owning contract's metadata.
outputRoot: artifactExtractionInputSchema.shape.output_root
.unwrap()
.optional()
.meta(artifactExtractionInputSchema.shape.output_root.meta() ?? {}),
occurrenceId: artifactExtractionInputSchema.shape.occurrence_ids
.unwrap()
.optional()
.describe(
"Inventory occurrence ID to extract (repeatable); omit to extract all regular entries",
),
integrityPolicy: z
.enum(["fail", "record-and-continue"])
.default("fail")
@@ -264,7 +277,12 @@ const registerExtractionCommand = (
"extract_artifact",
{
integrity_policy: options.integrityPolicy,
output_root: createArtifactExtractionDestination(),
output_root: createArtifactExtractionDestination(
options.outputRoot,
),
...(options.occurrenceId === undefined
? {}
: { occurrence_ids: options.occurrenceId }),
},
logger,
undefined,
+24 -5
View File
@@ -1,6 +1,8 @@
import { requireOutputSchema } from "./toolOutputSchemaPrimitives.js";
import { z } from "zod";
import { artifactOccurrenceSchema } from "../domain/artifactGraph.js";
import type { ToolContract } from "./toolContractTypes.js";
import { artifactOutputSchemas } from "./toolOutputSchemaGroups.js";
import { artifactIntegrityPolicySchema } from "../domain/artifactIntegrityPolicy.js";
@@ -9,7 +11,7 @@ import { toolContractMetadata } from "./toolEffects.js";
import { appleAssetCatalogInputSchema } from "../domain/apple/appleAssetCatalog.js";
import { dylibResolutionInputSchema } from "../domain/apple/dylibResolution.js";
import { keyedArchiveInputSchema } from "../domain/apple/keyedArchive.js";
import { localPathStringSchema } from "../domain/localPath.js";
import { absoluteLocalPathStringSchema } from "../domain/localPath.js";
import { analysisDetailSchema } from "./analysisDetail.js";
/** Exact caller boundary for deterministic artifact inventory. */
export const artifactInventoryInputSchema = z.strictObject({
@@ -27,15 +29,32 @@ export const artifactInspectionInputSchema =
),
});
/** Extraction needs no selector: it materializes every regular child file. */
/** Omitted selection preserves complete extraction of the active reader. */
export const artifactExtractionInputSchema = z.strictObject({
integrity_policy: artifactIntegrityPolicySchema,
output_root: absoluteLocalPathStringSchema
.exactOptional()
.describe(
"Absolute destination path on the REA host. It must not exist and its parent must exist. Omit to use a fresh temporary directory. Successful output persists after the call.",
),
occurrence_ids: z
.array(artifactOccurrenceSchema.shape.occurrence_id)
.min(1)
.refine(
(ids) => new Set(ids).size === ids.length,
"Occurrence IDs must be unique",
)
.meta({ uniqueItems: true })
.exactOptional()
.describe(
"Exact regular-file or slice occurrence IDs from the current inventory. Omit to extract all regular entries exposed by the active reader. Directories, links, the root and members inside nested archives cannot be selected.",
),
});
/** Provider input containing the destination chosen by the local adapter. */
/** Provider input after the adapter has supplied the optional destination. */
export const artifactExtractionExecutionSchema =
artifactExtractionInputSchema.extend({
output_root: localPathStringSchema,
output_root: artifactExtractionInputSchema.shape.output_root.unwrap(),
});
const exampleInputSchema = z.record(z.string(), jsonValueSchema);
@@ -89,7 +108,7 @@ export const ARTIFACT_TOOL_CONTRACTS = [
),
artifact(
"extract_artifact",
"Extract regular files (including Node.js SEA scripts and assets) exposed by a supported active archive reader into a fresh temporary directory chosen by REA. A nested archive exposed as a file is copied as a file; inspect and extract it separately to unpack its contents. The result includes its location. DMG extraction is unavailable; use a caller-managed read-only mount on macOS to access its files. Rejects traversal and symlink escapes, never overwrites, and verifies cleanup. Integrity mismatches fail by default; record-and-continue extracts the observed bytes and returns each mismatch as an explicitly untrusted integrity contradiction.",
"Extract all or caller-selected regular files (including Node.js SEA scripts and assets) exposed by a supported active archive reader. Optionally select exact inventory occurrence_ids and an absent absolute output_root on the REA host; otherwise use a fresh temporary directory. The result records selected IDs and the actual output location; successful files persist after the call. Nested archive files are copied as files; inspect and extract them separately to unpack their contents. DMG extraction is unavailable; use a caller-managed read-only mount on macOS. Rejects traversal and symlink escapes, never overwrites, and verifies cleanup. Inventory integrity checks still cover the full input: mismatches fail by default; record-and-continue keeps extracted contradictions explicitly untrusted.",
artifactExtractionInputSchema,
),
artifact(
+12 -5
View File
@@ -1,6 +1,9 @@
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import { ARTIFACT_TOOL_CONTRACTS } from "../contracts/artifactToolContracts.js";
import {
ARTIFACT_TOOL_CONTRACTS,
artifactExtractionInputSchema,
} from "../contracts/artifactToolContracts.js";
import { createArtifactExtractionDestination } from "../application/artifacts/ArtifactExtractionDestination.js";
import { registerEvidenceTools } from "./registerEvidenceTools.js";
import type { EvidenceToolFamilyOptions } from "./retainedSummaryDelivery.js";
@@ -12,9 +15,13 @@ export const registerArtifactTools = (
): void => {
registerEvidenceTools(server, ARTIFACT_TOOL_CONTRACTS, {
...options,
parametersForExecution: (operation, parameters) =>
operation === "extract_artifact"
? { ...parameters, output_root: createArtifactExtractionDestination() }
: parameters,
parametersForExecution: (operation, parameters) => {
if (operation !== "extract_artifact") return parameters;
const execution = artifactExtractionInputSchema.parse(parameters);
return {
...execution,
output_root: createArtifactExtractionDestination(execution.output_root),
};
},
});
};
@@ -0,0 +1,204 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { lstat, readFile, readdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { expect, it } from "vitest";
import { artifactMcpCall } from "../../scripts/lib/artifact-e2e.mjs";
import {
artifactExtractionResultSchema,
artifactInventoryResultSchema,
} from "../../src/domain/artifactGraph.js";
import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js";
import { parseEvidence } from "../../src/domain/evidence.js";
import { advertisedMcpToolSchemas } from "../fixtures/advertisedMcpTool.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
const execute = promisify(execFile);
const cli = fileURLToPath(new URL("../../scripts/rea.mjs", import.meta.url));
const setup = async () => {
const root = await createTestTempDirectory(
"rea-public-extraction-selection-",
);
const path = join(root, "fixture.zip");
const writer = new ZipWriter(new Uint8ArrayWriter());
await writer.add("lib/selected.so", new TextReader("native fixture"));
await writer.add("lib/other.so", new TextReader("other architecture"));
await writer.add("res/2f.xml", new TextReader("lower"));
await writer.add("res/2F.xml", new TextReader("upper"));
const bytes = await writer.close();
await writeFile(path, bytes);
const environment = {
PATH: process.env.PATH ?? "",
HOME: root,
USERPROFILE: root,
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "auto",
};
const run = async (command: string, flags: readonly string[] = []) => {
const { stdout } = await execute(
process.execPath,
[cli, command, path, ...flags, "--json"],
{
env: environment,
timeout: 30000,
maxBuffer: 16 * 1024 * 1024,
},
);
return parseEvidence(JSON.parse(stdout));
};
const inspected = artifactInspectionResultSchema.parse(
(await run("inspect-artifact")).normalized_result,
);
const inventoryEvidence = inspected.substeps.find(
({ evidence }) => evidence.operation === "inventory_artifact",
)?.evidence;
if (inventoryEvidence === undefined)
throw new Error("Missing inventory Evidence");
const inventory = artifactInventoryResultSchema.parse(
inventoryEvidence.normalized_result,
);
const occurrence = (path: string) => {
const entry = inventory.occurrences.find(
({ logical_path }) => logical_path === path,
);
if (entry === undefined) throw new Error(`Missing occurrence: ${path}`);
return entry.occurrence_id;
};
return { root, path, bytes, environment, run, inventory, occurrence };
};
it("extracts repeatable CLI selections into the exact requested destination", async () => {
const data = await setup();
const ids = [
data.occurrence("lib/selected.so"),
data.occurrence("lib/other.so"),
];
const output = join(data.root, "chosen-output");
const evidence = await data.run("extract-artifact", [
"--output-root",
output,
...ids.flatMap((id) => ["--occurrence-id", id]),
]);
const result = artifactExtractionResultSchema.parse(
evidence.normalized_result,
);
expect(result.output_root).toBe(output);
expect(result.artifacts.map(({ relative_path }) => relative_path)).toEqual([
"lib/other.so",
"lib/selected.so",
]);
expect(result.extraction_manifest.selected_occurrence_ids).toEqual(
[...ids].sort(),
);
expect(result.manifest).toEqual(data.inventory.manifest);
expect(await readdir(output)).toEqual(["lib"]);
expect(await readFile(join(output, "lib/selected.so"), "utf8")).toBe(
"native fixture",
);
});
it("keeps CLI/MCP selection identity, invalid-input refusals and committed output after close", async () => {
const data = await setup();
const id = data.occurrence("lib/selected.so");
const cliResult = artifactExtractionResultSchema.parse(
(
await data.run("extract-artifact", [
"--output-root",
join(data.root, "cli-output"),
"--occurrence-id",
id,
])
).normalized_result,
);
const transport = new StdioClientTransport({
command: process.execPath,
args: [cli, "mcp"],
env: {
...data.environment,
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
REA_MCP_TOOL_FAMILIES: "artifact,session",
},
stderr: "pipe",
});
const client = new Client({
name: "extraction-selection-test",
version: "1",
});
const output = join(data.root, "mcp-output");
try {
await client.connect(transport);
const opened = await artifactMcpCall(client, {
name: "open_binary",
arguments: { path: data.path },
});
expect(opened.isError).not.toBe(true);
const schemas = await advertisedMcpToolSchemas(client, "extract_artifact");
for (const occurrence_ids of [[id], [id, data.occurrence("lib/other.so")]])
expect(
schemas.ajv.validate(schemas.inputSchema, {
output_root: output,
occurrence_ids,
}),
JSON.stringify(schemas.ajv.errors),
).toBe(true);
expect(
schemas.ajv.validate(schemas.inputSchema, {
output_root: output,
occurrence_ids: [id, id],
}),
).toBe(false);
expect(schemas.ajv.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({ keyword: "uniqueItems" }),
]),
);
const reply = await artifactMcpCall(client, {
name: "extract_artifact",
arguments: { output_root: output, occurrence_ids: [id] },
});
expect(reply.isError).not.toBe(true);
const evidence = parseEvidence(reply.structuredContent);
const result = artifactExtractionResultSchema.parse(
evidence.normalized_result,
);
expect(result.output_root).toBe(output);
expect(result.extraction_manifest).toEqual(cliResult.extraction_manifest);
expect(result.artifacts).toEqual(cliResult.artifacts);
for (const [index, occurrence_ids] of [
[`occ_${"0".repeat(64)}`],
[id, id],
].entries()) {
const invalidOutput = join(data.root, `invalid-output-${index}`);
const invalid = await artifactMcpCall(client, {
name: "extract_artifact",
arguments: { output_root: invalidOutput, occurrence_ids },
});
expect(invalid.isError).toBe(true);
await expect(lstat(invalidOutput)).rejects.toMatchObject({
code: "ENOENT",
});
}
const closed = await artifactMcpCall(client, {
name: "close_binary",
arguments: {},
});
expect(closed.isError).not.toBe(true);
} finally {
await client.close();
await transport.close();
}
expect(await readFile(join(output, "lib/selected.so"), "utf8")).toBe(
"native fixture",
);
const digest = (bytes: Uint8Array) =>
createHash("sha256").update(bytes).digest("hex");
expect(digest(await readFile(data.path))).toBe(digest(data.bytes));
});
@@ -108,6 +108,12 @@ const inputCases = (
return [
pathCase("open_binary", "path", { path: absolute }, { path: relative }),
pathCase(
"extract_artifact",
"output_root",
{ output_root: absolute },
{ output_root: relative },
),
pathCase(
"analyze_javascript_application",
"input_path",
+13 -1
View File
@@ -1,4 +1,4 @@
import { readFile, rm, writeFile } from "node:fs/promises";
import { lstat, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { parseEvidence } from "../../../src/domain/evidence.js";
@@ -6,6 +6,7 @@ import { artifactInspectionResultSchema } from "../../../src/domain/artifactInsp
import { artifactExtractionResultSchema } from "../../../src/domain/artifactGraph.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { buildUnityFs } from "../../fixtures/unityfs.js";
it.each(["full", "compact"] as const)(
@@ -68,6 +69,17 @@ it.each(["full", "compact"] as const)(
},
},
]);
const refusedOutput = join(root, "unsupported-output");
const refused = await call("extract_artifact", {
output_root: refusedOutput,
});
expect(parseMcpToolError(refused).error).toMatchObject({
code: "unsupported_target",
details: { operation: "extract_artifact", path },
});
await expect(lstat(refusedOutput)).rejects.toMatchObject({
code: "ENOENT",
});
await call("close_binary", {});
await writeFile(
path,
@@ -0,0 +1,61 @@
import {
lstat,
mkdir,
readFile,
readdir,
symlink,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js";
import {
CPU,
machoImage,
} from "../../../../src/artifacts/apple/MachoImage.fixture.js";
import { withArtifactClient } from "../../../support/artifactClientScope.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
it.each(["direct", "symlink"] as const)(
"refuses a %s output path inside the source without modifying caller files",
async (kind) => {
const root = await createTestTempDirectory(
"rea-extraction-source-boundary-",
);
const source = join(root, "source.app");
await mkdir(source);
await writeFile(join(source, "payload.txt"), "caller bytes");
await writeFile(
join(source, "Info.plist"),
'<plist version="1.0"><dict><key>CFBundleExecutable</key><string>Main</string></dict></plist>',
);
await writeFile(join(source, "Main"), machoImage({ cpu: CPU.arm64 }));
const target = await parseBinaryTarget(source);
if (!target.ok) throw target.error;
let parent = source;
if (kind === "symlink") {
parent = join(root, "alias");
await symlink(
source,
parent,
process.platform === "win32" ? "junction" : "dir",
);
}
const output = join(parent, "output");
const result = await withArtifactClient(target.value, (client) =>
client.execute("extract_artifact", { output_root: output }),
);
expect(result).toMatchObject({ ok: false, error: { reason: "path" } });
expect((await readdir(source)).sort()).toEqual([
"Info.plist",
"Main",
"payload.txt",
]);
expect(await readFile(join(source, "payload.txt"), "utf8")).toBe(
"caller bytes",
);
await expect(lstat(output)).rejects.toMatchObject({ code: "ENOENT" });
},
);
@@ -0,0 +1,625 @@
import {
lstat,
mkdir,
readFile,
readdir,
symlink,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { Readable } from "node:stream";
import { createPackage } from "@electron/asar";
import {
TextReader,
Uint8ArrayReader,
Uint8ArrayWriter,
ZipWriter,
} from "@zip.js/zip.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js";
import { createArtifactExtractionDestination } from "../../../../src/application/artifacts/ArtifactExtractionDestination.js";
import {
artifactExtractionInputSchema,
artifactExtractionExecutionSchema,
} from "../../../../src/contracts/artifactToolContracts.js";
import {
artifactExtractionResultSchema,
artifactInventoryResultSchema,
} from "../../../../src/domain/artifactGraph.js";
import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js";
import type { ArtifactEntry } from "../../../../src/artifacts/ArtifactReader.js";
import { ZipArtifactReader } from "../../../../src/artifacts/ZipArtifactReader.js";
import {
cArchiveExecutable,
pyzArchive,
PYTHON_39_MAGIC,
type CArchiveFixtureMember,
} from "../../../../src/artifacts/pyinstaller/CArchive.fixture.js";
import { selectArtifactExtractionOccurrences } from "../../../../src/artifacts/extraction/ArtifactExtractionSelection.js";
import { withArtifactClient } from "../../../support/artifactClientScope.js";
import {
materializeArtifactInventory,
scanArtifactInventory,
} from "../../../fixtures/artifactInventory.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
afterEach(() => vi.restoreAllMocks());
const archive = async (
entries: readonly (readonly [string, string | Uint8Array])[],
) => {
const writer = new ZipWriter(new Uint8ArrayWriter());
for (const [path, value] of entries)
await writer.add(
path,
typeof value === "string"
? new TextReader(value)
: new Uint8ArrayReader(value),
);
return writer.close();
};
const fixture = async () => {
const root = await createTestTempDirectory("rea-selected-extraction-");
const path = join(root, "fixture.zip");
await writeFile(
path,
await archive([
["lib/arm64-v8a/selected.so", "native fixture"],
["lib/x86_64/other.so", "native fixture"],
["res/2f.xml", "lower"],
["res/2F.xml", "upper"],
]),
);
const parsed = await parseBinaryTarget(path);
if (!parsed.ok) throw parsed.error;
const inventory = await inventoryOf(parsed.value);
const selected = inventory.occurrences.find(
({ logical_path }) => logical_path === "lib/arm64-v8a/selected.so",
);
if (selected === undefined)
throw new Error("Selected fixture occurrence missing");
return { root, path, target: parsed.value, inventory, selected };
};
const inventoryOf = async (target: BinaryTarget) => {
const result = await withArtifactClient(target, (client) =>
client.execute("inventory_artifact", {}),
);
if (!result.ok) throw result.error;
return artifactInventoryResultSchema.parse(result.value.result);
};
const extract = (
target: BinaryTarget,
output: string,
ids?: readonly string[],
signal?: AbortSignal,
) =>
withArtifactClient(target, (client) =>
client.execute(
"extract_artifact",
artifactExtractionExecutionSchema.parse({
output_root: output,
...(ids === undefined ? {} : { occurrence_ids: ids }),
}),
signal === undefined ? undefined : { signal },
),
);
const pyInstallerFixture = async (
members: readonly CArchiveFixtureMember[],
) => {
const root = await createTestTempDirectory("rea-pyinstaller-selection-");
const path = join(root, "fixture");
await writeFile(
path,
cArchiveExecutable({ layout: "2.1", pythonVersion: 39, members }),
);
const target = await parseBinaryTarget(path);
if (!target.ok) throw target.error;
return {
root,
target: target.value,
inventory: await inventoryOf(target.value),
};
};
describe("selected PyInstaller destination paths", () => {
it.each(["sibling.txt", "PYZ.pyz", "PYZ.pyz/member.txt", "all"])(
"checks directory/file collisions only for selected output: %s",
async (selection) => {
const contents = new Map([
["PYZ.pyz", "independent file"],
["PYZ.pyz/member.txt", "inner data"],
["sibling.txt", "sibling data"],
]);
const data = await pyInstallerFixture([
{
name: "PYZ.pyz",
typeCode: "z",
payload: pyzArchive({
magic: PYTHON_39_MAGIC,
modules: [
{
name: "member.txt",
typeCode: 2,
body: Buffer.from("inner data"),
},
],
}),
compress: false,
},
...["PYZ.pyz", "sibling.txt"].map((name) => ({
name,
typeCode: "x",
payload: Buffer.from(contents.get(name)!),
compress: false,
})),
]);
expect(
data.inventory.occurrences
.filter(({ logical_path }) => logical_path === "PYZ.pyz")
.map(({ entry_kind }) => entry_kind),
).toEqual(["directory", "file"]);
const selected = data.inventory.occurrences.filter(
({ entry_kind, logical_path }) =>
entry_kind === "file" && logical_path === selection,
);
if (selection !== "all") expect(selected).toHaveLength(1);
const output = join(data.root, "output");
const result = await extract(
data.target,
output,
selection === "all"
? undefined
: selected.map(({ occurrence_id }) => occurrence_id),
);
if (selection === "all") {
expect(result).toMatchObject({ ok: false, error: { reason: "path" } });
await expect(lstat(output)).rejects.toMatchObject({ code: "ENOENT" });
return;
}
if (!result.ok) throw result.error;
const value = artifactExtractionResultSchema.parse(result.value.result);
expect(value.manifest).toEqual(data.inventory.manifest);
expect(value.artifacts.map(({ relative_path }) => relative_path)).toEqual(
[selection],
);
expect(await readFile(join(output, selection), "utf8")).toBe(
contents.get(selection),
);
expect(await readdir(output)).toEqual([selection.split("/")[0]]);
},
);
it.each(["assets", "assets/item", "both"])(
"keeps independent prefix-sharing TOC entries selectable: %s",
async (selection) => {
const contents = new Map([
["assets", "parent name bytes"],
["assets/item", "child name bytes"],
]);
const data = await pyInstallerFixture(
[...contents].map(([name, bytes]) => ({
name,
typeCode: "x",
payload: Buffer.from(bytes),
compress: false,
})),
);
const entries = data.inventory.occurrences.filter(
({ entry_kind, logical_path }) =>
entry_kind === "file" && logical_path !== ".",
);
expect(entries.map(({ logical_path }) => logical_path)).toEqual([
"assets",
"assets/item",
]);
expect(entries[0]?.parent_occurrence_id).toBe(
entries[1]?.parent_occurrence_id,
);
const selected = entries.filter(
({ logical_path }) =>
selection === "both" || logical_path === selection,
);
const output = join(data.root, "output");
const result = await extract(
data.target,
output,
selected.map(({ occurrence_id }) => occurrence_id),
);
if (selection === "both") {
expect(result).toMatchObject({ ok: false, error: { reason: "path" } });
await expect(lstat(output)).rejects.toMatchObject({ code: "ENOENT" });
return;
}
if (!result.ok) throw result.error;
const value = artifactExtractionResultSchema.parse(result.value.result);
expect(value.artifacts.map(({ relative_path }) => relative_path)).toEqual(
[selection],
);
expect(value.extraction_manifest.selected_occurrence_ids).toEqual([
selected[0]?.occurrence_id,
]);
expect(await readFile(join(output, selection), "utf8")).toBe(
contents.get(selection),
);
expect(await readdir(output)).toEqual(["assets"]);
},
);
});
describe("duplicate artifact occurrence selection", () => {
it.each(["first", "second", "excluded", "both"] as const)(
"preserves duplicate ZIP occurrence identity when %s entries are selected",
async (selection) => {
const root = await createTestTempDirectory("rea-duplicate-selection-");
const path = join(root, "duplicates.zip");
const bytes = Buffer.from(
await archive([
["first.txt", "first bytes"],
["other.txt", "second bytes"],
["keep.txt", "unrelated bytes"],
]),
);
// Both local and central-directory names keep their original byte length.
for (
let offset = bytes.indexOf("other.txt");
offset !== -1;
offset = bytes.indexOf("other.txt", offset + 9)
)
bytes.write("first.txt", offset);
await writeFile(path, bytes);
const target = await parseBinaryTarget(path);
if (!target.ok) throw target.error;
const inventory = await inventoryOf(target.value);
const duplicates = inventory.occurrences.filter(
({ logical_path }) => logical_path === "first.txt",
);
expect(duplicates).toHaveLength(2);
expect(
new Set(duplicates.map(({ occurrence_id }) => occurrence_id)).size,
).toBe(2);
const keep = inventory.occurrences.find(
({ logical_path }) => logical_path === "keep.txt",
);
if (keep === undefined) throw new Error("Missing unrelated occurrence");
const ids =
selection === "excluded"
? [keep.occurrence_id]
: selection === "both"
? duplicates.map(({ occurrence_id }) => occurrence_id)
: [duplicates[selection === "first" ? 0 : 1]!.occurrence_id];
const output = join(root, "selected");
const result = await extract(target.value, output, ids);
if (selection === "both") {
expect(result.ok).toBe(false);
await expect(lstat(output)).rejects.toMatchObject({ code: "ENOENT" });
return;
}
if (!result.ok) throw result.error;
const value = artifactExtractionResultSchema.parse(result.value.result);
expect(value.extraction_manifest.selected_occurrence_ids).toEqual(ids);
const name = selection === "excluded" ? "keep.txt" : "first.txt";
expect(await readdir(output)).toEqual([name]);
expect(await readFile(join(output, name), "utf8")).toBe(
selection === "excluded"
? "unrelated bytes"
: selection === "first"
? "first bytes"
: "second bytes",
);
},
);
});
describe("selected artifact extraction", () => {
it("selects occurrences, not shared content IDs, and avoids unselected name collisions", async () => {
const data = await fixture();
const output = join(data.root, "selected");
const result = await extract(data.target, output, [
data.selected.occurrence_id,
]);
if (!result.ok) throw result.error;
const value = artifactExtractionResultSchema.parse(result.value.result);
expect(value.artifacts.map(({ relative_path }) => relative_path)).toEqual([
"lib/arm64-v8a/selected.so",
]);
expect(value.extraction_manifest.selected_occurrence_ids).toEqual([
data.selected.occurrence_id,
]);
expect(value.manifest).toEqual(data.inventory.manifest);
expect(value.limitations.join(" ")).toContain("Only caller-selected");
expect(
await readFile(join(output, "lib/arm64-v8a/selected.so"), "utf8"),
).toBe("native fixture");
expect(await readdir(output)).toEqual(["lib"]);
await expect(lstat(join(output, "lib/x86_64"))).rejects.toMatchObject({
code: "ENOENT",
});
const other = data.inventory.occurrences.find(
({ logical_path }) => logical_path === "lib/x86_64/other.so",
);
expect(other?.artifact_id).toBe(data.selected.artifact_id);
});
it("keeps selected manifests independent of request order and output location", async () => {
const data = await fixture();
const ids = data.inventory.occurrences
.filter(
({ logical_path }) =>
logical_path.startsWith("lib/") && logical_path.endsWith(".so"),
)
.map(({ occurrence_id }) => occurrence_id);
const one = await extract(data.target, join(data.root, "one"), ids);
const two = await extract(
data.target,
join(data.root, "two"),
[...ids].reverse(),
);
if (!one.ok) throw one.error;
if (!two.ok) throw two.error;
const first = artifactExtractionResultSchema.parse(one.value.result);
const second = artifactExtractionResultSchema.parse(two.value.result);
expect(first.extraction_manifest).toEqual(second.extraction_manifest);
expect(first.artifacts).toEqual(second.artifacts);
});
it("rejects unknown and root IDs before creating the destination", async () => {
const data = await fixture();
const root = data.inventory.occurrences.find(
({ logical_path }) => logical_path === ".",
);
if (root === undefined) throw new Error("Root occurrence missing");
for (const [index, id] of [
`occ_${"f".repeat(64)}`,
root.occurrence_id,
].entries()) {
const output = join(data.root, `invalid-${index}`);
const result = await extract(data.target, output, [id]);
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError" },
});
await expect(lstat(output)).rejects.toMatchObject({ code: "ENOENT" });
}
});
it("does not promote nested inventory records when their containing file is excluded", async () => {
const root = await createTestTempDirectory("rea-nested-selection-");
const source = join(root, "source");
const contents = join(root, "contents");
await mkdir(source);
await mkdir(join(contents, "assets"), { recursive: true });
await writeFile(join(contents, "assets", "nested.txt"), "nested bytes");
await createPackage(contents, join(source, "inner.asar"));
const inventory = await scanArtifactInventory(source);
const container = inventory.occurrences.find(
({ logical_path }) => logical_path === "inner.asar",
);
const nested = inventory.occurrences.find(
({ logical_path }) => logical_path === "inner.asar/assets/nested.txt",
);
if (container === undefined || nested === undefined)
throw new Error("Nested ASAR fixture inventory missing");
const output = join(root, "output");
await expect(
materializeArtifactInventory(
{
inputPath: source,
inputFormat: "asar",
outputRoot: output,
occurrenceIds: [nested.occurrence_id],
environment: process.env,
integrityPolicy: "fail",
},
source,
inventory,
),
).rejects.toMatchObject({
_tag: "AnalysisInputError",
issues: [
expect.objectContaining({
path: ["occurrence_ids"],
reason: "invalid_value",
message: expect.stringContaining(
"not a regular file or slice exposed by the active reader",
),
}),
],
});
await expect(lstat(output)).rejects.toMatchObject({ code: "ENOENT" });
expect(
selectArtifactExtractionOccurrences(inventory.occurrences, [
container.occurrence_id,
]).selected,
).toEqual([container]);
});
});
describe("selected extraction ownership and rollback", () => {
it.each(["missing", "added", "unsafe-directory"] as const)(
"validates excluded entries during materialization: %s",
async (change) => {
const data = await fixture();
const output = join(data.root, "changed-inventory");
const entries = ZipArtifactReader.prototype.entries;
let traversals = 0;
vi.spyOn(ZipArtifactReader.prototype, "entries").mockImplementation(
async function* (
this: ZipArtifactReader,
signal,
): AsyncIterable<ArtifactEntry> {
const materializing = ++traversals === 2;
for await (const entry of entries.call(this, signal)) {
if (!materializing || entry.path !== "res/2f.xml") {
yield entry;
continue;
}
if (change === "missing") continue;
yield entry;
yield {
...entry,
path: change === "added" ? "new.txt" : "../unsafe",
kind: change === "added" ? "file" : "directory",
};
}
},
);
const result = await extract(data.target, output, [
data.selected.occurrence_id,
]);
expect(traversals).toBe(2);
expect(result).toMatchObject({
ok: false,
error: { reason: change === "unsafe-directory" ? "path" : "integrity" },
});
await expect(lstat(output)).rejects.toMatchObject({ code: "ENOENT" });
},
);
it.skipIf(process.platform === "win32")(
"reports the actual output selected through symlink/.. traversal",
async () => {
const data = await fixture();
const nested = join(data.root, "deep", "nested");
await mkdir(nested, { recursive: true });
const alias = join(data.root, "alias");
await symlink(nested, alias, "dir");
const requested = `${alias}/../chosen`;
const result = await extract(data.target, requested, [
data.selected.occurrence_id,
]);
if (!result.ok) throw result.error;
const value = artifactExtractionResultSchema.parse(result.value.result);
expect(value.output_root).toBe(join(data.root, "deep", "chosen"));
expect(
await readFile(`${requested}/${data.selected.logical_path}`, "utf8"),
).toBe("native fixture");
expect(
await readFile(
join(value.output_root, data.selected.logical_path),
"utf8",
),
).toBe("native fixture");
await expect(lstat(join(data.root, "chosen"))).rejects.toMatchObject({
code: "ENOENT",
});
},
);
it.skipIf(process.platform === "win32")(
"refuses missing/.. output parents before creating any destination",
async () => {
const data = await fixture();
const requested = `${data.root}/missing/../chosen`;
const result = await extract(data.target, requested, [
data.selected.occurrence_id,
]);
expect(result.ok).toBe(false);
await expect(lstat(join(data.root, "chosen"))).rejects.toMatchObject({
code: "ENOENT",
});
},
);
it("never overwrites a caller-owned destination", async () => {
const data = await fixture();
const output = join(data.root, "existing");
await mkdir(output);
await writeFile(join(output, "keep.txt"), "caller data");
const result = await extract(data.target, output, [
data.selected.occurrence_id,
]);
expect(result).toMatchObject({ ok: false, error: { reason: "path" } });
expect(await readFile(join(output, "keep.txt"), "utf8")).toBe(
"caller data",
);
expect(await readdir(output)).toEqual(["keep.txt"]);
});
it("honors cancellation before selection or output creation", async () => {
const data = await fixture();
const output = join(data.root, "cancelled");
const controller = new AbortController();
controller.abort();
const result = await extract(
data.target,
output,
[data.selected.occurrence_id],
controller.signal,
);
expect(result).toMatchObject({ ok: false, error: { reason: "cancelled" } });
await expect(lstat(output)).rejects.toMatchObject({ code: "ENOENT" });
});
it("rolls back a selected output if bytes change between inventory and extraction", async () => {
const data = await fixture();
const output = join(data.root, "changed");
const original = ZipArtifactReader.prototype.open;
let selectedReads = 0;
vi.spyOn(ZipArtifactReader.prototype, "open").mockImplementation(function (
this: ZipArtifactReader,
entry,
signal,
) {
if (entry.path === data.selected.logical_path && ++selectedReads === 2)
return Promise.resolve(Readable.from([Buffer.from("changed bytes")]));
return original.call(this, entry, signal);
});
const result = await extract(data.target, output, [
data.selected.occurrence_id,
]);
expect(result).toMatchObject({ ok: false, error: { reason: "integrity" } });
await expect(lstat(output)).rejects.toMatchObject({ code: "ENOENT" });
});
it("preserves default all-file selection before applying any filter", async () => {
const data = await fixture();
const selection = selectArtifactExtractionOccurrences(
data.inventory.occurrences,
);
expect(selection.selected).toBe(selection.available);
expect(selection.selected).toHaveLength(4);
expect(() =>
selectArtifactExtractionOccurrences(data.inventory.occurrences, []),
).toThrow();
expect(() =>
selectArtifactExtractionOccurrences(data.inventory.occurrences, [
data.selected.occurrence_id,
data.selected.occurrence_id,
]),
).toThrow();
});
});
describe("extraction caller contract", () => {
it("keeps the temporary default and preserves an explicit absolute root", () => {
const explicit = join(process.cwd(), "chosen-output");
expect(createArtifactExtractionDestination(explicit)).toBe(explicit);
expect(createArtifactExtractionDestination()).not.toBe(
createArtifactExtractionDestination(),
);
expect(artifactExtractionInputSchema.parse({})).toEqual({
integrity_policy: "fail",
});
expect(
artifactExtractionInputSchema.parse({ output_root: explicit })
.output_root,
).toBe(explicit);
});
it.each([
{ output_root: "relative" },
{ output_root: "" },
{ output_root: "/invalid\0path" },
{ occurrence_ids: [] },
{ occurrence_ids: ["not-an-occurrence"] },
{ occurrence_ids: [`occ_${"1".repeat(64)}`, `occ_${"1".repeat(64)}`] },
])("rejects invalid extraction options: %j", (input) => {
expect(artifactExtractionInputSchema.safeParse(input).success).toBe(false);
});
});