diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index 174ef8308..1a8713929 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -509,17 +509,6 @@ describe("contextual JavaScript artifact path resolution", () => { resolution_status: "resolved", resolved_path: "renderer/local.js", }); - expect( - resolve({ - declaredPath: "file:///assets/app.js#entry", - sourcePath: "main.js", - context: "module-specifier", - files, - }), - ).toMatchObject({ - resolution_status: "resolved", - resolved_path: "assets/app.js", - }); }); it.each(["node:fs", "electron", "../../escape.js", "%2e%2e/escape.js"])( @@ -576,6 +565,55 @@ describe("contextual JavaScript artifact path resolution", () => { }); }); +describe("JavaScript file URL resolution", () => { + it("resolves file URL paths without interpreting query bytes as path syntax", () => { + const files = fileMap([file("assets/app.js", "root")]); + for (const declaredPath of [ + "file:///assets/app.js#entry", + "FILE:///assets/app.js?token=%2F#entry", + ]) { + expect( + resolve({ + declaredPath, + sourcePath: "main.js", + context: "module-specifier", + moduleKind: "import", + files, + }), + ).toMatchObject({ + declared_path: declaredPath, + resolution_status: "resolved", + resolved_path: "assets/app.js", + }); + } + + for (const declaredPath of [ + "file:///assets%2Fapp.js", + "file:///assets%2\nFapp.js", + ]) { + expect( + resolve({ + declaredPath, + sourcePath: "main.js", + context: "module-specifier", + moduleKind: "import", + files, + }), + ).toMatchObject({ resolution_status: "rejected", resolved_path: null }); + } + + expect( + resolve({ + declaredPath: "file:/assets/app.js", + sourcePath: "main.js", + context: "module-specifier", + moduleKind: "import", + files, + }), + ).toMatchObject({ resolution_status: "external", resolved_path: null }); + }); +}); + describe("contextual JavaScript package path resolution", () => { it("follows package entrypoints beyond the former recursion ceiling", () => { const files = fileMap([ diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index 1096a177f..b3d769af6 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -205,17 +205,26 @@ const contextualCandidate = ( context === "url-reference" ? stripQueryAndFragment(input.declaredPath) : input.declaredPath; - const rejected = rejectDeclaration(declared); - if (rejected !== null) return rejected; - if (context === "html-reference") return htmlCandidate(input); - if (context === "module-specifier") { + if (context === "module-specifier" && /^file:/iu.test(declared)) { const fileUrl = fileUrlPath(declared); + if (fileUrl === null) + return unresolvedCandidate("rejected", [ + "The file URL path contains syntax that cannot identify a canonical artifact path.", + ]); if (fileUrl !== undefined) return input.moduleKind === "require" ? unresolvedCandidate("external", [ "Node CommonJS require specifiers do not resolve file URLs as local artifacts.", ]) : fileUrl; + return unresolvedCandidate("external", [ + "File URL authorities or invalid file URL forms cannot be mapped to artifact-local paths.", + ]); + } + const rejected = rejectDeclaration(declared); + if (rejected !== null) return rejected; + if (context === "html-reference") return htmlCandidate(input); + if (context === "module-specifier") { if (hasScheme(declared)) return unresolvedCandidate("external", [ "URL and Node builtin schemes are outside static artifact module resolution.", @@ -1194,12 +1203,26 @@ const exactPackageTargetNotFound = ( ], }); -const fileUrlPath = (value: string): string | undefined => { - if (!value.startsWith("file://")) return undefined; +const fileUrlPath = (value: string): string | null | undefined => { + if (!/^file:\/\//iu.test(value)) return undefined; + // URL parsing removes tabs and newlines and normalizes encoded dot segments. + // Check the raw path first, excluding URL identity suffixes, then check the + // parsed pathname so parser normalization cannot introduce a separator. + const rawPath = stripQueryAndFragment(value); + if ( + value.includes("\0") || + value.includes("\\") || + /[\t\n\r]/u.test(rawPath) || + !admitsCanonicalPathSyntax(rawPath) + ) + return null; try { const url = new URL(value); if (url.protocol !== "file:" || url.hostname !== "") return undefined; - return decodeURIComponent(url.pathname).replace(/^\/+/, ""); + if (!admitsCanonicalPathSyntax(url.pathname)) return null; + const decoded = decodeURIComponent(url.pathname); + if (decoded.includes("\0")) return null; + return decoded.replace(/^\/+/, ""); } catch (cause: unknown) { // Unparseable file URLs have no path to resolve. void cause; diff --git a/tests/acceptance/applications/packageSubpathsCli.test.ts b/tests/acceptance/applications/packageSubpathsCli.test.ts index 6a12c762e..15f734e22 100644 --- a/tests/acceptance/applications/packageSubpathsCli.test.ts +++ b/tests/acceptance/applications/packageSubpathsCli.test.ts @@ -35,7 +35,7 @@ const createPackageSubpathFixture = async () => { const selfSource = 'const self = require("fi%xture"); module.exports = self;'; const importSource = [ 'import literal from "fi%xture";', - 'import uri from "file:///src/uri-dep.cjs";', + 'import uri from "FILE:///src/uri-dep.cjs?token=%2F#entry";', ].join("\n"); const files = { "src/main.cjs": source, @@ -181,7 +181,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)( relation: "imports", properties: expect.objectContaining({ module_link_kind: "import", - specifier: "file:///src/uri-dep.cjs", + specifier: "FILE:///src/uri-dep.cjs?token=%2F#entry", resolved_path: "src/uri-dep.cjs", resolution_status: "resolved", }),