From 9b780c47bca039939dd5f73b78541f3ea566a7dd Mon Sep 17 00:00:00 2001 From: xin zhuang <65798732+1052326311@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:56:51 +0800 Subject: [PATCH] fix(browser): isolate script URLs with empty query or fragment (#2074) --- src/domain/webScriptExportPlan.test.ts | 3 ++ src/domain/webScriptExportPlan.ts | 5 ++- .../filesystem/webScriptExport.test.ts | 40 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/domain/webScriptExportPlan.test.ts b/src/domain/webScriptExportPlan.test.ts index f0af5cddd0..05b7965be2 100644 --- a/src/domain/webScriptExportPlan.test.ts +++ b/src/domain/webScriptExportPlan.test.ts @@ -73,6 +73,9 @@ describe("captured script path projection", () => { it.each([ "", "https://a.test/main.js?v=2", + "https://a.test/main.js?", + "https://a.test/main.js#", + "https://a.test/main.js?#", "https://a.test/main.js#variant", "data:text/javascript,export{}", "https://a.test/%2fescape.js", diff --git a/src/domain/webScriptExportPlan.ts b/src/domain/webScriptExportPlan.ts index e4c0aed4b0..e8b129c930 100644 --- a/src/domain/webScriptExportPlan.ts +++ b/src/domain/webScriptExportPlan.ts @@ -94,8 +94,9 @@ const candidatePath = (script: CapturedWebScript): CandidatePath => { return { path: `origins/${hash(url.origin)}/${parts.join("/")}`, reason: - url.search !== "" || - url.hash !== "" || + // URL.search/hash collapse absent and explicitly empty components. + url.href.includes("?") || + url.href.includes("#") || url.username !== "" || url.password !== "" ? "URL query, fragment, or credentials cannot select a canonical local module." diff --git a/tests/boundary/filesystem/webScriptExport.test.ts b/tests/boundary/filesystem/webScriptExport.test.ts index c624ba7a24..998bbccf2d 100644 --- a/tests/boundary/filesystem/webScriptExport.test.ts +++ b/tests/boundary/filesystem/webScriptExport.test.ts @@ -74,6 +74,46 @@ const setup = async (capture: unknown = scriptCaptureEvidenceFixture()) => { return { root, input, resources }; }; +describe("empty URL component publication", () => { + it("isolates empty query and fragment identities while retaining durable script bytes", async () => { + const scripts = [ + { + url: "https://fixture.test/ordinary.js", + bytes: Buffer.from("export const ordinary = 1;"), + }, + { + url: "https://fixture.test/query.js?", + bytes: Buffer.from("export const query = 2;"), + }, + { + url: "https://fixture.test/fragment.js#", + bytes: Buffer.from("export const fragment = 3;"), + }, + ]; + 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, + ); + if (result.analysis_input === null) + throw new Error("Missing analysis input"); + for (const [index, script] of result.scripts.entries()) { + if (script.content.state !== "exported") + throw new Error("Missing source bytes"); + expect(script.url).toBe(scripts[index]?.url); + expect(script.content.layout).toBe(index === 0 ? "url-path" : "isolated"); + expect( + await readFile( + join(result.analysis_input.input_path, script.content.relative_path), + ), + ).toEqual(scripts[index]?.bytes); + } + const manifest = JSON.parse(await readFile(result.manifest.path, "utf8")); + expect(manifest.scripts).toEqual(result.scripts); + }); +}); + describe("captured script publication boundary", () => { it("verifies durable bytes, manifest, capture identity, and existing module analysis", async () => { const capture = scriptScenarioFixture([