diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index 04172cf65..f56623f2e 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -376,8 +376,11 @@ const resolveRuntimeSourceCandidates = ( return null; }; -const rejectDeclaration = (declared: string): CandidateResolution | null => { - if (declared.length === 0) +const rejectDeclaration = ( + declared: string, + allowEmptyPath = false, +): CandidateResolution | null => { + if (declared.length === 0 && !allowEmptyPath) return unresolvedCandidate("rejected", ["The declared path is empty."]); if (declared.includes("\0") || declared.includes("\\")) return unresolvedCandidate("rejected", [ @@ -394,6 +397,7 @@ const contextualCandidate = ( input: ResolveArtifactPathInput, ): string | CandidateResolution => { const { context } = input; + if (context === "html-reference") return htmlCandidate(input); // URL suffixes are evidence, not pathname bytes. Admit only the path while // retaining the complete declaration on the resolution result. let declared = @@ -426,7 +430,6 @@ const contextualCandidate = ( } 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", [ @@ -907,6 +910,10 @@ const htmlCandidate = ( input: ResolveArtifactPathInput, ): string | CandidateResolution => { const declared = stripQueryAndFragment(htmlUrlText(input.declaredPath)); + // HTML owns URL-text normalization and pathname admission. A nonempty URL + // reference may have no pathname; suffix bytes remain declaration evidence. + const rejected = rejectDeclaration(declared, input.declaredPath !== ""); + if (rejected !== null) return rejected; if (looksExternal(declared)) return unresolvedCandidate("external", [ "External HTML references are not mapped to local artifact assets.", @@ -920,12 +927,6 @@ const htmlCandidate = ( return unresolvedCandidate("external", [ "The document base href is external, so its script reference is not a local artifact path.", ]); - // Removing URL tabs and newlines can join an encoded dot or separator that - // the raw declaration split, so admit the parsed URL text again. - if (!admitsCanonicalPathSyntax(declared)) - return unresolvedCandidate("rejected", [ - "Encoded dot or separator bytes are rejected before artifact path resolution.", - ]); const declaredPath = percentDecodeUrlPath(declared); if (declaredPath === null) return unresolvedCandidate("rejected", [ diff --git a/tests/acceptance/applications/javascriptHtmlUrlSuffixesCli.test.ts b/tests/acceptance/applications/javascriptHtmlUrlSuffixesCli.test.ts new file mode 100644 index 000000000..88e94144f --- /dev/null +++ b/tests/acceptance/applications/javascriptHtmlUrlSuffixesCli.test.ts @@ -0,0 +1,136 @@ +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "admits HTML script pathnames without treating URL suffixes as paths through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-html-url-suffixes-"); + const cases = [ + { + source: "query.html", + script: "./app.js?redirect=%2Ffoo", + resolved: "app.js", + }, + { + source: "fragment.html", + script: "./app.js#route=%2Ffoo", + resolved: "app.js", + }, + { + source: "suffix-bytes.html", + script: "./app.js?value=%2e%5c%00%ff\\tail", + resolved: "app.js", + }, + { + source: "url-text.html", + script: " \n./a\tpp.js?redirect=%2\nfassets ", + resolved: "app.js", + }, + { + source: "self.html", + script: "?redirect=%2Ffoo", + resolved: "self.html", + }, + { + source: "self-fragment.html", + script: "#route=%5Cfoo", + resolved: "self-fragment.html", + }, + { + source: "base.html", + base: "assets/?redirect=%2Froot", + script: "app.js#route=%2Ffoo", + resolved: "assets/app.js", + }, + { + source: "base-self.html", + base: "assets/app.js?redirect=%5Croot", + script: "?redirect=%2Ffoo", + resolved: "assets/app.js", + }, + { + source: "path-separator.html", + script: "./assets%2Fapp.js?valid=1", + resolved: null, + }, + { source: "path-dot.html", script: "./%2e/app.js#valid", resolved: null }, + { + source: "path-backslash.html", + script: "./assets\\app.js?valid=1", + resolved: null, + }, + { + source: "path-nul.html", + script: "./app%00.js?valid=1", + resolved: null, + }, + { + source: "split-separator.html", + script: "./assets%2\nfapp.js?valid=1", + resolved: null, + }, + { + source: "base-separator.html", + base: "assets%2F?valid=1", + script: "app.js?redirect=%2Ffoo", + resolved: null, + }, + ]; + await writeFixtureFiles(root, { + ...Object.fromEntries( + cases.map(({ source, base, script }) => [ + source, + `${base === undefined ? "" : ``}`, + ]), + ), + "app.js": "globalThis.rootApp = true;", + "assets/app.js": "globalThis.nestedApp = true;", + }); + + const result = await analyzeJavaScriptDirectory(surface, cli, root); + const loads = result.graph.edges.filter( + ({ relation }) => relation === "loads", + ); + const unresolved = result.graph.nodes + .flatMap(({ observations }) => observations) + .filter( + ({ properties }) => properties.mechanism === "html-script-reference", + ); + for (const { source, base, script, resolved } of cases) { + const load = loads.find( + ({ evidence }) => + evidence.location.available && + evidence.location.value.kind === "source-range" && + evidence.location.value.source === source, + ); + if (resolved === null) { + expect( + unresolved.find( + ({ properties }) => properties.script_path === script, + ), + ).toMatchObject({ + properties: { + script_path: script, + resolved_path: null, + resolution_status: "rejected", + }, + }); + expect(load).toBeUndefined(); + continue; + } + expect(load).toMatchObject({ + properties: { + script_path: script, + base_href: base ?? null, + resolved_path: resolved, + resolution_context: "html-reference", + resolution_status: "resolved", + }, + }); + } + }, +);