diff --git a/src/artifacts/ArtifactPaths.test.ts b/src/artifacts/ArtifactPaths.test.ts index b57a37b3f1..de8cc75c82 100644 --- a/src/artifacts/ArtifactPaths.test.ts +++ b/src/artifacts/ArtifactPaths.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { ArtifactPathRegistry, + destinationCaseCollision, + DestinationCaseCollisionFailure, annotateEnglishUnicodeCaseCollisions, ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION, normalizeArtifactPath, @@ -155,3 +157,26 @@ const caseVariants = (lowercase: string, count: number): string[] => ) .join(""), ); + +it("retains both source spellings and the native failure in a destination collision", () => { + const cause = Object.assign(new Error("exclusive create refused"), { + code: "EEXIST", + }); + const collision = destinationCaseCollision("res/café.js", ["CAFÉ.js"], { + cause, + }); + expect(collision).toBeInstanceOf(DestinationCaseCollisionFailure); + expect(collision).toMatchObject({ + reason: "path", + requestedPath: "res/café.js", + existingPath: "res/CAFÉ.js", + }); + expect(collision?.cause).toBe(cause); +}); + +it.each([["app.js"], ["app.js", "App.js"], ["other.js"]])( + "does not invent a destination alias from listing %j", + (...names) => { + expect(destinationCaseCollision("res/app.js", names)).toBeUndefined(); + }, +); diff --git a/src/artifacts/ArtifactPaths.ts b/src/artifacts/ArtifactPaths.ts index f29c1faa9f..314f6777c5 100644 --- a/src/artifacts/ArtifactPaths.ts +++ b/src/artifacts/ArtifactPaths.ts @@ -31,9 +31,21 @@ const ENGLISH_UNICODE_CASE_NOTE = /** Inventory limitation when logical names collide under English Unicode case folding. */ export const ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION = `Logical archive names collide under English (en-US) Unicode case folding; a case-insensitive destination cannot store both spellings. ${ENGLISH_UNICODE_CASE_NOTE}`; -/** User-facing prefix for a destination that cannot keep two logical spellings. */ -export const DESTINATION_CASE_COLLISION_PREFIX = - "Destination filesystem cannot store both "; +/** A rejected destination create whose directory listing identifies another spelling. */ +export class DestinationCaseCollisionFailure extends ArtifactReaderFailure { + constructor( + readonly requestedPath: string, + readonly existingPath: string, + options?: ErrorOptions, + ) { + super( + "path", + `Destination filesystem cannot store both ${requestedPath} and ${existingPath}; inventory retains both logical names.`, + options, + ); + this.name = "DestinationCaseCollisionFailure"; + } +} /** Normalize slash-separated logical names; backslashes are literal components. */ export const normalizeArtifactPath = (input: string): string => { @@ -168,10 +180,11 @@ const caseCollisionLimitation = ( * Name the directory-listing spelling that differs from the requested segment * only by English (en-US) Unicode case. Exact matches are the same logical name. */ -export const destinationCaseCollisionMessage = ( +export const destinationCaseCollision = ( requestedPath: string, existingNames: readonly string[], -): string | undefined => { + options?: ErrorOptions, +): DestinationCaseCollisionFailure | undefined => { const slash = requestedPath.lastIndexOf("/"); const segment = slash < 0 ? requestedPath : requestedPath.slice(slash + 1); if (existingNames.includes(segment)) return undefined; @@ -181,7 +194,11 @@ export const destinationCaseCollisionMessage = ( .sort(compareUnicodeCodePoints)[0]; if (existing === undefined) return undefined; const parent = slash < 0 ? "" : requestedPath.slice(0, slash + 1); - return `${DESTINATION_CASE_COLLISION_PREFIX}${requestedPath} and ${parent}${existing}; inventory retains both logical names.`; + return new DestinationCaseCollisionFailure( + requestedPath, + `${parent}${existing}`, + options, + ); }; const englishUnicodeCaseCollisions = ( diff --git a/src/artifacts/SafeOutputTree.ts b/src/artifacts/SafeOutputTree.ts index 072d5ba9a8..3141dc00fb 100644 --- a/src/artifacts/SafeOutputTree.ts +++ b/src/artifacts/SafeOutputTree.ts @@ -15,7 +15,7 @@ import { streamChunkToBuffer } from "./StreamBytes.js"; import { ArtifactPathRegistry, - destinationCaseCollisionMessage, + destinationCaseCollision, normalizeArtifactPath, } from "./ArtifactPaths.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; @@ -188,7 +188,8 @@ export class SafeOutputTree { constants.O_NOFOLLOW, 0o600, ).catch(async (cause: unknown) => { - await throwIfDestinationCaseCollision(parent.path, path, cause); + if (isAlreadyExists(cause)) + await throwIfDestinationCaseCollision(parent.path, path, cause); throw new ArtifactReaderFailure( "path", `Could not exclusively create extraction path: ${path}`, @@ -482,13 +483,19 @@ export class SafeOutputTree { logicalParent.length === 0 ? part : `${logicalParent}/${part}`; current = join(current, part); await assertPathIdentity(parent.path, parent.identity); - let created = true; - await mkdir(current, { mode: 0o700 }).catch((cause: unknown) => { - if (!isAlreadyExists(cause)) throw cause; - created = false; - }); - if (!created) { - await throwIfDestinationCaseCollision(parent.path, logicalPath); + const creation = await mkdir(current, { mode: 0o700 }).then( + () => ({ kind: "created" }) as const, + (cause: unknown) => { + if (!isAlreadyExists(cause)) throw cause; + return { kind: "existing", cause } as const; + }, + ); + if (creation.kind === "existing") { + await throwIfDestinationCaseCollision( + parent.path, + logicalPath, + creation.cause, + ); const identity = this.#nestedDirectories.get(current); if (identity === undefined) throw replacedDirectory(current); await assertPathIdentity(current, identity); @@ -684,11 +691,10 @@ const throwIfDestinationCaseCollision = async ( ): Promise => { const names = await readdir(parentDirectory).catch(() => undefined); if (names === undefined) return; - const message = destinationCaseCollisionMessage(logicalPath, names); - if (message === undefined) return; - throw new ArtifactReaderFailure( - "path", - message, + const failure = destinationCaseCollision( + logicalPath, + names, cause === undefined ? undefined : { cause }, ); + if (failure !== undefined) throw failure; }; diff --git a/src/browser/assets/PublishWebScripts.ts b/src/browser/assets/PublishWebScripts.ts index afad3965f9..0d083c010b 100644 --- a/src/browser/assets/PublishWebScripts.ts +++ b/src/browser/assets/PublishWebScripts.ts @@ -4,7 +4,7 @@ import { Readable } from "node:stream"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; -import { DESTINATION_CASE_COLLISION_PREFIX } from "../../artifacts/ArtifactPaths.js"; +import { DestinationCaseCollisionFailure } from "../../artifacts/ArtifactPaths.js"; import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js"; import { SafeOutputTreeCreationFailure } from "../../artifacts/SafeOutputTreeCreationFailure.js"; import { PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION } from "../../domain/browserNetworkEvidence.js"; @@ -185,8 +185,10 @@ const publishPlannedScript = async ( await write(record.content.relative_path); return record; } catch (cause: unknown) { - const collision = destinationCollisionReason(cause); - if (collision === undefined || record.content.layout !== "url-path") + if ( + !(cause instanceof DestinationCaseCollisionFailure) || + record.content.layout !== "url-path" + ) throw cause; const relativePath = isolatedScriptPath(script, index); await write(relativePath); @@ -196,7 +198,7 @@ const publishPlannedScript = async ( ...record.content, relative_path: relativePath, layout: "isolated", - layout_reason: collision, + layout_reason: cause.message, }, }; } @@ -208,12 +210,5 @@ const isolatedScriptPath = ( ): string => `isolated/source-${index + 1}-${createHash("sha256").update(JSON.stringify(script.source)).digest("hex")}.js`; -const destinationCollisionReason = (cause: unknown): string | undefined => - cause instanceof ArtifactReaderFailure && - cause.reason === "path" && - cause.message.startsWith(DESTINATION_CASE_COLLISION_PREFIX) - ? cause.message - : undefined; - const message = (cause: unknown): string => cause instanceof Error ? cause.message : "Unknown publication failure"; diff --git a/tests/boundary/filesystem/webScriptExport.test.ts b/tests/boundary/filesystem/webScriptExport.test.ts index 45e178b76b..975aafc9e4 100644 --- a/tests/boundary/filesystem/webScriptExport.test.ts +++ b/tests/boundary/filesystem/webScriptExport.test.ts @@ -1,4 +1,5 @@ import { execFile } from "node:child_process"; +import { constants } from "node:fs"; import { createHash } from "node:crypto"; import { access, @@ -28,6 +29,8 @@ import { exportWebScripts } from "../../../src/application/WebScriptExportServic import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js"; import { publishWebScripts } from "../../../src/browser/assets/PublishWebScripts.js"; +import { DestinationCaseCollisionFailure } from "../../../src/artifacts/ArtifactPaths.js"; +import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js"; import { SafeOutputTree } from "../../../src/artifacts/SafeOutputTree.js"; import { SafeOutputTreeCreationFailure } from "../../../src/artifacts/SafeOutputTreeCreationFailure.js"; import { selectScriptCapture } from "../../../src/browser/assets/ScriptCaptureAdapters.js"; @@ -1097,3 +1100,138 @@ describe("capture handle ownership", () => { } }); }); + +it("does not isolate a permission refusal just because a differently cased peer exists", async () => { + const { input, resources } = await setup( + scriptScenarioFixture([ + { + url: "https://fixture.test/App.js", + bytes: Buffer.from("export const upper = 1;"), + }, + { + url: "https://fixture.test/app.js", + bytes: Buffer.from("export const lower = 2;"), + }, + ]), + ); + const denied = Object.assign(new Error("controlled selected-path denial"), { + code: "EACCES", + }); + let refusals = 0; + openMock.mockImplementation(async (...args) => { + if ( + String(args[0]).endsWith("/app.js") && + typeof args[1] === "number" && + (args[1] & constants.O_EXCL) !== 0 + ) { + refusals++; + throw denied; + } + return actualFs.open(...args); + }); + const result = await exportWebScripts(input, resources); + expect(refusals).toBe(1); + if (result.ok) throw new Error("Permission failure was hidden by recovery"); + expect(result.error.cause).toBeInstanceOf(ArtifactReaderFailure); + if (!(result.error.cause instanceof ArtifactReaderFailure)) + throw new Error("Missing file creation failure"); + expect(result.error.cause.cause).toBe(denied); + await expect(access(input.output_directory)).rejects.toMatchObject({ + code: "ENOENT", + }); +}); + +it("does not choose collision recovery from an untyped diagnostic prefix", async () => { + const { input, resources } = await setup(); + const refusal = new ArtifactReaderFailure( + "path", + "Destination filesystem cannot store both diagnostic words and authority.", + ); + const write = vi + .spyOn(SafeOutputTree.prototype, "write") + .mockRejectedValueOnce(refusal); + try { + const result = await exportWebScripts(input, resources); + expect(result.ok).toBe(false); + await expect(access(input.output_directory)).rejects.toMatchObject({ + code: "ENOENT", + }); + } finally { + write.mockRestore(); + } +}); + +it("isolates an observed exclusive-create alias using typed facts when diagnostic wording changes", async () => { + const scripts = [ + { + url: "https://fixture.test/App.js", + bytes: Buffer.from("export const upper = 1;"), + }, + { + url: "https://fixture.test/app.js", + bytes: Buffer.from("export const lower = 2;"), + }, + ]; + const { input, resources } = await setup(scriptScenarioFixture(scripts)); + const native = Object.assign( + new Error("controlled case-insensitive exclusive create"), + { code: "EEXIST" }, + ); + openMock.mockImplementation(async (...args) => { + if ( + String(args[0]).endsWith("/app.js") && + typeof args[1] === "number" && + (args[1] & constants.O_EXCL) !== 0 + ) + throw native; + return actualFs.open(...args); + }); + const collisions: DestinationCaseCollisionFailure[] = []; + const original = SafeOutputTree.prototype.write; + const write = vi + .spyOn(SafeOutputTree.prototype, "write") + .mockImplementation(async function (this: SafeOutputTree, ...args) { + try { + return await original.apply(this, args); + } catch (cause) { + if (cause instanceof DestinationCaseCollisionFailure) { + collisions.push(cause); + cause.message = "Observed destination spelling conflict."; + } + throw cause; + } + }); + try { + const exported = await exportWebScripts(input, resources); + if (!exported.ok) throw exported.error; + const result = webScriptExportResultSchema.parse( + exported.value.normalized_result, + ); + expect(collisions).toHaveLength(1); + expect(collisions[0]?.requestedPath).toMatch(/\/app\.js$/u); + expect(collisions[0]?.existingPath).toMatch(/\/App\.js$/u); + expect(collisions[0]?.cause).toBe(native); + expect( + result.scripts.map(({ content }) => + content.state === "exported" ? content.layout : null, + ), + ).toEqual(["url-path", "isolated"]); + expect(result.scripts[1]?.content).toMatchObject({ + layout_reason: "Observed destination spelling conflict.", + }); + if (result.analysis_input === null) + throw new Error("Missing exported bytes"); + for (const [index, script] of result.scripts.entries()) { + if (script.content.state !== "exported") + throw new Error("Missing script bytes"); + expect(script.url).toBe(scripts[index]?.url); + expect( + await readFile( + join(result.analysis_input.input_path, script.content.relative_path), + ), + ).toEqual(scripts[index]?.bytes); + } + } finally { + write.mockRestore(); + } +}); diff --git a/tests/boundary/providers/native/artifactProvider.failureDetail.test.ts b/tests/boundary/providers/native/artifactProvider.failureDetail.test.ts index 5b042e6e2e..d1600d6469 100644 --- a/tests/boundary/providers/native/artifactProvider.failureDetail.test.ts +++ b/tests/boundary/providers/native/artifactProvider.failureDetail.test.ts @@ -7,7 +7,7 @@ import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; import { - destinationCaseCollisionMessage, + destinationCaseCollision, ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION, } from "../../../../src/artifacts/ArtifactPaths.js"; import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; @@ -85,9 +85,10 @@ it("reports an unsafe path as a hostile artifact path", async () => { }); it("returns a destination case collision as the user-facing path message", () => { - const detail = destinationCaseCollisionMessage("res/2f.xml", ["2F.xml"]); - expect(detail).toEqual(expect.any(String)); - if (detail === undefined) return; + const collision = destinationCaseCollision("res/2f.xml", ["2F.xml"]); + if (collision === undefined) + throw new Error("Expected destination collision"); + const detail = collision.message; expect( projectAnalysisError( new ArtifactOperationError("extract_artifact", "path", undefined, detail),