Skip to content

Commit

Permalink
fix: handle invalid files in imports and exports field properly
Browse files Browse the repository at this point in the history
  • Loading branch information
alexander-akait authored Jul 18, 2024
2 parents 7b6834c + d7a3003 commit ce50aa8
Show file tree
Hide file tree
Showing 13 changed files with 586 additions and 271 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [10.x, 12.x, 14.x, 16.x, 18.x, 20.x, 22.x]
node-version: [10.x, 12.x, 14.x, 16.x, 18.x, 20.x, 22.4]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
Expand Down
39 changes: 34 additions & 5 deletions lib/ExportsFieldPlugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ const DescriptionFileUtils = require("./DescriptionFileUtils");
const forEachBail = require("./forEachBail");
const { processExportsField } = require("./util/entrypoints");
const { parseIdentifier } = require("./util/identifier");
const { checkImportsExportsFieldTarget } = require("./util/path");
const {
invalidSegmentRegEx,
deprecatedInvalidSegmentRegEx
} = require("./util/path");

/** @typedef {import("./Resolver")} Resolver */
/** @typedef {import("./Resolver").JsonObject} JsonObject */
Expand Down Expand Up @@ -79,6 +82,8 @@ module.exports = class ExportsFieldPlugin {

/** @type {string[]} */
let paths;
/** @type {string | null} */
let usedField;

try {
// We attach the cache to the description file instead of the exportsField value
Expand All @@ -94,7 +99,10 @@ module.exports = class ExportsFieldPlugin {
fieldProcessor
);
}
paths = fieldProcessor(remainingRequest, this.conditionNames);
[paths, usedField] = fieldProcessor(
remainingRequest,
this.conditionNames
);
} catch (/** @type {unknown} */ err) {
if (resolveContext.log) {
resolveContext.log(
Expand Down Expand Up @@ -126,10 +134,31 @@ module.exports = class ExportsFieldPlugin {

const [relativePath, query, fragment] = parsedIdentifier;

const error = checkImportsExportsFieldTarget(relativePath);
if (relativePath.length === 0 || !relativePath.startsWith("./")) {
if (paths.length === 1) {
return callback(
new Error(
`Invalid "exports" target "${p}" defined for "${usedField}" in the package config ${request.descriptionFilePath}, targets must start with "./"`
)
);
}

return callback();
}

if (error) {
return callback(error);
if (
invalidSegmentRegEx.exec(relativePath.slice(2)) !== null &&
deprecatedInvalidSegmentRegEx.test(relativePath.slice(2)) !== null
) {
if (paths.length === 1) {
return callback(
new Error(
`Trying to access out of package scope. Requesting ${relativePath}`
)
);
}

return callback();
}

/** @type {ResolveRequest} */
Expand Down
28 changes: 20 additions & 8 deletions lib/ImportsFieldPlugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ const DescriptionFileUtils = require("./DescriptionFileUtils");
const forEachBail = require("./forEachBail");
const { processImportsField } = require("./util/entrypoints");
const { parseIdentifier } = require("./util/identifier");
const { checkImportsExportsFieldTarget } = require("./util/path");
const {
invalidSegmentRegEx,
deprecatedInvalidSegmentRegEx
} = require("./util/path");

/** @typedef {import("./Resolver")} Resolver */
/** @typedef {import("./Resolver").JsonObject} JsonObject */
Expand Down Expand Up @@ -97,7 +100,7 @@ module.exports = class ImportsFieldPlugin {
fieldProcessor
);
}
paths = fieldProcessor(remainingRequest, this.conditionNames);
[paths] = fieldProcessor(remainingRequest, this.conditionNames);
} catch (/** @type {unknown} */ err) {
if (resolveContext.log) {
resolveContext.log(
Expand Down Expand Up @@ -129,15 +132,24 @@ module.exports = class ImportsFieldPlugin {

const [path_, query, fragment] = parsedIdentifier;

const error = checkImportsExportsFieldTarget(path_);

if (error) {
return callback(error);
}

switch (path_.charCodeAt(0)) {
// should be relative
case dotCode: {
if (
invalidSegmentRegEx.exec(path_.slice(2)) !== null &&
deprecatedInvalidSegmentRegEx.test(path_.slice(2)) !== null
) {
if (paths.length === 1) {
return callback(
new Error(
`Trying to access out of package scope. Requesting ${path_}`
)
);
}

return callback();
}

/** @type {ResolveRequest} */
const obj = {
...request,
Expand Down
101 changes: 38 additions & 63 deletions lib/util/entrypoints.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* @callback FieldProcessor
* @param {string} request request
* @param {Set<string>} conditionNames condition names
* @returns {string[]} resolved paths
* @returns {[string[], string | null]} resolved paths with used field
*/

/*
Expand Down Expand Up @@ -72,6 +72,7 @@ Conditional mapping nested in another conditional mapping is called nested mappi
*/

const { parseIdentifier } = require("./identifier");
const slashCode = "/".charCodeAt(0);
const dotCode = ".".charCodeAt(0);
const hashCode = "#".charCodeAt(0);
Expand Down Expand Up @@ -100,7 +101,7 @@ module.exports.processImportsField = function processImportsField(
importsField
) {
return createFieldProcessor(
buildImportsField(importsField),
importsField,
request => "#" + request,
assertImportsFieldRequest,
assertImportTarget
Expand All @@ -125,9 +126,10 @@ function createFieldProcessor(

const match = findMatch(normalizeRequest(request), field);

if (match === null) return [];
if (match === null) return [[], null];

const [mapping, remainingRequest, isSubpathMapping, isPattern] = match;
const [mapping, remainingRequest, isSubpathMapping, isPattern, usedField] =
match;

/** @type {DirectMapping|null} */
let direct = null;
Expand All @@ -139,19 +141,22 @@ function createFieldProcessor(
);

// matching not found
if (direct === null) return [];
if (direct === null) return [[], null];
} else {
direct = /** @type {DirectMapping} */ (mapping);
}

return directMapping(
remainingRequest,
isPattern,
isSubpathMapping,
direct,
conditionNames,
assertTarget
);
return [
directMapping(
remainingRequest,
isPattern,
isSubpathMapping,
direct,
conditionNames,
assertTarget
),
usedField
];
};
}

Expand Down Expand Up @@ -200,18 +205,15 @@ function assertImportsFieldRequest(request) {
* @param {boolean} expectFolder is folder expected
*/
function assertExportTarget(exp, expectFolder) {
if (
exp.charCodeAt(0) === slashCode ||
(exp.charCodeAt(0) === dotCode && exp.charCodeAt(1) !== slashCode)
) {
throw new Error(
`Export should be relative path and start with "./", got ${JSON.stringify(
exp
)}.`
);
const parsedIdentifier = parseIdentifier(exp);

if (!parsedIdentifier) {
return;
}

const isFolder = exp.charCodeAt(exp.length - 1) === slashCode;
const [relativePath] = parsedIdentifier;
const isFolder =
relativePath.charCodeAt(relativePath.length - 1) === slashCode;

if (isFolder !== expectFolder) {
throw new Error(
Expand All @@ -231,7 +233,15 @@ function assertExportTarget(exp, expectFolder) {
* @param {boolean} expectFolder is folder expected
*/
function assertImportTarget(imp, expectFolder) {
const isFolder = imp.charCodeAt(imp.length - 1) === slashCode;
const parsedIdentifier = parseIdentifier(imp);

if (!parsedIdentifier) {
return;
}

const [relativePath] = parsedIdentifier;
const isFolder =
relativePath.charCodeAt(relativePath.length - 1) === slashCode;

if (isFolder !== expectFolder) {
throw new Error(
Expand Down Expand Up @@ -271,7 +281,7 @@ function patternKeyCompare(a, b) {
* Trying to match request to field
* @param {string} request request
* @param {ExportsField | ImportsField} field exports or import field
* @returns {[MappingValue, string, boolean, boolean]|null} match or null, number is negative and one less when it's a folder mapping, number is request.length + 1 for direct mappings
* @returns {[MappingValue, string, boolean, boolean, string]|null} match or null, number is negative and one less when it's a folder mapping, number is request.length + 1 for direct mappings
*/
function findMatch(request, field) {
if (
Expand All @@ -281,7 +291,7 @@ function findMatch(request, field) {
) {
const target = /** @type {{[k: string]: MappingValue}} */ (field)[request];

return [target, "", false, false];
return [target, "", false, false, request];
}

/** @type {string} */
Expand Down Expand Up @@ -332,7 +342,8 @@ function findMatch(request, field) {
target,
/** @type {string} */ (bestMatchSubpath),
isSubpathMapping,
isPattern
isPattern,
bestMatch
];
}

Expand Down Expand Up @@ -560,39 +571,3 @@ function buildExportsField(field) {

return field;
}

/**
* @param {ImportsField} field imports field
* @returns {ImportsField} normalized imports field
*/
function buildImportsField(field) {
const keys = Object.keys(field);

for (let i = 0; i < keys.length; i++) {
const key = keys[i];

if (key.charCodeAt(0) !== hashCode) {
throw new Error(
`Imports field key should start with "#" (key: ${JSON.stringify(key)})`
);
}

if (key.length === 1) {
throw new Error(
`Imports field key should have at least 2 characters (key: ${JSON.stringify(
key
)})`
);
}

if (key.charCodeAt(1) === slashCode) {
throw new Error(
`Imports field key should not start with "#/" (key: ${JSON.stringify(
key
)})`
);
}
}

return field;
}
42 changes: 8 additions & 34 deletions lib/util/path.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ const PathType = Object.freeze({
});
exports.PathType = PathType;

const invalidSegmentRegEx =
/(^|\\|\/)((\.|%2e)(\.|%2e)?|(n|%6e|%4e)(o|%6f|%4f)(d|%64|%44)(e|%65|%45)(_|%5f)(m|%6d|%4d)(o|%6f|%4f)(d|%64|%44)(u|%75|%55)(l|%6c|%4c)(e|%65|%45)(s|%73|%53))?(\\|\/|$)/i;
exports.invalidSegmentRegEx = invalidSegmentRegEx;

const deprecatedInvalidSegmentRegEx =
/(^|\\|\/)((\.|%2e)(\.|%2e)?|(n|%6e|%4e)(o|%6f|%4f)(d|%64|%44)(e|%65|%45)(_|%5f)(m|%6d|%4d)(o|%6f|%4f)(d|%64|%44)(u|%75|%55)(l|%6c|%4c)(e|%65|%45)(s|%73|%53))(\\|\/|$)/i;
exports.deprecatedInvalidSegmentRegEx = deprecatedInvalidSegmentRegEx;

/**
* @param {string} p a path
* @returns {PathType} type of path
Expand Down Expand Up @@ -193,37 +201,3 @@ const cachedJoin = (rootPath, request) => {
return cacheEntry;
};
exports.cachedJoin = cachedJoin;

/**
* @param {string} relativePath relative path
* @returns {undefined|Error} nothing or an error
*/
const checkImportsExportsFieldTarget = relativePath => {
let lastNonSlashIndex = 0;
let slashIndex = relativePath.indexOf("/", 1);
let cd = 0;

while (slashIndex !== -1) {
const folder = relativePath.slice(lastNonSlashIndex, slashIndex);

switch (folder) {
case "..": {
cd--;
if (cd < 0)
return new Error(
`Trying to access out of package scope. Requesting ${relativePath}`
);
break;
}
case ".":
break;
default:
cd++;
break;
}

lastNonSlashIndex = slashIndex + 1;
slashIndex = relativePath.indexOf("/", lastNonSlashIndex);
}
};
exports.checkImportsExportsFieldTarget = checkImportsExportsFieldTarget;
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
"pretty": "prettier --loglevel warn --write \"lib/**/*.{js,json}\" \"test/*.js\"",
"pretest": "yarn lint",
"spelling": "cspell \"**\"",
"test:only": "jest",
"test:only": "node_modules/.bin/jest",
"test:watch": "yarn test:only -- --watch",
"test:coverage": "yarn test:only -- --collectCoverageFrom=\"lib/**/*.js\" --coverage",
"test": "yarn test:coverage",
Expand Down
Loading

0 comments on commit ce50aa8

Please sign in to comment.