From 721dd3d1aa271b99feaf56559af6d3ed24cc101a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:39:29 +0800 Subject: [PATCH] fix(javascript): preserve selected inputs and completed source evidence --- .../JavaScriptRecoveryService.test.ts | 22 --- .../javascript/JavaScriptRecoveryService.ts | 20 +-- src/domain/analysisErrorBase.ts | 2 + src/javascript/recovery/RecoveryFiles.test.ts | 64 +++++++- src/javascript/recovery/RecoveryFiles.ts | 82 ++++++++-- .../sourceMaps/SourceMapCommand.test.ts | 29 +++- src/javascript/sourceMaps/SourceMapCommand.ts | 19 ++- src/javascript/sourceMaps/SourceMapDecoder.ts | 94 +----------- src/javascript/sourceMaps/SourceMapReply.ts | 101 +++++++++++++ .../wakaruIntegration.test.ts | 57 ++++++- .../javascript/sourceMapDecoder.test.ts | 140 +++++++++++++++--- 11 files changed, 453 insertions(+), 177 deletions(-) create mode 100644 src/javascript/sourceMaps/SourceMapReply.ts diff --git a/src/application/javascript/JavaScriptRecoveryService.test.ts b/src/application/javascript/JavaScriptRecoveryService.test.ts index 6594fbbd8e..fbd6ae9a60 100644 --- a/src/application/javascript/JavaScriptRecoveryService.test.ts +++ b/src/application/javascript/JavaScriptRecoveryService.test.ts @@ -132,28 +132,6 @@ describe("JavaScript recovery workflow", () => { expect(cancelled.error.cause).toBe(reason); } }); - it("rejects additional_paths repeating path or each other", async () => { - const service = new JavaScriptRecoveryService({ - recover: async () => { - throw new Error("port must not execute"); - }, - }); - for (const additional_paths of [ - [artifact.path], - ["/tmp/dir/../bundle.js", "/tmp/bundle.js"], - ["/tmp/shared.js", "/tmp/shared.js"], - ]) { - const result = await service.recover({ - ...input, - additional_paths, - }); - if (result.ok) throw new Error("expected duplicate rejection"); - expect(result.error._tag).toBe("AnalysisInputError"); - expect(result.error).toMatchObject({ - issues: [{ path: ["additional_paths"], reason: "invalid_format" }], - }); - } - }); it("rejects relative additional_paths before acquiring the port", async () => { let invoked = false; const service = new JavaScriptRecoveryService({ diff --git a/src/application/javascript/JavaScriptRecoveryService.ts b/src/application/javascript/JavaScriptRecoveryService.ts index a0bfed4778..852b5a3a6c 100644 --- a/src/application/javascript/JavaScriptRecoveryService.ts +++ b/src/application/javascript/JavaScriptRecoveryService.ts @@ -1,5 +1,5 @@ import { parseAnalysisInput } from "../AnalysisInput.js"; -import { isAbsolute, resolve } from "node:path"; +import { isAbsolute } from "node:path"; import { AnalysisInputError, @@ -59,24 +59,6 @@ export class JavaScriptRecoveryService { })), ]), ); - const selectedInputs = new Set( - [parsed.value.path, ...(parsed.value.additional_paths ?? [])].map( - (path) => resolve(path), - ), - ); - if ( - selectedInputs.size !== - 1 + (parsed.value.additional_paths ?? []).length - ) - return err( - new AnalysisInputError(OPERATION, undefined, [ - { - path: ["additional_paths"], - reason: "invalid_format", - message: "additional_paths must not repeat path or each other.", - }, - ]), - ); const execution = await this.provider.recover(parsed.value, options); if (!execution.ok) return execution; const result = javascriptRecoveryResultSchema.safeParse( diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index 0212b9bb51..e756b0f7db 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -283,6 +283,7 @@ import type { WebSourceMapFetchPartialObservation, } from "./webBundleAnalysis.js"; import type { WebExecutionObservation } from "./webExecution.js"; +import type { WebSourceMapReport } from "./webSourceLocation.js"; import type { WebEventListenersObservation } from "./webEventListeners.js"; import type { NativeLoadImagePartialObservation } from "./native/nativeLoadImage.js"; @@ -313,6 +314,7 @@ export type AnalysisPartialObservation = | WebBundleAnalysis | WebBundleAcquisitionPartialObservation | WebSourceMapFetchPartialObservation + | WebSourceMapReport | WebExecutionObservation | WebEventListenersObservation | JavaScriptRuntimeObservation diff --git a/src/javascript/recovery/RecoveryFiles.test.ts b/src/javascript/recovery/RecoveryFiles.test.ts index 9323d6f692..0d60069697 100644 --- a/src/javascript/recovery/RecoveryFiles.test.ts +++ b/src/javascript/recovery/RecoveryFiles.test.ts @@ -1,5 +1,12 @@ import { execFile } from "node:child_process"; -import { mkdir, stat, writeFile } from "node:fs/promises"; +import { + mkdir, + readFile, + realpath, + stat, + symlink, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; import { expect, it, vi } from "vitest"; @@ -191,6 +198,61 @@ it("reports one file selected twice as an input error", async () => { }); }); +it.skipIf(process.platform === "win32")( + "keeps distinct filesystem selections whose lexical paths collapse together", + async () => { + const root = await realpath( + await createTestTempDirectory("rea-recovery-selected-layout-"), + ); + await mkdir(join(root, "target/child"), { recursive: true }); + await symlink("target/child", join(root, "link")); + const selected = `${root}/link/../input.js`; + const sibling = join(root, "input.js"); + await writeFile(selected, "export const selected = 1;\n"); + await writeFile(sibling, "export const sibling = 2;\n"); + const snapshots = await snapshotRecoveryInputs( + [selected, sibling], + join(root, "snapshots"), + ); + expect( + snapshots.map(({ path, relative_path }) => ({ path, relative_path })), + ).toEqual([ + { path: selected, relative_path: "target/input.js" }, + { path: sibling, relative_path: "input.js" }, + ]); + expect(await readFile(snapshots[0].snapshot_path, "utf8")).toBe( + "export const selected = 1;\n", + ); + expect(await readFile(snapshots[1]!.snapshot_path, "utf8")).toBe( + "export const sibling = 2;\n", + ); + await writeFile(selected, "export const selected = 3;\n"); + const refreshed = await snapshotRecoveryInputs( + [selected, sibling], + join(root, "refreshed"), + ); + expect(refreshed[0].sha256).not.toBe(snapshots[0].sha256); + expect(refreshed[1]!.sha256).toBe(snapshots[1]!.sha256); + expect(await readFile(snapshots[0].snapshot_path, "utf8")).toBe( + "export const selected = 1;\n", + ); + }, +); + +it.skipIf(process.platform === "win32")( + "refuses a regular-file/.. traversal before deriving the snapshot layout", + async () => { + const root = await createTestTempDirectory("rea-recovery-invalid-parent-"); + await writeFile(join(root, "file"), "not a directory"); + await writeFile(join(root, "input.js"), "export const sibling = 1;\n"); + const snapshots = join(root, "snapshots"); + await expect( + snapshotRecoveryInputs([`${root}/file/../input.js`], snapshots), + ).rejects.toMatchObject({ _tag: "AnalysisInputError" }); + await expect(stat(snapshots)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + it("reports a file and directory name clash as an input error", async () => { const clash = filesystemError("ENOTDIR"); diff --git a/src/javascript/recovery/RecoveryFiles.ts b/src/javascript/recovery/RecoveryFiles.ts index 31cac69a0b..d17937520c 100644 --- a/src/javascript/recovery/RecoveryFiles.ts +++ b/src/javascript/recovery/RecoveryFiles.ts @@ -9,8 +9,15 @@ import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { OwnedDirectoryHandle } from "../../filesystem/OwnedDirectoryHandle.js"; import { createHash } from "node:crypto"; -import { lstat, mkdir, opendir, writeFile } from "node:fs/promises"; -import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { + lstat, + mkdir, + opendir, + realpath, + stat, + writeFile, +} from "node:fs/promises"; +import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"; import { TextDecoder } from "node:util"; import { normalizeArtifactPath } from "../../artifacts/ArtifactPaths.js"; import { @@ -175,11 +182,6 @@ const snapshotRecoveryInput = async ( signal?: AbortSignal, resources?: ArtifactResourceScope, ): Promise => { - if (!isAbsolute(path)) - throw recoveryInputFailure( - "path", - "Select an absolute JavaScript file path", - ); let bytes: Buffer; try { bytes = await readRecoveryFile(path, maximum, signal, resources); @@ -254,17 +256,69 @@ const snapshotRecoveryInput = async ( }; }; -/** Preserve each selected file and its relative input layout without concatenation. */ +/** Resolve native parent traversal while keeping the selected final filename. */ +const recoveryInputLayoutPath = async ( + path: string, + signal?: AbortSignal, +): Promise => { + if (!isAbsolute(path)) + throw recoveryInputFailure( + "path", + "Select an absolute JavaScript file path", + ); + try { + checkRecoveryCancellation(signal); + const parent = dirname(path); + // Native lookup must accept the parent before realpath can collapse it: + // Darwin realpath(file/..) otherwise succeeds despite ENOTDIR on reads. + if (!(await stat(parent)).isDirectory()) + throw new NonRegularFileReadError(path, false); + const canonicalParent = await realpath(parent); + checkRecoveryCancellation(signal); + return join(canonicalParent, basename(path)); + } catch (cause: unknown) { + if (cause instanceof AnalysisCancelledError) throw cause; + if ( + cause instanceof Error && + "code" in cause && + (cause.code === "EACCES" || cause.code === "EPERM") + ) + throw new AnalysisAccessDeniedError(OPERATION, path, cause.code, { + cause, + }); + throw recoveryInputFailure( + "path", + `Cannot resolve selected JavaScript input parent ${path}: ${recoveryFailureMessage(cause)}`, + cause, + ); + } +}; + +/** Own selected path interpretation, native collisions and sequential snapshots. */ export const snapshotRecoveryInputs = async ( paths: readonly [string, ...string[]], directory: string, signal?: AbortSignal, resources?: ArtifactResourceScope, ): Promise => { - let commonDirectory = dirname(resolve(paths[0])); - for (const path of paths) { + const selections: [ + { readonly path: string; readonly layoutPath: string }, + ...{ readonly path: string; readonly layoutPath: string }[], + ] = [ + { + path: paths[0], + layoutPath: await recoveryInputLayoutPath(paths[0], signal), + }, + ]; + for (const path of paths.slice(1)) + selections.push({ + path, + layoutPath: await recoveryInputLayoutPath(path, signal), + }); + let commonDirectory = dirname(selections[0].layoutPath); + for (const { layoutPath } of selections) { while (true) { - const fromCommon = relative(commonDirectory, resolve(path)); + const fromCommon = relative(commonDirectory, layoutPath); if ( fromCommon !== ".." && !fromCommon.startsWith(`..${sep}`) && @@ -282,11 +336,11 @@ export const snapshotRecoveryInputs = async ( } const snapshots: RecoveryInputSnapshot[] = []; let remaining = RECOVERY_LIMITS.inputBytes; - for (const path of paths) { + for (const { path, layoutPath } of selections) { const relativePath = - paths.length === 1 + selections.length === 1 ? "bundle.js" - : relative(commonDirectory, resolve(path)).split(sep).join("/"); + : relative(commonDirectory, layoutPath).split(sep).join("/"); try { // Wakaru's multi-input public paths interpret backslashes as separators. if (relativePath.includes("\\")) diff --git a/src/javascript/sourceMaps/SourceMapCommand.test.ts b/src/javascript/sourceMaps/SourceMapCommand.test.ts index ae2b338408..669a632742 100644 --- a/src/javascript/sourceMaps/SourceMapCommand.test.ts +++ b/src/javascript/sourceMaps/SourceMapCommand.test.ts @@ -10,13 +10,34 @@ import { ProviderProcessSupervisor, spawnOwnedProviderProcess, } from "../../process/ProviderProcess.js"; -import { WEB_SOURCE_MAP_LIMITS } from "../../domain/webSourceLocation.js"; +import { + WEB_SOURCE_MAP_LIMITS, + type WebSourceMapReport, +} from "../../domain/webSourceLocation.js"; +import { traceSourceMap } from "./TraceSourceMap.js"; import { runSourceMapCommand, SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES, type SourceMapCleanupRetention, } from "./SourceMapCommand.js"; +const report = traceSourceMap( + JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }), + "https://app.test/app.map", + { line: 1, column: 0 }, +); +const completedReport: WebSourceMapReport = { + ...report, + runtime: + report.runtime.id === "node-v8" + ? { ...report.runtime, v8_heap_limit_bytes: 200 * 1024 * 1024 } + : { + ...report.runtime, + process_memory_limit_bytes: SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES, + }, +}; +const completedCodec = `process.stdout.write(${JSON.stringify(JSON.stringify({ state: "success", report: completedReport }))})`; + const unexpectedCleanup: SourceMapCleanupRetention = {}; afterEach(async () => { const owner = unexpectedCleanup.owner; @@ -114,7 +135,7 @@ it.each([null, { caller: "late source-map cancellation" }])( launcher: async (options) => spawnOwnedProviderProcess({ ...options, - arguments: ["-e", 'process.stdout.write("complete")'], + arguments: ["-e", completedCodec], }), }, retainedCleanup, @@ -141,8 +162,8 @@ it.each([null, { caller: "late source-map cancellation" }])( ); it("returns completed codec output after successful cleanup", async () => { - const result = await runChild(undefined, 'process.stdout.write("complete")'); - expect(result).toEqual({ ok: true, value: "complete" }); + const result = await runChild(undefined, completedCodec); + expect(result).toEqual({ ok: true, value: completedReport }); }); it("preserves exact caller reason when cancellation precedes process exit", async () => { diff --git a/src/javascript/sourceMaps/SourceMapCommand.ts b/src/javascript/sourceMaps/SourceMapCommand.ts index 3da5b9eef6..2fb2c25ff3 100644 --- a/src/javascript/sourceMaps/SourceMapCommand.ts +++ b/src/javascript/sourceMaps/SourceMapCommand.ts @@ -14,13 +14,15 @@ import { import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; import type { JsonValue } from "../../domain/jsonValue.js"; -import { err, ok, type Result } from "../../domain/result.js"; +import { err, type Result } from "../../domain/result.js"; import { noRuntimeOverrideSentence } from "../../domain/browserDeadline.js"; import { DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS, WEB_SOURCE_MAP_LIMITS, webSourceMapDecodeTimeoutRecovery, + type WebSourceMapReport, } from "../../domain/webSourceLocation.js"; +import { parseSourceMapReply } from "./SourceMapReply.js"; import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js"; import { RuntimeControlUnavailableError, @@ -99,7 +101,7 @@ export class SourceMapCleanupFailure extends ProviderCleanupError { } } -/** Run a trusted codec command and release its process/root before returning output. */ +/** Interpret a trusted codec reply, then release its process and private root. */ export const runSourceMapCommand = async ( request: string, path: string, @@ -107,7 +109,7 @@ export const runSourceMapCommand = async ( dependencies: SourceMapDecoderDependencies, retainedCleanup: SourceMapCleanupRetention, decodeTimeoutMs: number = DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS, -): Promise> => { +): Promise> => { try { await (dependencies.prepareOwnership ?? prepareProcessOwnershipInspection)( options?.signal, @@ -135,10 +137,14 @@ export const runSourceMapCommand = async ( options?.signal, ); const command = new OwnedCodec(path, dependencies, deadline, decodeTimeoutMs); - let result: Result; + let result: Result; try { await command.start(request); - result = ok(await command.collect()); + result = parseSourceMapReply( + await command.collect(), + path, + SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES, + ); } catch (cause: unknown) { result = err(command.failure(cause)); } finally { @@ -146,15 +152,18 @@ export const runSourceMapCommand = async ( deadline.dispose(); } const cleanup = await command.close(); + const observation = result.ok ? result.value : undefined; const completed = result.ok && options?.signal?.aborted ? err( new AnalysisCancelledError(OPERATION, { cause: options.signal.reason, + partialObservation: observation, }), ) : result; if (cleanup === undefined) return completed; + if (observation !== undefined) cleanup.retainPartialObservation(observation); retainedCleanup.owner = command; return completed.ok ? err(cleanup) diff --git a/src/javascript/sourceMaps/SourceMapDecoder.ts b/src/javascript/sourceMaps/SourceMapDecoder.ts index 08eca6a1c5..9b359e08b3 100644 --- a/src/javascript/sourceMaps/SourceMapDecoder.ts +++ b/src/javascript/sourceMaps/SourceMapDecoder.ts @@ -3,25 +3,18 @@ import type { WebSourceMapPort } from "../../application/WebSourceLocationPorts. import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisCancelledError, - AnalysisCapabilityUnavailableError, AnalysisInputError, - AnalysisOutputError, } from "../../domain/analysisErrorCore.js"; import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; -import { err, ok, type Result } from "../../domain/result.js"; -import { javaScriptRuntime } from "../../domain/runtimeVersion.js"; +import { err, type Result } from "../../domain/result.js"; import { noRuntimeOverrideSentence } from "../../domain/browserDeadline.js"; import { DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS, WEB_SOURCE_MAP_LIMITS, type WebSourceMapReport, } from "../../domain/webSourceLocation.js"; +import { sourceMapCodecInputSchema } from "./SourceMapCodecProtocol.js"; import { - sourceMapCodecInputSchema, - sourceMapCodecReplySchema, -} from "./SourceMapCodecProtocol.js"; -import { - SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES, runSourceMapCommand, type SourceMapCleanupOwner, type SourceMapCleanupRetention, @@ -125,36 +118,7 @@ export class SourceMapDecoder implements WebSourceMapPort { ); if (retainedCleanup.owner !== undefined) this.#pendingCleanup = retainedCleanup.owner; - if (!response.ok) return response; - let value: unknown; - try { - value = JSON.parse(response.value); - } catch (cause: unknown) { - return err( - new AnalysisOutputError( - OPERATION, - `Malformed codec JSON reply: ${cause instanceof Error ? cause.message : String(cause)}`, - ), - ); - } - const reply = sourceMapCodecReplySchema.safeParse(value); - if (!reply.success) - return err( - new AnalysisOutputError( - OPERATION, - `Malformed codec reply: ${reply.error.message}`, - ), - ); - if (options?.signal?.aborted) - return err( - new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), - ); - if (reply.data.state === "failure") - return codecFailure(input.path, reply.data.reason, reply.data.message); - const runtimeFailure = codecRuntimeFailure(reply.data.report.runtime); - return runtimeFailure === undefined - ? ok(reply.data.report) - : err(new AnalysisOutputError(OPERATION, runtimeFailure)); + return response; } async close(): Promise { @@ -180,55 +144,3 @@ export class SourceMapDecoder implements WebSourceMapPort { return undefined; } } -/** - * The codec runs on this process's executable. Only a V8 report carries a heap - * limit; a Bun report must name the process limit its launcher enforced. - */ -const codecRuntimeFailure = ( - runtime: WebSourceMapReport["runtime"], -): string | undefined => { - if ( - runtime.id !== - (javaScriptRuntime(process.versions).id === "bun" ? "bun" : "node-v8") - ) - return `Codec reported runtime ${runtime.id}, not the runtime that launched it.`; - if ( - runtime.id === "node-v8" && - runtime.v8_heap_limit_bytes > 256 * 1024 * 1024 - ) - return "Codec reported a V8 heap limit exceeding its independent process budget."; - if ( - runtime.id === "bun" && - runtime.process_memory_limit_bytes !== SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES - ) - return "Codec reported a process memory limit other than the one its launcher enforced."; - return undefined; -}; -const codecFailure = ( - path: string, - reason: "format" | "unsupported" | "limit", - detail: string, -): Result => - err( - reason === "limit" - ? new ArtifactOperationError( - OPERATION, - "limit", - undefined, - `${path}: ${detail}`, - ) - : reason === "unsupported" - ? new AnalysisCapabilityUnavailableError( - "source-map-decoder", - OPERATION, - "unsupported_source_map_profile", - { userMessage: `${path}: ${detail}` }, - ) - : new AnalysisInputError(OPERATION, undefined, [ - { - path: ["source_map", "path"], - reason: "invalid_format", - message: `${path}: ${detail}`, - }, - ]), - ); diff --git a/src/javascript/sourceMaps/SourceMapReply.ts b/src/javascript/sourceMaps/SourceMapReply.ts new file mode 100644 index 0000000000..702738a41d --- /dev/null +++ b/src/javascript/sourceMaps/SourceMapReply.ts @@ -0,0 +1,101 @@ +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisCapabilityUnavailableError, + AnalysisInputError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; +import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { javaScriptRuntime } from "../../domain/runtimeVersion.js"; +import type { WebSourceMapReport } from "../../domain/webSourceLocation.js"; +import { sourceMapCodecReplySchema } from "./SourceMapCodecProtocol.js"; + +const OPERATION = "trace_web_source_location"; + +/** Interpret the complete codec reply before its command owner finalizes. */ +export const parseSourceMapReply = ( + text: string, + path: string, + processMemoryLimitBytes: number | null, +): Result => { + let value: unknown; + try { + value = JSON.parse(text); + } catch (cause: unknown) { + return err( + new AnalysisOutputError( + OPERATION, + `Malformed codec JSON reply: ${cause instanceof Error ? cause.message : String(cause)}`, + ), + ); + } + const reply = sourceMapCodecReplySchema.safeParse(value); + if (!reply.success) + return err( + new AnalysisOutputError( + OPERATION, + `Malformed codec reply: ${reply.error.message}`, + ), + ); + if (reply.data.state === "failure") + return codecFailure(path, reply.data.reason, reply.data.message); + const runtimeFailure = codecRuntimeFailure( + reply.data.report.runtime, + processMemoryLimitBytes, + ); + return runtimeFailure === undefined + ? ok(reply.data.report) + : err(new AnalysisOutputError(OPERATION, runtimeFailure)); +}; + +/** Only the launched Node runtime's V8 heap, or Bun's process limit, is admitted. */ +const codecRuntimeFailure = ( + runtime: WebSourceMapReport["runtime"], + processMemoryLimitBytes: number | null, +): string | undefined => { + if ( + runtime.id !== + (javaScriptRuntime(process.versions).id === "bun" ? "bun" : "node-v8") + ) + return `Codec reported runtime ${runtime.id}, not the runtime that launched it.`; + if ( + runtime.id === "node-v8" && + runtime.v8_heap_limit_bytes > 256 * 1024 * 1024 + ) + return "Codec reported a V8 heap limit exceeding its independent process budget."; + if ( + runtime.id === "bun" && + runtime.process_memory_limit_bytes !== processMemoryLimitBytes + ) + return "Codec reported a process memory limit other than the one its launcher enforced."; + return undefined; +}; + +const codecFailure = ( + path: string, + reason: "format" | "unsupported" | "limit", + detail: string, +): Result => + err( + reason === "limit" + ? new ArtifactOperationError( + OPERATION, + "limit", + undefined, + `${path}: ${detail}`, + ) + : reason === "unsupported" + ? new AnalysisCapabilityUnavailableError( + "source-map-decoder", + OPERATION, + "unsupported_source_map_profile", + { userMessage: `${path}: ${detail}` }, + ) + : new AnalysisInputError(OPERATION, undefined, [ + { + path: ["source_map", "path"], + reason: "invalid_format", + message: `${path}: ${detail}`, + }, + ]), + ); diff --git a/tests/boundary/javascript-recovery/wakaruIntegration.test.ts b/tests/boundary/javascript-recovery/wakaruIntegration.test.ts index 5c9b5fa6ce..52dd80eef8 100644 --- a/tests/boundary/javascript-recovery/wakaruIntegration.test.ts +++ b/tests/boundary/javascript-recovery/wakaruIntegration.test.ts @@ -1,5 +1,12 @@ import { execFile } from "node:child_process"; -import { access, mkdir, open, readFile, writeFile } from "node:fs/promises"; +import { + access, + mkdir, + open, + readFile, + symlink, + writeFile, +} from "node:fs/promises"; import { dirname, join } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it, vi } from "vitest"; @@ -420,6 +427,54 @@ describe.skipIf(process.platform !== "linux" || process.arch !== "x64")( }, ); +describe.skipIf(process.platform !== "linux" || process.arch !== "x64")( + "Wakaru selected input layout", + () => { + it("preserves selected source identities and layout through a symlink followed by ..", async () => { + const fixture = await recoveryFixture(); + await mkdir(join(fixture.root, "target/child"), { recursive: true }); + await symlink("target/child", join(fixture.root, "link")); + const selected = `${fixture.root}/link/../selected.js`; + await writeFile(selected, "export const selected = true;\n"); + const result = await fixture.service.recover({ + path: selected, + additional_paths: [fixture.path], + output_directory: fixture.output, + }); + if (!result.ok) throw result.error; + const value = javascriptRecoveryResultSchema.parse( + result.value.normalized_result, + ); + const sources = [value.source, ...(value.additional_sources ?? [])]; + expect(sources.map(({ path }) => path)).toEqual([selected, fixture.path]); + expect(sources.map(({ published_copy }) => published_copy.path)).toEqual([ + join(fixture.output, "inputs/target/selected.js"), + join(fixture.output, "inputs/selected.js"), + ]); + expect(await readFile(sources[0]!.published_copy.path, "utf8")).toBe( + "export const selected = true;\n", + ); + expect(await readFile(sources[1]!.published_copy.path, "utf8")).toBe( + "module.exports = 42;", + ); + expect(result.value.parameters).toMatchObject({ + path: selected, + additional_paths: [fixture.path], + }); + expect(result.value.subject).toMatchObject({ + local_path: selected, + digest: { sha256: sources[0]!.sha256 }, + }); + for (const [index, module] of value.modules.entries()) + expect(module.provenance).toMatchObject({ + original_path: sources[index]!.path, + original_sha256: sources[index]!.sha256, + }); + await assertRecoveryCleanup(fixture.launches); + }); + }, +); + describe.skipIf(process.platform !== "linux" || process.arch !== "x64")( "Wakaru multi-input ownership", () => { diff --git a/tests/boundary/javascript/sourceMapDecoder.test.ts b/tests/boundary/javascript/sourceMapDecoder.test.ts index f428d339bb..0eedf9723d 100644 --- a/tests/boundary/javascript/sourceMapDecoder.test.ts +++ b/tests/boundary/javascript/sourceMapDecoder.test.ts @@ -2,6 +2,8 @@ import { fileURLToPath } from "node:url"; import { access } from "node:fs/promises"; import { expect, it, vi } from "vitest"; import { AnalysisInputError } from "../../../src/domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { webSourceMapReportSchema } from "../../../src/domain/webSourceLocation.js"; import { SourceMapDecoder } from "../../../src/javascript/sourceMaps/SourceMapDecoder.js"; import { SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES, @@ -114,6 +116,122 @@ it("runs the real pinned codec with an independent heap and removes its private if (path === undefined) throw new Error("runtime not acquired"); await expect(access(path)).rejects.toMatchObject({ code: "ENOENT" }); }); + +it.each([ + { cancel: false, denyCleanup: false }, + { cancel: true, denyCleanup: false }, + { cancel: false, denyCleanup: true }, + { cancel: true, denyCleanup: true }, +])( + "retains completed real-codec matches through finalization (cancel=$cancel, denied=$denyCleanup)", + async ({ cancel, denyCleanup }) => { + const controller = new AbortController(); + const reason = { phase: "source-map root finalization" }; + let path: string | undefined; + let cleanupAttempts = 0; + const decoder = realDecoder({ + createRuntime: async () => { + const root = await PrivateRuntimeRoot.create(); + path = root.path; + return { + path: root.path, + close: async () => { + cleanupAttempts += 1; + if (cancel) controller.abort(reason); + if (denyCleanup && cleanupAttempts === 1) + throw new Error("controlled source-map cleanup denial"); + await root.close(); + }, + }; + }, + }); + try { + const response = await decoder.trace(input, { + signal: controller.signal, + }); + expect(response.ok).toBe(!cancel && !denyCleanup); + const report = webSourceMapReportSchema.parse( + response.ok ? response.value : response.error.partialObservation, + ); + expect(report).toMatchObject({ + source_map_sha256: fixture.sourceMap.file.sha256, + url_context: input.url, + lookup: { requested: input.position }, + matches: [{ state: "mapped", content: { text: "original" } }], + }); + if (path === undefined) throw new Error("runtime not acquired"); + if (!response.ok) { + expect(response.error).toMatchObject({ + operation: "trace_web_source_location", + }); + expect(projectAnalysisError(response.error)).toMatchObject({ + details: { partial_observation: report }, + }); + if (cancel) { + const primary = response.error.cleanupPrimary ?? response.error; + expect(primary._tag).toBe("AnalysisCancelledError"); + expect(primary.cause).toBe(reason); + } + expect(response.error.cleanupIncomplete).toBe(denyCleanup); + if (denyCleanup) { + expect(response.error.cleanupResources).toEqual([path]); + await expect(access(path)).resolves.toBeUndefined(); + await decoder.close(); + expect(cleanupAttempts).toBe(2); + expect(response.error.cleanupIncomplete).toBe(false); + expect(response.error.cleanupResources).toEqual([]); + expect(response.error.partialObservation).toEqual(report); + } + } + await expect(access(path)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await decoder.close(); + } + }, +); + +it.each([false, true])( + "keeps the completed codec refusal primary over late cancellation (denied=%s)", + async (denyCleanup) => { + const controller = new AbortController(); + const reason = new Error("cancel after codec refusal"); + let cleanupAttempts = 0; + const decoder = realDecoder({ + createRuntime: async () => { + const root = await PrivateRuntimeRoot.create(); + return { + path: root.path, + close: async () => { + cleanupAttempts += 1; + controller.abort(reason); + if (denyCleanup && cleanupAttempts === 1) + throw new Error("controlled refusal cleanup denial"); + await root.close(); + }, + }; + }, + }); + try { + const response = await decoder.trace( + { ...input, text: "{" }, + { signal: controller.signal }, + ); + if (response.ok) throw new Error("Expected malformed-map refusal"); + expect(controller.signal.reason).toBe(reason); + expect(response.error.cleanupPrimary ?? response.error).toBeInstanceOf( + AnalysisInputError, + ); + expect(response.error.partialObservation).toBeUndefined(); + expect(response.error.cleanupIncomplete).toBe(denyCleanup); + await decoder.close(); + expect(response.error.cleanupIncomplete).toBe(false); + expect(cleanupAttempts).toBe(denyCleanup ? 2 : 1); + } finally { + await decoder.close(); + } + }, +); + it.each([ { failure: "malformed JSON", @@ -222,26 +340,6 @@ it("keeps indexed section overlap typed as a malformed source map", async () => ]), ); }); -it("reports cleanup failure with the actual owning operation", async () => { - const response = await realDecoder({ - createRuntime: async () => { - const root = await PrivateRuntimeRoot.create(); - return { - path: root.path, - close: async () => { - await root.close(); - throw new Error("observed cleanup failure"); - }, - }; - }, - }).trace(input); - if (response.ok) throw new Error("expected cleanup failure"); - expect(response.error).toMatchObject({ - cleanupIncomplete: true, - operation: "trace_web_source_location", - diagnostics: { cleanup_failures: ["observed cleanup failure"] }, - }); -}); it("cancels a real owned process and removes the runtime request", async () => { const controller = new AbortController(); let path: string | undefined; @@ -311,6 +409,7 @@ it("rejects a malformed actual subprocess reply", async () => { }).trace(input); if (response.ok) throw new Error("expected malformed reply"); expect(response.error._tag).toBe("AnalysisOutputError"); + expect(response.error.partialObservation).toBeUndefined(); }); it("rejects a codec report from a runtime other than the launching one", async () => { const actual = await realDecoder().trace(input); @@ -344,6 +443,7 @@ it("rejects a codec report from a runtime other than the launching one", async ( }); if (response.ok) throw new Error("expected runtime mismatch"); expect(response.error._tag).toBe("AnalysisOutputError"); + expect(response.error.partialObservation).toBeUndefined(); if (actual.value.runtime.id !== "bun") return; // A Bun codec must report exactly the process limit its launcher enforced. const limit = await replay({