From 7ba3580c860463adcd5edc73b7a80f34b92480cf Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:53:33 +0800 Subject: [PATCH] refactor(artifacts): select extraction by logical path --- docs/javascript-artifact-reconstruction.md | 21 ++- scripts/verify-package-artifact.mjs | 36 ++--- src/application/ArtifactExtraction.ts | 136 ++++++++++++------ src/artifacts/ArtifactProvider.ts | 5 +- src/cli/artifactCommands.ts | 30 +++- src/contracts/artifactToolContracts.ts | 27 +++- .../artifactProvider.extraction.test.ts | 18 ++- 7 files changed, 190 insertions(+), 83 deletions(-) diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 969cca14a..f27f3412f 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -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 diff --git a/scripts/verify-package-artifact.mjs b/scripts/verify-package-artifact.mjs index 290113a79..9857e7761 100644 --- a/scripts/verify-package-artifact.mjs +++ b/scripts/verify-package-artifact.mjs @@ -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) => { diff --git a/src/application/ArtifactExtraction.ts b/src/application/ArtifactExtraction.ts index bc81f07e0..444232912 100644 --- a/src/application/ArtifactExtraction.ts +++ b/src/application/ArtifactExtraction.ts @@ -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 => { - 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(); + const neededNodes = new Set(); + collectOccurrences( + snapshot.occurrences, + selectedIds, + occurrences, + neededNodes, + ); + const nodes = new Map(); + 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; } -const loadInventory = async ( - path: string, - limits: ArtifactLimits, - selectedIds: ReadonlySet, - signal?: AbortSignal, -): Promise => { - const snapshot = await scanArtifactInventory(path, limits, { signal }); - const occurrences = new Map(); - const neededNodes = new Set(); - collectOccurrences( - snapshot.occurrences, - selectedIds, - occurrences, - neededNodes, - ); - const nodes = new Map(); - collectNodes(snapshot.nodes, neededNodes, nodes); - return { manifest: snapshot.manifest, occurrences, nodes }; -}; - const collectOccurrences = ( items: readonly ArtifactOccurrence[], selected: ReadonlySet, @@ -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(); + 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( diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index 161887d7d..2449e7932 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -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, diff --git a/src/cli/artifactCommands.ts b/src/cli/artifactCommands.ts index dcc4bf4ac..6d3ab5085 100644 --- a/src/cli/artifactCommands.ts +++ b/src/cli/artifactCommands.ts @@ -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, ), diff --git a/src/contracts/artifactToolContracts.ts b/src/contracts/artifactToolContracts.ts index d83dc0ca3..3abf43397 100644 --- a/src/contracts/artifactToolContracts.ts +++ b/src/contracts/artifactToolContracts.ts @@ -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>>> = { 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[]; diff --git a/tests/boundary/providers/native/artifactProvider.extraction.test.ts b/tests/boundary/providers/native/artifactProvider.extraction.test.ts index b3728c848..838cb7ff9 100644 --- a/tests/boundary/providers/native/artifactProvider.extraction.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extraction.test.ts @@ -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)