Files
rea/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts
T
Kaoru0822 90c6922905 fix(javascript): keep empty URL path segments significant (#3792)
* fix(javascript): keep empty URL path segments significant

ESM imports and HTML script references were joined with POSIX path
rules, which collapse '//' before applying '..'. URL resolution removes
the empty segment instead, so './assets//../app.js' names assets/app.js,
not app.js, and the graph linked the importer and renderer to the wrong
file. URL references refused every '//' even when dot segments cancel it.

Resolve ESM, HTML (including document base) and URL references through
one URL dot-segment helper. A remaining empty segment stays unavailable,
references above the artifact root are still rejected, and CommonJS keeps
filesystem path semantics.

Fixes #3307

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(javascript): resolve HTML base and reference as one URL path

The base href was turned into a canonical path before the script
reference was applied. A base empty segment that the reference's '..'
would remove was rejected early, and an above-root '..' from the base
became a removable segment that the reference's '..' could cancel.

Keep one URL path state through base and reference resolution, with
above-root escapes held apart from removable segments, and reject empty
segments only in the final path. Remove the superseded htmlBaseDirectory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:12:49 -07:00

225 lines
7.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
const paths = [
"renderer/index.html",
"assets/app.js",
"renderer/app.js",
"renderer/a/app.js",
"renderer/assets/app.js",
"app.js",
];
const files = new Map<string, JavaScriptArtifactFile>(
paths.map((path) => [
path,
{
path,
container_sha256: "a".repeat(64),
sha256: "b".repeat(64),
bytes: 0,
inventory_artifact_id: path,
kind: "javascript",
unpacked: false,
text: { included: true, value: "" },
},
]),
);
const resolve = (declaredPath: string, htmlBaseHref: string) =>
resolveArtifactPathByContext({
declaredPath,
htmlBaseHref,
sourcePath: "renderer/index.html",
context: "html-reference",
files,
});
const documentUrl = "https://artifact.test/renderer/index.html";
describe("HTML base href URL components", () => {
it.each(["?base=1", "#base-fragment"])(
"keeps the document pathname for suffix-only base href %s",
(base) => {
const resolvedBase = new URL(base, documentUrl);
expect(new URL("app.js", resolvedBase).pathname).toBe("/renderer/app.js");
expect(resolve("app.js", base)).toMatchObject({
resolved_path: "renderer/app.js",
resolution_status: "resolved",
});
const emptyPathReference = "?script=1#fragment";
expect(new URL(emptyPathReference, resolvedBase).pathname).toBe(
"/renderer/index.html",
);
expect(resolve(emptyPathReference, base)).toMatchObject({
declared_path: emptyPathReference,
resolved_path: "renderer/index.html",
resolution_status: "resolved",
});
},
);
it.each([
"/assets/?cache=/wrong/",
"/assets/#/wrong/",
"/assets/index.html?cache=/wrong/",
])("ignores query and fragment path-looking characters in %s", (base) => {
expect(
new URL(
"app.js",
new URL(base, "https://artifact.test/renderer/index.html"),
).pathname,
).toBe("/assets/app.js");
expect(resolve("app.js", base)).toMatchObject({
resolution_status: "resolved",
resolved_path: "assets/app.js",
});
});
it.each(["https://external.test/", "//external.test/"])(
"keeps root-relative references external when their base is %s",
(base) => {
expect(resolve("/assets/app.js", base)).toMatchObject({
resolution_status: "external",
resolved_path: null,
});
},
);
});
describe("HTML base href dot segments", () => {
it.each([
[".", "renderer/app.js"],
["./", "renderer/app.js"],
["a/.", "renderer/a/app.js"],
["a/..", "renderer/app.js"],
["..", "app.js"],
])(
"resolves %s against the document directory like the URL parser",
(base, expected) => {
// A relative reference resolves against the base URL's directory, which
// drops a trailing "." or ".." segment instead of stepping through it.
expect(
decodeURIComponent(
new URL("app.js", new URL(base, documentUrl)).pathname,
).replace(/^\/+/, ""),
).toBe(expected);
expect(resolve("app.js", base)).toMatchObject({
resolution_status: "resolved",
resolved_path: expected,
});
},
);
});
describe("HTML base href empty segments", () => {
it("lets the script reference remove an empty segment from the base", () => {
// URL resolution keeps the base's empty segment until `..` removes it.
expect(
new URL("../app.js", new URL("./assets//", documentUrl)).pathname,
).toBe("/renderer/assets/app.js");
expect(resolve("../app.js", "./assets//")).toMatchObject({
resolution_status: "resolved",
resolved_path: "renderer/assets/app.js",
});
});
it("keeps an above-root base escape when the reference also climbs", () => {
expect(resolve("../app.js", "../../")).toMatchObject({
resolution_status: "rejected",
limitations: [
"The resolved candidate escapes the canonical artifact root.",
],
});
});
});
describe("HTML base href path syntax", () => {
it.each(["%2e%2e/secret/", "/a/%2e%2e/", "/a\\b/", "a\0b"])(
"rejects base href %j with the same admission rules a declared path gets",
(base) => {
const outcome = resolve("app.js", base);
expect(outcome.resolution_status).toBe("rejected");
expect(outcome.limitations[0]).toContain(
"not admitted for canonical artifact paths",
);
},
);
it("still confines a base href that escapes the artifact root", () => {
expect(resolve("app.js", "/a/../../secret/")).toMatchObject({
resolution_status: "rejected",
limitations: [
"The resolved candidate escapes the canonical artifact root.",
],
});
});
it("reports an external base href as external rather than malformed", () => {
expect(resolve("app.js", "https://external.test/%2e%2e/")).toMatchObject({
resolution_status: "external",
resolved_path: null,
});
});
});
describe("HTML reference URL text", () => {
const urlPath = (declared: string, base: string) =>
decodeURIComponent(
new URL(declared.trim(), new URL(base.trim(), documentUrl)).pathname,
).replace(/^\/+/, "");
it.each([
["%61pp.js", "/assets/", "assets/app.js"],
[" app.js ", "/assets/", "assets/app.js"],
["\napp.js\t", "/assets/", "assets/app.js"],
["a\npp.js", "/assets/", "assets/app.js"],
["app.js", "%61/", "renderer/a/app.js"],
["app.js", " /%61ssets/ ", "assets/app.js"],
["/%61ssets/app.js", "", "assets/app.js"],
])(
"resolves %j against base %j like the URL parser",
(declared, base, expected) => {
if (base !== "") expect(urlPath(declared, base)).toBe(expected);
expect(resolve(declared, base)).toMatchObject({
declared_path: declared,
resolution_status: "resolved",
resolved_path: expected,
});
},
);
it("keeps a literal percent sign that starts no escape", () => {
expect(resolve("100%.js", "")).toMatchObject({
resolution_status: "not-found",
limitations: [
"The exact HTML resource renderer/100%.js was not found among the selected application files.",
],
});
});
it.each(["%00app.js", "%ff.js"])(
"rejects %j, which decodes to no canonical artifact path",
(declared) => {
expect(resolve(declared, "")).toMatchObject({
resolution_status: "rejected",
resolved_path: null,
});
},
);
it("rejects a base href whose URL whitespace splits an encoded separator", () => {
expect(resolve("app.js", "a%2\nf/")).toMatchObject({
resolution_status: "rejected",
resolved_path: null,
});
});
it("rejects a base href that decodes to NUL", () => {
expect(resolve("app.js", "/a%00/")).toMatchObject({
resolution_status: "rejected",
resolved_path: null,
});
});
it.each([" https://external.test/app.js", "\t//external.test/app.js"])(
"reports %j as external after removing URL whitespace",
(declared) => {
expect(resolve(declared, "")).toMatchObject({
resolution_status: "external",
});
},
);
it.each(["%2e%2e/app.js", "a%2fapp.js", "%2\nfapp.js", "%2\ne%2\te/app.js"])(
"still rejects encoded dot and separator bytes in %j",
(declared) => {
expect(resolve(declared, "")).toMatchObject({
resolution_status: "rejected",
});
},
);
});