refactor(artifacts): select extraction by logical path
This commit is contained in:
parent
dca293a446
commit
7ba3580c86
|
|
@ -144,23 +144,22 @@ represented by metadata only.
|
|||
|
||||
## Bounds and coverage
|
||||
|
||||
The input schema bounds artifact entries, cumulative artifact bytes, bytes per
|
||||
entry, compression ratio, path depth and length, selected text files, text bytes,
|
||||
AST nodes, static findings, bundle modules, source-map originals, and cooperative
|
||||
parse time. Source-map original limits are shared across all maps in one
|
||||
reconstruction. Combined input limits are rejected if their conservative graph
|
||||
projection could exceed the v1 contract's 100,000 nodes or 200,000 edges.
|
||||
The reconstruction keeps internal resource safeguards for artifact entries,
|
||||
cumulative artifact bytes, bytes per entry, compression ratio, path depth and
|
||||
length, selected text files, text bytes, AST nodes, and cooperative parse time.
|
||||
These safeguards are not caller-selectable output budgets. Source-map parsing
|
||||
reports truncation when its format or parser safety boundary is reached. The
|
||||
application graph has no aggregate node, edge, root, or observation prefix cap.
|
||||
|
||||
Byte, entry, path, and graph-shape bounds are hard limits. The parse deadline is
|
||||
checked before and between bounded parsing and traversal phases; the synchronous
|
||||
Babel and JSON parser calls cannot be preempted mid-call, so their input byte
|
||||
bounds remain the hard protection for an individual call.
|
||||
|
||||
Byte-identical assets share a content-digest node. At most 64 distinct
|
||||
observations are retained on that node, as required by the graph contract; every
|
||||
inventoried containment edge remains present, and any omitted observations make
|
||||
top-level coverage partial and truncated. When an exact omission count is not
|
||||
knowable, `omitted_count` is `null` rather than a guessed value.
|
||||
Byte-identical assets share a content-digest node. Every distinct observation
|
||||
and inventoried containment edge is retained. If parsing or artifact safety
|
||||
boundaries omit input, coverage reports that omission; when an exact omission
|
||||
count is not knowable, `omitted_count` is `null` rather than a guessed value.
|
||||
|
||||
## Verification boundary
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { join } from "node:path";
|
||||
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
|
||||
|
|
@ -126,37 +126,41 @@ const verifyPackagedArtifactExtraction = async ({
|
|||
artifactInventory,
|
||||
cli,
|
||||
environment,
|
||||
workspace,
|
||||
}) => {
|
||||
const occurrence = artifactInventory?.occurrences?.items?.find(
|
||||
({ logical_path: path }) => path === "app/main.js",
|
||||
);
|
||||
if (occurrence?.occurrence_id === undefined)
|
||||
if (occurrence?.logical_path === undefined)
|
||||
throw new Error("packaged artifact inventory omitted the selected member");
|
||||
const outputRoot = join(workspace, "artifact-extraction");
|
||||
const extraction = json(
|
||||
await run(
|
||||
cli,
|
||||
[
|
||||
"extract-artifact",
|
||||
artifactArchive,
|
||||
outputRoot,
|
||||
"--occurrence-ids",
|
||||
occurrence.occurrence_id,
|
||||
"--paths",
|
||||
occurrence.logical_path,
|
||||
"--json",
|
||||
],
|
||||
environment,
|
||||
),
|
||||
);
|
||||
if (
|
||||
extraction.operation !== "extract_artifact" ||
|
||||
extraction.provider?.id !== "rea-artifact-graph" ||
|
||||
extraction.normalized_result?.containment_verified !== true ||
|
||||
extraction.normalized_result?.artifacts?.items?.[0]?.relative_path !==
|
||||
"app/main.js" ||
|
||||
(await readFile(join(outputRoot, "app/main.js"), "utf8")) !== "main();"
|
||||
)
|
||||
throw new Error("packaged artifact extraction CLI failed");
|
||||
const outputRoot = extraction.normalized_result?.output_root;
|
||||
try {
|
||||
if (
|
||||
extraction.operation !== "extract_artifact" ||
|
||||
extraction.provider?.id !== "rea-artifact-graph" ||
|
||||
extraction.normalized_result?.containment_verified !== true ||
|
||||
extraction.normalized_result?.artifacts?.items?.[0]?.relative_path !==
|
||||
"app/main.js" ||
|
||||
typeof outputRoot !== "string" ||
|
||||
(await readFile(join(outputRoot, "app/main.js"), "utf8")) !== "main();"
|
||||
)
|
||||
throw new Error("packaged artifact extraction CLI failed");
|
||||
} finally {
|
||||
if (typeof outputRoot === "string")
|
||||
await rm(outputRoot, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
const assertRoutedApplicationAnalysis = (analysis) => {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ export interface ArtifactExtractionInput {
|
|||
readonly inputPath: string;
|
||||
readonly inputFormat: BinaryTarget["format"];
|
||||
readonly outputRoot: string;
|
||||
readonly occurrenceIds: readonly string[];
|
||||
readonly selection:
|
||||
| { readonly paths: readonly string[] }
|
||||
| { readonly occurrenceIds: readonly string[] };
|
||||
readonly limits: ArtifactLimits;
|
||||
}
|
||||
|
||||
|
|
@ -43,22 +45,34 @@ export const extractArtifact = async (
|
|||
input: ArtifactExtractionInput,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ArtifactExtractionResult> => {
|
||||
validateSelection(input.occurrenceIds);
|
||||
validateSelection(input.selection, input.limits);
|
||||
const sourcePath = await realpath(input.inputPath);
|
||||
const selectedIds = new Set(input.occurrenceIds);
|
||||
const inventory = await loadInventory(
|
||||
sourcePath,
|
||||
input.limits,
|
||||
selectedIds,
|
||||
const snapshot = await scanArtifactInventory(sourcePath, input.limits, {
|
||||
signal,
|
||||
});
|
||||
const selectedOccurrences = resolveSelection(
|
||||
snapshot.occurrences,
|
||||
input.selection,
|
||||
);
|
||||
const selected = input.occurrenceIds.map((id) => {
|
||||
const occurrence = inventory.occurrences.get(id);
|
||||
if (occurrence === undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
`Selected artifact occurrence was not found: ${id}`,
|
||||
);
|
||||
const selectedIds = new Set(
|
||||
selectedOccurrences.map(({ occurrence_id: id }) => id),
|
||||
);
|
||||
const occurrences = new Map<string, ArtifactOccurrence>();
|
||||
const neededNodes = new Set<string>();
|
||||
collectOccurrences(
|
||||
snapshot.occurrences,
|
||||
selectedIds,
|
||||
occurrences,
|
||||
neededNodes,
|
||||
);
|
||||
const nodes = new Map<string, ArtifactNode>();
|
||||
collectNodes(snapshot.nodes, neededNodes, nodes);
|
||||
const inventory: LoadedInventory = {
|
||||
manifest: snapshot.manifest,
|
||||
occurrences,
|
||||
nodes,
|
||||
};
|
||||
const selected = selectedOccurrences.map((occurrence) => {
|
||||
if (
|
||||
(occurrence.entry_kind !== "file" && occurrence.entry_kind !== "slice") ||
|
||||
occurrence.artifact_id === null ||
|
||||
|
|
@ -67,13 +81,13 @@ export const extractArtifact = async (
|
|||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`Selected occurrence is not an extractable regular child file: ${id}`,
|
||||
`Selected occurrence is not an extractable regular child file: ${occurrence.occurrence_id}`,
|
||||
);
|
||||
const node = inventory.nodes.get(occurrence.artifact_id);
|
||||
if (node === undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Selected occurrence has no inventory node: ${id}`,
|
||||
`Selected occurrence has no inventory node: ${occurrence.occurrence_id}`,
|
||||
);
|
||||
return { occurrence, node };
|
||||
});
|
||||
|
|
@ -214,26 +228,6 @@ interface LoadedInventory {
|
|||
readonly nodes: ReadonlyMap<string, ArtifactNode>;
|
||||
}
|
||||
|
||||
const loadInventory = async (
|
||||
path: string,
|
||||
limits: ArtifactLimits,
|
||||
selectedIds: ReadonlySet<string>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LoadedInventory> => {
|
||||
const snapshot = await scanArtifactInventory(path, limits, { signal });
|
||||
const occurrences = new Map<string, ArtifactOccurrence>();
|
||||
const neededNodes = new Set<string>();
|
||||
collectOccurrences(
|
||||
snapshot.occurrences,
|
||||
selectedIds,
|
||||
occurrences,
|
||||
neededNodes,
|
||||
);
|
||||
const nodes = new Map<string, ArtifactNode>();
|
||||
collectNodes(snapshot.nodes, neededNodes, nodes);
|
||||
return { manifest: snapshot.manifest, occurrences, nodes };
|
||||
};
|
||||
|
||||
const collectOccurrences = (
|
||||
items: readonly ArtifactOccurrence[],
|
||||
selected: ReadonlySet<string>,
|
||||
|
|
@ -278,19 +272,79 @@ const createReader = async (
|
|||
);
|
||||
};
|
||||
|
||||
const validateSelection = (ids: readonly string[]): void => {
|
||||
if (ids.length === 0)
|
||||
type ArtifactSelection = ArtifactExtractionInput["selection"];
|
||||
|
||||
const validateSelection = (
|
||||
selection: ArtifactSelection,
|
||||
limits: ArtifactLimits,
|
||||
): void => {
|
||||
const values =
|
||||
"paths" in selection ? selection.paths : selection.occurrenceIds;
|
||||
if (values.length === 0)
|
||||
throw new ArtifactReaderFailure(
|
||||
"limit",
|
||||
"Extraction requires at least one explicitly selected occurrence",
|
||||
"Extraction requires at least one selected path or occurrence",
|
||||
);
|
||||
if (new Set(ids).size !== ids.length)
|
||||
const normalized =
|
||||
"paths" in selection
|
||||
? values.map((path) => normalizeArtifactPath(path, limits))
|
||||
: values;
|
||||
if (new Set(normalized).size !== normalized.length)
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
"Extraction occurrence selection contains duplicates",
|
||||
"Extraction selection contains duplicates",
|
||||
);
|
||||
};
|
||||
|
||||
const resolveSelection = (
|
||||
occurrences: readonly ArtifactOccurrence[],
|
||||
selection: ArtifactSelection,
|
||||
): ArtifactOccurrence[] => {
|
||||
if ("occurrenceIds" in selection) {
|
||||
const byId = new Map(
|
||||
occurrences.map((occurrence) => [occurrence.occurrence_id, occurrence]),
|
||||
);
|
||||
return selection.occurrenceIds.map((id) => {
|
||||
const occurrence = byId.get(id);
|
||||
if (occurrence === undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
`Selected artifact occurrence was not found: ${id}`,
|
||||
);
|
||||
return occurrence;
|
||||
});
|
||||
}
|
||||
|
||||
const byPath = new Map<string, ArtifactOccurrence[]>();
|
||||
for (const occurrence of occurrences) {
|
||||
const matches = byPath.get(occurrence.logical_path);
|
||||
if (matches === undefined)
|
||||
byPath.set(occurrence.logical_path, [occurrence]);
|
||||
else matches.push(occurrence);
|
||||
}
|
||||
return selection.paths.map((path) => {
|
||||
const normalizedPath = path.normalize("NFC");
|
||||
const matches = byPath.get(normalizedPath) ?? [];
|
||||
if (matches.length === 0)
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
`Selected artifact path was not found: ${path}`,
|
||||
);
|
||||
if (matches.length > 1)
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Selected artifact path is ambiguous: ${path}`,
|
||||
);
|
||||
const match = matches[0];
|
||||
if (match === undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
`Selected artifact path was not found: ${path}`,
|
||||
);
|
||||
return match;
|
||||
});
|
||||
};
|
||||
|
||||
const preflight = (entry: ArtifactEntry, limits: ArtifactLimits): void => {
|
||||
if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted)
|
||||
throw new ArtifactReaderFailure(
|
||||
|
|
|
|||
|
|
@ -119,7 +119,10 @@ class ArtifactClient implements AnalysisClient {
|
|||
inputPath: this.target.sourcePath ?? this.target.path,
|
||||
inputFormat: this.target.format,
|
||||
outputRoot: parsed.output_root,
|
||||
occurrenceIds: parsed.occurrence_ids,
|
||||
selection:
|
||||
"paths" in parsed
|
||||
? { paths: parsed.paths }
|
||||
: { occurrenceIds: parsed.occurrence_ids },
|
||||
limits: DEFAULT_ARTIFACT_LIMITS,
|
||||
},
|
||||
options?.signal,
|
||||
|
|
|
|||
|
|
@ -21,12 +21,24 @@ const registerExtractionCommand = (cli: CliInstance, logger: Logger): void => {
|
|||
args: z.object({
|
||||
path: z.string().describe("Application or package path"),
|
||||
}),
|
||||
options: z.object({
|
||||
occurrenceIds: z
|
||||
.array(z.string().regex(/^occ_[a-f0-9]{64}$/u))
|
||||
.min(1)
|
||||
.describe("Exact artifact occurrence IDs selected for extraction"),
|
||||
}),
|
||||
options: z
|
||||
.object({
|
||||
paths: z
|
||||
.array(z.string().min(1))
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Logical artifact paths selected for extraction"),
|
||||
occurrenceIds: z
|
||||
.array(z.string().regex(/^occ_[a-f0-9]{64}$/u))
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Alternate exact occurrence IDs selected for extraction"),
|
||||
})
|
||||
.refine(
|
||||
({ paths, occurrenceIds }) =>
|
||||
(paths === undefined) !== (occurrenceIds === undefined),
|
||||
"Provide paths or occurrence IDs",
|
||||
),
|
||||
alias: { occurrenceIds: "occurrence-ids" },
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "extract-artifact", () =>
|
||||
|
|
@ -35,7 +47,11 @@ const registerExtractionCommand = (cli: CliInstance, logger: Logger): void => {
|
|||
"extract_artifact",
|
||||
{
|
||||
output_root: createArtifactExtractionDestination(),
|
||||
occurrence_ids: options.occurrenceIds,
|
||||
...(options.paths !== undefined
|
||||
? { paths: options.paths }
|
||||
: options.occurrenceIds !== undefined
|
||||
? { occurrence_ids: options.occurrenceIds }
|
||||
: {}),
|
||||
},
|
||||
logger,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -27,19 +27,34 @@ export const artifactInventoryInputSchema = z.union([
|
|||
]);
|
||||
|
||||
/** Exact caller boundary for approved artifact extraction. */
|
||||
export const artifactExtractionInputSchema = z.object({
|
||||
occurrence_ids: z.array(z.string().regex(/^occ_[a-f0-9]{64}$/u)).min(1),
|
||||
const artifactOccurrenceSelectionSchema = z.strictObject({
|
||||
occurrence_ids: z
|
||||
.array(z.string().regex(/^occ_[a-f0-9]{64}$/u))
|
||||
.min(1)
|
||||
.describe("Alternate exact occurrence IDs from inspect_artifact"),
|
||||
});
|
||||
const artifactPathSelectionSchema = z.strictObject({
|
||||
paths: z
|
||||
.array(z.string().min(1))
|
||||
.min(1)
|
||||
.describe("Exact logical_path values from inspect_artifact"),
|
||||
});
|
||||
export const artifactExtractionInputSchema = z.union([
|
||||
artifactPathSelectionSchema,
|
||||
artifactOccurrenceSelectionSchema,
|
||||
]);
|
||||
|
||||
/** Provider input after the local permission boundary chooses its destination. */
|
||||
export const artifactExtractionExecutionSchema =
|
||||
artifactExtractionInputSchema.extend({ output_root: z.string().min(1) });
|
||||
export const artifactExtractionExecutionSchema = z.union([
|
||||
artifactPathSelectionSchema.extend({ output_root: z.string().min(1) }),
|
||||
artifactOccurrenceSelectionSchema.extend({ output_root: z.string().min(1) }),
|
||||
]);
|
||||
|
||||
const exampleInputSchema = z.record(z.string(), jsonValueSchema);
|
||||
const examples: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
|
||||
inspect_artifact: {},
|
||||
extract_artifact: {
|
||||
occurrence_ids: [`occ_${"0".repeat(64)}`],
|
||||
paths: ["Contents/Info.plist"],
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -77,7 +92,7 @@ export const ARTIFACT_TOOL_CONTRACTS = [
|
|||
),
|
||||
artifact(
|
||||
"extract_artifact",
|
||||
"Extract selected graph artifacts into a fresh temporary directory chosen by REA. The result includes its location. Rejects traversal and symlink escapes, never overwrites, enforces archive integrity checks, and verifies cleanup.",
|
||||
"Extract selected regular files by logical paths from inspect_artifact (or by occurrence IDs) into a fresh temporary directory chosen by REA. The result includes its location. Rejects traversal and symlink escapes, never overwrites, enforces archive integrity checks, and verifies cleanup.",
|
||||
artifactExtractionInputSchema,
|
||||
),
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ describe("artifact extraction", () => {
|
|||
"extract_artifact",
|
||||
artifactExtractionExecutionSchema.parse({
|
||||
output_root: output,
|
||||
occurrence_ids: [selected.occurrence_id],
|
||||
paths: ["assets/selected.js"],
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
|
|
@ -75,6 +75,22 @@ describe("artifact extraction", () => {
|
|||
access(join(output, "assets", "ignored.js")),
|
||||
).rejects.toThrow();
|
||||
|
||||
const traversalOutput = join(root, "traversal-output");
|
||||
const traversal = await new ArtifactProvider()
|
||||
.createClient(targetValue)
|
||||
.execute(
|
||||
"extract_artifact",
|
||||
artifactExtractionExecutionSchema.parse({
|
||||
output_root: traversalOutput,
|
||||
paths: ["../source/assets/ignored.js"],
|
||||
}),
|
||||
);
|
||||
expect(traversal).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "ArtifactOperationError", reason: "path" },
|
||||
});
|
||||
await expect(access(traversalOutput)).rejects.toThrow();
|
||||
|
||||
const relocatedOutput = join(root, "relocated-output");
|
||||
const relocated = await new ArtifactProvider()
|
||||
.createClient(targetValue)
|
||||
|
|
|
|||
Loading…
Reference in New Issue