fix(javascript): normalize package URLs before array selection (#1916)

This commit is contained in:
morluto authored and GitHub committed 2026-10-12 01:36:42 +08:00
1 parent 2976fac30d
commit 369cd3dfdc
5 files changed
+318 -33

No files matched your search

@@ -74,6 +74,21 @@ const EXTENSIONS = [
".node",
];
// A private URL namespace for logical artifact paths, including root packages.
// Encode inventory segments so filename punctuation remains literal in the base.
const PACKAGE_ARTIFACT_ROOT_URL = new URL("file:///rea-artifact/");
const packageMetadataUrl = (path: string): URL =>
new URL(
path.split("/").map(encodeURIComponent).join("/"),
PACKAGE_ARTIFACT_ROOT_URL,
);
interface PackageFileTarget {
readonly value: string;
/** Normalized URL retained until loader-specific validation and file lookup. */
readonly url: URL;
}
type CandidateResolution =
| {
readonly resolvedPath: string;
@@ -313,7 +328,11 @@ const resolvePackageImportAlias = (
return unresolvedCandidate("external", [
`Wildcard package import aliases remain unresolved; package metadata ${metadata.path} has no exact imports key ${specifier}.`,
]);
const selected = selectPackageImportTarget(lookup.target, input.moduleKind);
const selected = selectPackageImportTarget(
lookup.target,
input.moduleKind,
metadata.path,
);
if (selected.status === "rejected")
return unresolvedCandidate("rejected", [
`Package metadata ${metadata.path}: ${selected.limitation}`,
@@ -334,14 +353,15 @@ const resolvePackageImportAlias = (
: `External-package import targets remain unresolved; package metadata ${metadata.path} maps ${specifier} to ${JSON.stringify(target.target)}.`,
]);
}
return resolveExportTarget(input, metadata.path, selected.value, "imports");
return resolveExportTarget(input, metadata.path, selected, "imports");
};
const selectPackageImportTarget = (
value: unknown,
moduleKind: ResolveArtifactPathInput["moduleKind"],
packagePath: string,
):
| { readonly status: "value"; readonly value: string }
| ({ readonly status: "value" } & PackageFileTarget)
| { readonly status: "non-relative"; readonly value: string }
| { readonly status: "blocked"; readonly cause: PackageTargetBlockCause }
| { readonly status: "unmatched"; readonly declared: readonly string[] }
@@ -349,6 +369,7 @@ const selectPackageImportTarget = (
const selected = selectExportTarget(
value,
packageExportConditions(moduleKind),
packageMetadataUrl(packagePath),
"imports",
);
if (selected.kind === "invalid" || selected.kind === "invalid-config")
@@ -362,7 +383,11 @@ const selectPackageImportTarget = (
return { status: "blocked", cause: selected.cause };
if (selected.kind === "non-relative")
return { status: "non-relative", value: selected.value };
return { status: "value", value: selected.value };
return {
status: "value",
value: selected.value,
url: selected.url,
};
};
/** Self references use the nearest package scope before dependency lookup. */
@@ -430,7 +455,12 @@ const resolvePackageExports = (
subpath: string,
rawExports: string | object,
): CandidateResolution => {
const exported = selectPackageExport(rawExports, subpath, input.moduleKind);
const exported = selectPackageExport(
rawExports,
subpath,
input.moduleKind,
packagePath,
);
if (exported.status === "rejected")
return unresolvedCandidate("rejected", [
`Package metadata ${packagePath}: ${exported.limitation}`,
@@ -443,25 +473,45 @@ const resolvePackageExports = (
return unresolvedCandidate("external", [
`Directory package metadata ${packagePath} blocks subpath ${subpath} with ${exported.cause === "null" ? "an explicit null exports target" : "an empty exports target array"}.`,
]);
return resolveExportTarget(input, packagePath, exported.value);
return resolveExportTarget(input, packagePath, exported);
};
/** An exports or import target is a URL naming one exact file for both Node loaders. */
const resolveExportTarget = (
input: ResolveArtifactPathInput,
packagePath: string,
target: string,
target: PackageFileTarget,
mapKind: "exports" | "imports" = "exports",
): CandidateResolution => {
const rejected = rejectDeclaration(target);
if (rejected !== null) return rejected;
const decoded = decodeModuleUrlPath(stripQueryAndFragment(target));
if (typeof decoded !== "string") return decoded;
const candidate = confineCandidate(relativeCandidate(packagePath, decoded));
if (typeof candidate !== "string") return candidate;
const withTargetSource = (
outcome: CandidateResolution,
): CandidateResolution => ({
...outcome,
limitations: outcome.limitations.map(
(reason) =>
`Package metadata ${packagePath}, ${mapKind} target ${JSON.stringify(target.value)}: ${reason}`,
),
});
// Node's CommonJS finalizer checks the serialized URL, including suffixes;
// its ESM finalizer checks only the pathname. Neither error retries arrays.
if (input.moduleKind === "require" && /%(?:2f|5c)/iu.test(target.url.href))
return withTargetSource(
unresolvedCandidate("rejected", [
"The selected package URL contains encoded separator bytes.",
]),
);
const encodedPath = target.url.pathname.slice(
PACKAGE_ARTIFACT_ROOT_URL.pathname.length,
);
const rejected = rejectDeclaration(encodedPath);
if (rejected !== null) return withTargetSource(rejected);
const decoded = decodeModuleUrlPath(encodedPath);
if (typeof decoded !== "string") return withTargetSource(decoded);
const candidate = confineCandidate(decoded);
if (typeof candidate !== "string") return withTargetSource(candidate);
return (
resolveFileCandidates(input, [candidate]) ??
exactPackageTargetNotFound(candidate, mapKind)
withTargetSource(exactPackageTargetNotFound(candidate, mapKind))
);
};
@@ -776,7 +826,7 @@ const indexCandidates = (candidate: string): readonly string[] =>
type PackageTargetBlockCause = "null" | "empty-array";
type PackageExportOutcome =
| { readonly status: "value"; readonly value: string }
| ({ readonly status: "value" } & PackageFileTarget)
| { readonly status: "rejected"; readonly limitation: string }
| { readonly status: "blocked"; readonly cause: PackageTargetBlockCause }
| { readonly status: "unmatched"; readonly declared: readonly string[] };
@@ -786,6 +836,7 @@ const selectPackageExport = (
value: unknown,
subpath: string,
moduleKind: ResolveArtifactPathInput["moduleKind"],
packagePath: string,
): PackageExportOutcome => {
let target = value;
let declared: string[] = [];
@@ -812,6 +863,7 @@ const selectPackageExport = (
const selected = selectExportTarget(
target,
packageExportConditions(moduleKind),
packageMetadataUrl(packagePath),
);
if (selected.kind === "invalid" || selected.kind === "invalid-config")
return { status: "rejected", limitation: selected.limitation };
@@ -827,11 +879,15 @@ const selectPackageExport = (
status: "rejected",
limitation: `The package exports target ${JSON.stringify(selected.value)} must start with "./".`,
};
return { status: "value", value: selected.value };
return {
status: "value",
value: selected.value,
url: selected.url,
};
};
type ExportTargetOutcome =
| { readonly kind: "value"; readonly value: string }
| ({ readonly kind: "value" } & PackageFileTarget)
| { readonly kind: "non-relative"; readonly value: string }
| { readonly kind: "blocked"; readonly cause: PackageTargetBlockCause }
| { readonly kind: "unmatched"; readonly declared: readonly string[] }
@@ -866,6 +922,7 @@ const forbiddenExportSegment = (value: string): string | undefined =>
const selectStringPackageTarget = (
value: string,
packageUrl: URL,
mapKind: "exports" | "imports",
): ExportTargetOutcome => {
if (mapKind === "imports") {
@@ -880,18 +937,33 @@ const selectStringPackageTarget = (
} else if (!value.startsWith("./"))
return invalidExportTarget(value, 'must start with "./"', mapKind);
const forbidden = forbiddenExportSegment(value);
return forbidden === undefined
? { kind: "value", value }
: invalidExportTarget(
value,
`contains forbidden path segment ${JSON.stringify(forbidden)} before URL normalization`,
mapKind,
);
if (forbidden !== undefined)
return invalidExportTarget(
value,
`contains forbidden path segment ${JSON.stringify(forbidden)} before URL normalization`,
mapKind,
);
// Node constructs the URL before deciding whether this array entry is an
// invalid package target. Tabs/newlines and dot segments normalize here;
// percent decoding and existence checks belong after target selection.
const url = new URL(value, packageUrl);
if (!url.pathname.startsWith(new URL(".", packageUrl).pathname))
return invalidExportTarget(
value,
"escapes the package root after URL normalization",
mapKind,
);
return {
kind: "value",
value,
url,
};
};
const selectArrayPackageTarget = (
value: readonly unknown[],
conditions: ReadonlySet<string>,
packageUrl: URL,
mapKind: "exports" | "imports",
): ExportTargetOutcome => {
if (value.length === 0) return { kind: "blocked", cause: "empty-array" };
@@ -900,7 +972,7 @@ const selectArrayPackageTarget = (
| undefined;
const declared = new Set<string>();
for (const entry of value) {
const nested = selectExportTarget(entry, conditions, mapKind);
const nested = selectExportTarget(entry, conditions, packageUrl, mapKind);
// Node's array fallback catches invalid targets, not invalid configuration.
if (nested.kind === "invalid-config") return nested;
if (nested.kind === "unmatched") {
@@ -926,13 +998,14 @@ const selectArrayPackageTarget = (
const selectExportTarget = (
value: unknown,
conditions: ReadonlySet<string>,
packageUrl: URL,
mapKind: "exports" | "imports" = "exports",
): ExportTargetOutcome => {
if (typeof value === "string")
return selectStringPackageTarget(value, mapKind);
return selectStringPackageTarget(value, packageUrl, mapKind);
if (value === null) return { kind: "blocked", cause: "null" };
if (Array.isArray(value))
return selectArrayPackageTarget(value, conditions, mapKind);
return selectArrayPackageTarget(value, conditions, packageUrl, mapKind);
if (typeof value !== "object")
return invalidExportTarget(
value,
@@ -951,7 +1024,7 @@ const selectExportTarget = (
for (const [condition, target] of Object.entries(value)) {
declared.add(condition);
if (!conditions.has(condition)) continue;
const nested = selectExportTarget(target, conditions, mapKind);
const nested = selectExportTarget(target, conditions, packageUrl, mapKind);
if (nested.kind !== "unmatched") return nested;
for (const name of nested.declared) declared.add(name);
}