mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
fix(javascript): resolve query-only HTML references to their document
This commit is contained in:
@@ -270,7 +270,9 @@ const htmlCandidate = (
|
||||
]);
|
||||
if (declaredPath.startsWith("/")) return declaredPath.slice(1);
|
||||
if (base === undefined || base === null || base === "")
|
||||
return posix.join(posix.dirname(input.sourcePath), declaredPath);
|
||||
return declaredPath === ""
|
||||
? input.sourcePath
|
||||
: posix.join(posix.dirname(input.sourcePath), declaredPath);
|
||||
// A local base href is a second untrusted path input; apply the same
|
||||
// admission rules a declared path gets so it cannot smuggle traversal or
|
||||
// separator syntax past canonicalization.
|
||||
@@ -286,23 +288,39 @@ const htmlCandidate = (
|
||||
const basePath = decodedBase.startsWith("/")
|
||||
? decodedBase.slice(1)
|
||||
: posix.join(posix.dirname(input.sourcePath), decodedBase);
|
||||
if (declaredPath === "")
|
||||
return emptyHtmlReference(input, decodedBase, basePath);
|
||||
return posix.join(htmlBaseDirectory(decodedBase, basePath), declaredPath);
|
||||
};
|
||||
|
||||
const emptyHtmlReference = (
|
||||
input: ResolveArtifactPathInput,
|
||||
base: string,
|
||||
basePath: string,
|
||||
): string | ArtifactPathResolution => {
|
||||
if (!htmlBaseIsDirectory(base)) return basePath;
|
||||
const confined = confineCandidate(input, basePath);
|
||||
if (typeof confined !== "string") return confined;
|
||||
return unresolvedOutcome(input, "not-found", [
|
||||
`The HTML reference resolves to directory ${confined || "."}, not an exact selected application file.`,
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
* The directory a relative HTML reference resolves against. A base path
|
||||
* ending in "/" or in a "." or ".." segment is already a directory, because a
|
||||
* relative reference resolves against the base URL's directory and those
|
||||
* segments are dropped rather than stepped through.
|
||||
*/
|
||||
const htmlBaseDirectory = (base: string, basePath: string): string =>
|
||||
const htmlBaseIsDirectory = (base: string): boolean =>
|
||||
base.endsWith("/") ||
|
||||
base.endsWith("/.") ||
|
||||
base.endsWith("/..") ||
|
||||
base === "." ||
|
||||
base === ".."
|
||||
? basePath
|
||||
: posix.dirname(basePath);
|
||||
base === "..";
|
||||
|
||||
const htmlBaseDirectory = (base: string, basePath: string): string =>
|
||||
htmlBaseIsDirectory(base) ? basePath : posix.dirname(basePath);
|
||||
|
||||
const confineCandidate = (
|
||||
input: ResolveArtifactPathInput,
|
||||
|
||||
@@ -33,6 +33,28 @@ const resolve = (declaredPath: string, htmlBaseHref: string) =>
|
||||
});
|
||||
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/",
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
it("resolves empty-path HTML script URLs against their canonical base path", async () => {
|
||||
const root = await createTestTempDirectory("rea-html-empty-reference-");
|
||||
await Promise.all([
|
||||
mkdir(join(root, "base-file", "assets"), { recursive: true }),
|
||||
mkdir(join(root, "base-directory", "assets"), { recursive: true }),
|
||||
mkdir(join(root, "escaped"), { recursive: true }),
|
||||
]);
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
join(root, "document.html"),
|
||||
'<script src="?document=1"></script><script src="#document-fragment"></script>',
|
||||
),
|
||||
writeFile(
|
||||
join(root, "base-file", "index.html"),
|
||||
'<base href="assets/entry.html?base=1#base-fragment"><script src="?base-file=1#script-fragment"></script>',
|
||||
),
|
||||
writeFile(join(root, "base-file", "assets", "entry.html"), "<main></main>"),
|
||||
writeFile(
|
||||
join(root, "base-directory", "index.html"),
|
||||
'<base href="assets/"><script src="?base-directory=1"></script>',
|
||||
),
|
||||
writeFile(
|
||||
join(root, "base-directory", "assets", "index.js"),
|
||||
"export const value = 1;",
|
||||
),
|
||||
writeFile(
|
||||
join(root, "escaped", "index.html"),
|
||||
'<base href="../../outside/"><script src="?escape=1"></script>',
|
||||
),
|
||||
]);
|
||||
|
||||
const result = await reconstructJavaScriptArtifact({
|
||||
input_path: root,
|
||||
format: "directory",
|
||||
});
|
||||
const graph = result.graph;
|
||||
const loadFor = (scriptPath: string) =>
|
||||
graph.edges.find(
|
||||
(edge) =>
|
||||
edge.relation === "loads" && edge.properties.script_path === scriptPath,
|
||||
);
|
||||
const targetPath = (edge: NonNullable<ReturnType<typeof loadFor>>) =>
|
||||
graph.nodes
|
||||
.find(({ node_id }) => node_id === edge.target_node_id)
|
||||
?.observations.find(
|
||||
({ properties }) => typeof properties.path === "string",
|
||||
)?.properties.path;
|
||||
const unresolvedReference = (scriptPath: string) =>
|
||||
graph.nodes
|
||||
.flatMap(({ observations }) => observations)
|
||||
.find(
|
||||
({ properties }) =>
|
||||
properties.mechanism === "html-script-reference" &&
|
||||
properties.script_path === scriptPath,
|
||||
)?.properties;
|
||||
|
||||
for (const scriptPath of ["?document=1", "#document-fragment"]) {
|
||||
const edge = loadFor(scriptPath);
|
||||
expect(edge).toMatchObject({
|
||||
properties: {
|
||||
script_path: scriptPath,
|
||||
resolved_path: "document.html",
|
||||
resolution_status: "resolved",
|
||||
},
|
||||
});
|
||||
if (edge !== undefined) expect(targetPath(edge)).toBe("document.html");
|
||||
}
|
||||
|
||||
const baseFileEdge = loadFor("?base-file=1#script-fragment");
|
||||
expect(baseFileEdge).toMatchObject({
|
||||
properties: {
|
||||
script_path: "?base-file=1#script-fragment",
|
||||
base_href: "assets/entry.html?base=1#base-fragment",
|
||||
resolved_path: "base-file/assets/entry.html",
|
||||
resolution_status: "resolved",
|
||||
},
|
||||
});
|
||||
if (baseFileEdge !== undefined)
|
||||
expect(targetPath(baseFileEdge)).toBe("base-file/assets/entry.html");
|
||||
|
||||
expect(unresolvedReference("?base-directory=1")).toMatchObject({
|
||||
resolution_status: "not-found",
|
||||
limitations: [expect.stringContaining("resolves to directory")],
|
||||
});
|
||||
expect(unresolvedReference("?escape=1")).toMatchObject({
|
||||
resolution_status: "rejected",
|
||||
limitations: [
|
||||
expect.stringContaining("escapes the canonical artifact root"),
|
||||
],
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user