From e23cb3b240c6af779f6dd75429dda515bbdbbdf4 Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:13:54 -0400 Subject: [PATCH] fix(browser): preserve script exports with filesystem-unrepresentable paths (#7526) fix(browser): isolate unrepresentable script export paths Signed-off-by: Rudy Celekli --- src/artifacts/ArtifactPaths.ts | 15 ++++++ src/artifacts/SafeOutputTree.ts | 10 ++++ src/browser/assets/PublishWebScripts.ts | 10 +++- .../filesystem/webScriptExport.test.ts | 52 +++++++++++++++++++ 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/artifacts/ArtifactPaths.ts b/src/artifacts/ArtifactPaths.ts index 2f9d6df74e..30e87ef5a5 100644 --- a/src/artifacts/ArtifactPaths.ts +++ b/src/artifacts/ArtifactPaths.ts @@ -47,6 +47,21 @@ export class DestinationCaseCollisionFailure extends ArtifactReaderFailure { } } +/** The destination rejected a name before a file or directory was acquired. */ +export class DestinationPathUnrepresentableFailure extends ArtifactReaderFailure { + constructor( + readonly requestedPath: string, + options: ErrorOptions, + ) { + super( + "path", + `Destination filesystem cannot represent ${pathDiagnostic(requestedPath)}.`, + options, + ); + this.name = "DestinationPathUnrepresentableFailure"; + } +} + /** Normalize slash-separated logical names; backslashes are literal components. */ export const normalizeArtifactPath = (input: string): string => { if ( diff --git a/src/artifacts/SafeOutputTree.ts b/src/artifacts/SafeOutputTree.ts index 401f25a71a..916ce808fe 100644 --- a/src/artifacts/SafeOutputTree.ts +++ b/src/artifacts/SafeOutputTree.ts @@ -18,6 +18,7 @@ import { streamChunkToBuffer } from "./StreamBytes.js"; import { ArtifactPathRegistry, + DestinationPathUnrepresentableFailure, destinationCaseCollision, normalizeArtifactPath, } from "./ArtifactPaths.js"; @@ -269,6 +270,8 @@ export class SafeOutputTree { constants.O_NOFOLLOW, 0o600, ).catch(async (cause: unknown) => { + if (hasUnrepresentableName(cause)) + throw new DestinationPathUnrepresentableFailure(path, { cause }); if (isAlreadyExists(cause)) await throwIfDestinationCaseCollision(parent.path, path, cause); throw new ArtifactReaderFailure( @@ -571,6 +574,10 @@ export class SafeOutputTree { const creation = await mkdir(current, { mode: 0o700 }).then( () => ({ kind: "created" }) as const, (cause: unknown) => { + if (hasUnrepresentableName(cause)) + throw new DestinationPathUnrepresentableFailure(logicalPath, { + cause, + }); if (!isAlreadyExists(cause)) throw cause; return { kind: "existing", cause } as const; }, @@ -777,3 +784,6 @@ const throwIfDestinationCaseCollision = async ( ); if (failure !== undefined) throw failure; }; + +const hasUnrepresentableName = (cause: unknown): boolean => + cause instanceof Error && "code" in cause && cause.code === "ENAMETOOLONG"; diff --git a/src/browser/assets/PublishWebScripts.ts b/src/browser/assets/PublishWebScripts.ts index e3cd35988b..7da7e96127 100644 --- a/src/browser/assets/PublishWebScripts.ts +++ b/src/browser/assets/PublishWebScripts.ts @@ -4,7 +4,10 @@ import { Readable } from "node:stream"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; -import { DestinationCaseCollisionFailure } from "../../artifacts/ArtifactPaths.js"; +import { + DestinationCaseCollisionFailure, + DestinationPathUnrepresentableFailure, +} from "../../artifacts/ArtifactPaths.js"; import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js"; import { outputDirectoryAdmissionRefusal } from "../../artifacts/SafeOutputTreeAdmission.js"; import { SafeOutputTreeCreationFailure } from "../../artifacts/SafeOutputTreeCreationFailure.js"; @@ -209,7 +212,10 @@ const publishPlannedScript = async ( return record; } catch (cause: unknown) { if ( - !(cause instanceof DestinationCaseCollisionFailure) || + !( + cause instanceof DestinationCaseCollisionFailure || + cause instanceof DestinationPathUnrepresentableFailure + ) || record.content.layout !== "url-path" ) throw cause; diff --git a/tests/boundary/filesystem/webScriptExport.test.ts b/tests/boundary/filesystem/webScriptExport.test.ts index 942ca7ae3a..7008f15d04 100644 --- a/tests/boundary/filesystem/webScriptExport.test.ts +++ b/tests/boundary/filesystem/webScriptExport.test.ts @@ -1453,3 +1453,55 @@ it("isolates an observed exclusive-create alias using typed facts when diagnosti write.mockRestore(); } }); + +it.each(["basename", "directory"])( + "isolates a destination-unrepresentable %s without losing peer scripts", + async (kind) => { + const long = "b".repeat(kind === "basename" ? 253 : 256); + const scripts = [ + { + url: "https://fixture.test/regular.js", + bytes: Buffer.from("export const regular = 1;"), + }, + { + url: `https://fixture.test/${"c".repeat(252)}.js`, + bytes: Buffer.from("export const boundary = 1;"), + }, + { + url: `https://fixture.test/${kind === "basename" ? `${long}.js` : `${long}/main.js`}`, + bytes: Buffer.from("export const long = 2;"), + }, + ]; + const { input, resources } = await setup(scriptScenarioFixture(scripts)); + const exported = await exportWebScripts(input, resources); + if (!exported.ok) throw exported.error; + const result = webScriptExportResultSchema.parse( + exported.value.normalized_result, + ); + expect(result.scripts).toHaveLength(3); + expect(result.scripts[0]?.content).toMatchObject({ layout: "url-path" }); + expect(result.scripts[1]?.content).toMatchObject({ layout: "url-path" }); + expect(result.scripts[2]?.content).toMatchObject({ + layout: "isolated", + layout_reason: expect.stringContaining("cannot represent"), + }); + for (const [index, script] of result.scripts.entries()) { + if (script.content.state !== "exported" || result.analysis_input === null) + throw new Error("Missing exported source"); + expect(script.url).toBe(scripts[index]!.url); + expect( + ( + await readFile( + join( + result.analysis_input.input_path, + script.content.relative_path, + ), + ) + ).equals(scripts[index]!.bytes), + ).toBe(true); + } + expect( + JSON.parse(await readFile(result.manifest.path, "utf8")).scripts, + ).toEqual(result.scripts); + }, +);