diff --git a/src/browser/WebBundleAnalysisWorker.test.ts b/src/browser/WebBundleAnalysisWorker.test.ts index 118eccec86..47770d9732 100644 --- a/src/browser/WebBundleAnalysisWorker.test.ts +++ b/src/browser/WebBundleAnalysisWorker.test.ts @@ -78,6 +78,14 @@ type TestMode = readonly oomMarker?: string; readonly stderrMarker?: string; } + | { + readonly kind: "result"; + readonly encoding: + | "invalid-utf8" + | "replacement" + | "bom" + | "malformed-json"; + } | { readonly kind: "delayed-ready" } | { readonly kind: "exit-before-ready" } | { @@ -166,6 +174,33 @@ const protocolWorkerProgram = ( }); `; +const resultWorkerProgram = (encoding: string): string => ` + import { readFile, writeFile } from "node:fs/promises"; + import { createHash } from "node:crypto"; + import { createInterface } from "node:readline"; + import { getHeapStatistics } from "node:v8"; + const { analyzeCapturedWebScript } = await import(${JSON.stringify(new URL("../domain/webBundleAnalyzer.ts", import.meta.url).href)}); + const send = (value) => process.stdout.write(JSON.stringify(value) + "\\n"); + send({ kind: "ready", heap_limit_bytes: getHeapStatistics().heap_size_limit }); + createInterface({ input: process.stdin, crlfDelay: Infinity }).once("line", async (line) => { + const request = JSON.parse(line); + const script = JSON.parse(await readFile(request.id + ".source", "utf8")); + const facts = analyzeCapturedWebScript(script); + let bytes = Buffer.from(JSON.stringify(facts)); + const encoding = ${JSON.stringify(encoding)}; + if (encoding === "invalid-utf8") { + const index = bytes.indexOf(Buffer.from("result-marker")); + if (index < 0) throw new Error("Expected endpoint marker in schema-valid facts"); + bytes[index] = 0xff; + } else if (encoding === "bom") bytes = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), bytes]); + else if (encoding === "malformed-json") bytes = bytes.subarray(0, bytes.length - 1); + await writeFile(request.id + ".result", bytes); + send({ kind: "complete", id: request.id, result: { + bytes: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex"), + } }); + }); +`; + const delayedReadyProgram = ` import { createInterface } from "node:readline"; import { getHeapStatistics } from "node:v8"; @@ -203,7 +238,9 @@ const launcherFor = ( ? delayedReadyProgram : mode.kind === "exit-before-ready" ? exitBeforeReadyProgram - : protocolWorkerProgram(mode.failure); + : mode.kind === "result" + ? resultWorkerProgram(mode.encoding) + : protocolWorkerProgram(mode.failure); const spawned = await spawnOwnedProviderProcess({ ...request, arguments: [ @@ -926,3 +963,75 @@ it.each([ } }, ); + +it.each(["invalid-utf8", "bom", "malformed-json"] as const)( + "rejects %s worker result bytes with a matching descriptor", + async (encoding) => { + let root: string | undefined; + const worker = new WebBundleAnalysisWorker( + WEB_BUNDLE_WORKER_LIMITS.heapMb, + launcherFor( + { kind: "result", encoding }, + { + onSpawn: (spawned) => { + root = spawned.root; + }, + }, + ), + ); + try { + const result = await worker.analyze( + makeScript("fetch('/result-marker');"), + 10_000, + ); + const error = failureOf(result); + expect(error).toBeInstanceOf(AnalysisOutputError); + expect(error.cause).toBeInstanceOf( + encoding === "invalid-utf8" ? TypeError : SyntaxError, + ); + expect(error.cleanupIncomplete).toBe(false); + } finally { + await worker.close(); + } + expect(root).toBeDefined(); + await expect(access(root!)).rejects.toMatchObject({ + code: "ENOENT", + path: root, + }); + }, +); + +it("preserves literal replacement characters in valid UTF-8 worker facts", async () => { + let root: string | undefined; + const worker = new WebBundleAnalysisWorker( + WEB_BUNDLE_WORKER_LIMITS.heapMb, + launcherFor( + { kind: "result", encoding: "replacement" }, + { + onSpawn: (spawned) => { + root = spawned.root; + }, + }, + ), + ); + try { + const result = await worker.analyze( + makeScript("fetch('/literal-�');"), + 10_000, + ); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error(result.error.error.message); + if (result.value.status !== "analyzed") + throw new Error("Expected analyzed worker facts"); + expect(result.value.observations.endpoints).toContainEqual( + expect.objectContaining({ value: "/literal-�" }), + ); + } finally { + await worker.close(); + } + expect(root).toBeDefined(); + await expect(access(root!)).rejects.toMatchObject({ + code: "ENOENT", + path: root, + }); +}); diff --git a/src/browser/WebBundleAnalysisWorker.ts b/src/browser/WebBundleAnalysisWorker.ts index 1413d9eef6..218beffa48 100644 --- a/src/browser/WebBundleAnalysisWorker.ts +++ b/src/browser/WebBundleAnalysisWorker.ts @@ -31,6 +31,7 @@ import { import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { jsonParts, bufferedJsonParts } from "../domain/jsonSerialization.js"; +import { safeParseUtf8Json } from "../domain/safeJson.js"; import { javaScriptRuntime } from "../domain/runtimeVersion.js"; import { err, ok, type Result } from "../domain/result.js"; import type { IncludedScript } from "../domain/webBundleAnalyzerState.js"; @@ -787,9 +788,9 @@ export class WebBundleAnalysisWorker { OPERATION, "Web analyzer result differs from its descriptor", ); - const facts = webBundleScriptAnalysisSchema.parse( - JSON.parse(stored.bytes.toString("utf8")), - ); + const parsed = safeParseUtf8Json(stored.bytes); + if (!parsed.ok) throw parsed.cause; + const facts = webBundleScriptAnalysisSchema.parse(parsed.value); if (facts.status !== "analyzed" || facts.script_key !== script.script_key) throw new AnalysisOutputError( OPERATION, diff --git a/src/javascript/analysis/JavaScriptAnalysisWorker.ts b/src/javascript/analysis/JavaScriptAnalysisWorker.ts index c75c5703d0..58e87c9cf6 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorker.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorker.ts @@ -1,3 +1,4 @@ +import { safeParseUtf8Json } from "../../domain/safeJson.js"; import { analysisSettingLimit } from "../../domain/analysisSettingDefinitions.js"; import { WorkerFrameSignal } from "../../process/WorkerFrameSignal.js"; import type { @@ -719,17 +720,18 @@ export class JavaScriptAnalysisWorker { remediationAction: JAVASCRIPT_PARENT_CHECKPOINT_HEAP_REMEDIATION, }, ); - const text = await readFile(path, "utf8"); + const checkpoint = await readFile(path); if ( expected !== undefined && - createHash("sha256").update(text).digest("hex") !== expected.sha256 + createHash("sha256").update(checkpoint).digest("hex") !== expected.sha256 ) throw new AnalysisOutputError( OPERATION, "JavaScript static checkpoint digest does not match", ); - const value: unknown = JSON.parse(text); - const facts = javaScriptWorkerStaticAnalysisSchema.parse(value); + const decoded = safeParseUtf8Json(checkpoint); + if (!decoded.ok) throw decoded.cause; + const facts = javaScriptWorkerStaticAnalysisSchema.parse(decoded.value); const remaining = this.#remainingTotalResultBudget(); const retainedBytes = bytes * STATIC_CHECKPOINT_RETENTION_EXPANSION; this.#parentCapacityExhausted = retainedBytes > remaining; diff --git a/src/sqlite/SqliteDatabaseWorkerExecution.ts b/src/sqlite/SqliteDatabaseWorkerExecution.ts index 092f109503..985240a23c 100644 --- a/src/sqlite/SqliteDatabaseWorkerExecution.ts +++ b/src/sqlite/SqliteDatabaseWorkerExecution.ts @@ -1,3 +1,4 @@ +import { safeParseUtf8Json } from "../domain/safeJson.js"; import { bindArtifactFileCleanup, artifactFileCancellationCause, @@ -269,7 +270,9 @@ export const executeSqliteDatabaseWorker = async (request: { finalization = sqliteReplyCompletionFailure(cause, capturedOutput); file = completed; } - reply = replySchema.parse(JSON.parse(file.bytes.toString("utf8"))); + const decoded = safeParseUtf8Json(file.bytes); + if (!decoded.ok) throw decoded.cause; + reply = replySchema.parse(decoded.value); } catch (cause: unknown) { throw sqliteReplyFinalizationFailure( sqliteReplyReadFailure(cause, capturedOutput), diff --git a/tests/boundary/javascript/workerFailureComposition.test.ts b/tests/boundary/javascript/workerFailureComposition.test.ts index accc7b2c6d..94c855b2f7 100644 --- a/tests/boundary/javascript/workerFailureComposition.test.ts +++ b/tests/boundary/javascript/workerFailureComposition.test.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { access } from "node:fs/promises"; import { afterEach, expect, it, vi } from "vitest"; const faults = vi.hoisted(() => ({ @@ -185,3 +186,92 @@ it.each([false, true])( ); }, ); + +it.each([ + { malformed: true, advertised: true }, + { malformed: false, advertised: true }, + { malformed: true, advertised: false }, + { malformed: false, advertised: false }, +])( + "reads checkpoint bytes strictly with malformed=$malformed and advertised=$advertised", + async ({ malformed, advertised }) => { + const valid = Buffer.from( + JSON.stringify({ + ...staticCheckpointFacts, + limitations: ["literal � checkpoint"], + }), + ); + const replacementAt = valid.indexOf(Buffer.from("�")); + expect(replacementAt).toBeGreaterThanOrEqual(0); + const checkpoint = malformed + ? Buffer.concat([ + valid.subarray(0, replacementAt), + Buffer.from([0xff]), + valid.subarray(replacementAt + Buffer.byteLength("�")), + ]) + : valid; + let runtimeRoot: string | undefined; + const worker = new JavaScriptAnalysisWorker( + { heapMb: 128, timeoutMs: 30_000 }, + async (request) => { + runtimeRoot = request.cwd; + return spawnOwnedProviderProcess({ + ...request, + arguments: [ + "-e", + `${ready} + const fs=require("node:fs"); + const {createHash}=require("node:crypto"); + const checkpoint=Buffer.from(${JSON.stringify(checkpoint.toString("base64"))},"base64"); + require("node:readline").createInterface({input:process.stdin}).on("line",line=>{ + const request=JSON.parse(line); + if(request.file){ + fs.writeFileSync(request.id+".static.json",checkpoint); + if(!${advertised}) process.exit(2); + process.stdout.write(JSON.stringify({kind:"static",id:request.id,bytes:checkpoint.length,sha256:createHash("sha256").update(checkpoint).digest("hex")})+"\\n"); + } else process.stdout.write(JSON.stringify({kind:"complete",id:request.id,descriptor:null})+"\\n"); + });`, + ], + }); + }, + ); + try { + const result = await worker.analyze(file, { nodes: 100, unknowns: 100 }); + expect(result.ok).toBe(advertised && !malformed); + const retained = result.ok ? result.value : result.error; + if (malformed) { + expect(retained.javascript).toBeNull(); + if (result.ok) throw new Error("Expected malformed checkpoint failure"); + if (advertised) { + expect(result.error.error).toMatchObject({ + _tag: "AnalysisOutputError", + cause: expect.any(TypeError), + }); + expect(projectAnalysisError(result.error.error).code).toBe( + "unreadable_output", + ); + expect(result.error.error.message).not.toContain( + "digest does not match", + ); + } + } else { + expect(retained.javascript?.limitations).toEqual([ + "literal � checkpoint", + ]); + } + if (!advertised) { + if (result.ok) throw new Error("Expected worker crash failure"); + expect(result.error.error).toMatchObject({ + _tag: "ProviderAdapterError", + }); + } + } finally { + await worker.close(); + } + if (runtimeRoot === undefined) + throw new Error( + "Owned analyzer did not expose its runtime root to launcher", + ); + await expect(access(runtimeRoot)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); diff --git a/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts b/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts index c2140ba382..74770b0126 100644 --- a/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts @@ -1374,3 +1374,72 @@ nativeTest.each([ await expectPrivateRootRemoved(ownedRoot); }, ); + +nativeTest.each([ + { encoding: "malformed UTF-8", code: "unreadable_output" }, + { encoding: "literal replacement character", code: "capability_unavailable" }, + { encoding: "UTF-8 BOM", code: "unreadable_output" }, + { encoding: "malformed JSON", code: "unreadable_output" }, +] as const)( + "admits complete SQLite reply-file JSON without replacement decoding: $encoding", + async ({ encoding, code }) => { + const { database } = await createFixture(); + let ownedRoot = ""; + const provider = new SqliteDatabaseProvider(process.env, async (spawn) => { + ownedRoot = spawn.cwd ?? ""; + const requestPath = spawn.arguments[2]; + if (requestPath === undefined) + throw new Error("SQLite request path missing"); + const request = JSON.parse(await readFile(requestPath, "utf8")) as { + reply_path: string; + }; + const valid = Buffer.from( + JSON.stringify({ + ok: false, + reason: "unavailable", + message: "recorded \ufffd diagnostic", + }), + ); + let bytes = valid; + if (encoding === "malformed UTF-8") { + const replacementOffset = valid.indexOf(Buffer.from("\ufffd")); + expect(replacementOffset).toBeGreaterThanOrEqual(0); + bytes = Buffer.concat([ + valid.subarray(0, replacementOffset), + Buffer.from([0xff]), + valid.subarray(replacementOffset + 3), + ]); + } else if (encoding === "UTF-8 BOM") { + bytes = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), valid]); + } else if (encoding === "malformed JSON") { + bytes = Buffer.from("{ malformed reply"); + } + await writeFile(request.reply_path, bytes, { flag: "wx", mode: 0o600 }); + return spawnOwnedProviderProcess({ + ...spawn, + arguments: [ + "-e", + "process.stderr.write('sqlite-reply-marker'); process.exit(0)", + ], + }); + }); + onTestFinished(() => provider.close()); + const result = await provider.inspect({ path: database.path }); + if (result.ok) throw new Error("Expected worker refusal"); + expect(projectAnalysisError(result.error).code).toBe(code); + expect(result.error.capturedOutput).toMatchObject({ + stderr: "sqlite-reply-marker", + truncated: false, + }); + if (encoding === "literal replacement character") + expect(projectAnalysisError(result.error).message).toBe( + "recorded \ufffd diagnostic", + ); + else + expect(result.error.cause).toBeInstanceOf( + encoding === "malformed UTF-8" ? TypeError : SyntaxError, + ); + await provider.close(); + await expectPrivateRootRemoved(ownedRoot); + }, +);