From 3a6ccb10be1b86ec46cb5e83273b598d3b964bac Mon Sep 17 00:00:00 2001 From: Simen Bekkhus Date: Tue, 24 Jan 2023 10:09:31 +0100 Subject: [PATCH 1/4] chore: add validation of import assertions --- .../runtime_import_assertions.test.js | 68 +++++++++++++++++++ packages/jest-runtime/src/index.ts | 64 +++++++++++++---- 2 files changed, 118 insertions(+), 14 deletions(-) create mode 100644 packages/jest-runtime/src/__tests__/runtime_import_assertions.test.js diff --git a/packages/jest-runtime/src/__tests__/runtime_import_assertions.test.js b/packages/jest-runtime/src/__tests__/runtime_import_assertions.test.js new file mode 100644 index 000000000000..2c0e817953bd --- /dev/null +++ b/packages/jest-runtime/src/__tests__/runtime_import_assertions.test.js @@ -0,0 +1,68 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {onNodeVersions} from '@jest/test-utils'; + +let runtime; + +// version where `vm` API gets `import assertions` +onNodeVersions('>=16.12.0', () => { + beforeAll(async () => { + const createRuntime = require('createRuntime'); + + runtime = await createRuntime(__filename); + }); + + describe('import assertions', () => { + const jsonFileName = `${__filename}on`; + + it('works if passed correct import assertion', () => { + expect(() => + runtime.validateImportAssertions(jsonFileName, '', {type: 'json'}), + ).not.toThrow(); + }); + + it('does nothing if no assertions passed for js file', () => { + expect(() => + runtime.validateImportAssertions(__filename, '', undefined), + ).not.toThrow(); + }); + + it('throws if invalid assertions are passed', () => { + expect(() => + runtime.validateImportAssertions(__filename, '', {}), + ).toThrow('Import assertion value must be a string'); + expect(() => + runtime.validateImportAssertions(__filename, '', { + somethingElse: 'json', + }), + ).toThrow('Import assertion value must be a string'); + expect(() => + runtime.validateImportAssertions(__filename, '', {type: null}), + ).toThrow('Import assertion value must be a string'); + expect(() => + runtime.validateImportAssertions(__filename, '', {type: 42}), + ).toThrow('Import assertion value must be a string'); + expect(() => + runtime.validateImportAssertions(__filename, '', {type: 'javascript'}), + ).toThrow('Import assertion type "javascript" is unsupported'); + }); + + it('throws if missing json assertions', () => { + expect(() => runtime.validateImportAssertions(jsonFileName, '')).toThrow( + `Module "${jsonFileName}" needs an import assertion of type "json"`, + ); + }); + + it('throws if json assertion passed on wrong file', () => { + expect(() => + runtime.validateImportAssertions(__filename, '', {type: 'json'}), + ).toThrow(`Module "${__filename}" is not of type "json"`); + }); + }); +}); diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index 237040006c05..cf4efbe5e420 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -418,21 +418,10 @@ export default class Runtime { private async loadEsmModule( modulePath: string, query = '', - importAssertions: ImportAssertions = {}, + importAssertions?: ImportAssertions, ): Promise { - if ( - runtimeSupportsImportAssertions && - modulePath.endsWith('.json') && - importAssertions.type !== 'json' - ) { - const error: NodeJS.ErrnoException = new Error( - `Module "${ - modulePath + (query ? `?${query}` : '') - }" needs an import assertion of type "json"`, - ); - error.code = 'ERR_IMPORT_ASSERTION_TYPE_MISSING'; - - throw error; + if (runtimeSupportsImportAssertions) { + this.validateImportAssertions(modulePath, query, importAssertions); } const cacheKey = modulePath + query; @@ -572,6 +561,53 @@ export default class Runtime { return module; } + private validateImportAssertions( + modulePath: string, + query: string, + importAssertions: ImportAssertions | undefined, + ) { + const assertionType = importAssertions?.type; + + if (importAssertions) { + if (typeof assertionType !== 'string') { + throw new TypeError('Import assertion value must be a string'); + } + + // Only `json` is supported + // https://github.com/nodejs/node/blob/7dd458382580f68cf7d718d96c8f4d2d3fe8b9db/lib/internal/modules/esm/assert.js#L20-L32 + if (assertionType !== 'json') { + const error: NodeJS.ErrnoException = new Error( + `Import assertion type "${assertionType}" is unsupported`, + ); + + error.code = 'ERR_IMPORT_ASSERTION_TYPE_UNSUPPORTED'; + + throw error; + } + + if (!modulePath.endsWith('.json')) { + const error: NodeJS.ErrnoException = new Error( + `Module "${modulePath}" is not of type "${assertionType}"`, + ); + + error.code = 'ERR_IMPORT_ASSERTION_TYPE_FAILED'; + + throw error; + } + } + + if (modulePath.endsWith('.json') && assertionType !== 'json') { + const error: NodeJS.ErrnoException = new Error( + `Module "${ + modulePath + (query ? `?${query}` : '') + }" needs an import assertion of type "json"`, + ); + error.code = 'ERR_IMPORT_ASSERTION_TYPE_MISSING'; + + throw error; + } + } + private async resolveModule( specifier: string, referencingIdentifier: string, From dea17bab2f2573ba86d54e8015b304a98c371452 Mon Sep 17 00:00:00 2001 From: Simen Bekkhus Date: Tue, 24 Jan 2023 10:10:28 +0100 Subject: [PATCH 2/4] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 497e1145a06d..b1e78df194b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ - `[jest-resolve]` Add global paths to `require.resolve.paths` ([#13633](https://github.com/facebook/jest/pull/13633)) - `[jest-runtime]` Support WASM files that import JS resources ([#13608](https://github.com/facebook/jest/pull/13608)) - `[jest-runtime]` Use the `scriptTransformer` cache in `jest-runner` ([#13735](https://github.com/facebook/jest/pull/13735)) -- `[jest-runtime]` Enforce import assertions when importing JSON in ESM ([#12755](https://github.com/facebook/jest/pull/12755)) +- `[jest-runtime]` Enforce import assertions when importing JSON in ESM ([#12755](https://github.com/facebook/jest/pull/12755) & [#13805](https://github.com/facebook/jest/pull/13805)) - `[jest-snapshot]` Make sure to import `babel` outside of the sandbox ([#13694](https://github.com/facebook/jest/pull/13694)) - `[jest-transform]` Ensure the correct configuration is passed to preprocessors specified multiple times in the `transform` option ([#13770](https://github.com/facebook/jest/pull/13770)) From 61ba25a6e6e3f042ba03593265da6985dd81be55 Mon Sep 17 00:00:00 2001 From: Simen Bekkhus Date: Tue, 24 Jan 2023 11:04:12 +0100 Subject: [PATCH 3/4] validate corectly --- .../runtime_import_assertions.test.js | 36 +++-- packages/jest-runtime/src/index.ts | 134 ++++++++++++++---- 2 files changed, 127 insertions(+), 43 deletions(-) diff --git a/packages/jest-runtime/src/__tests__/runtime_import_assertions.test.js b/packages/jest-runtime/src/__tests__/runtime_import_assertions.test.js index 2c0e817953bd..40118befbce2 100644 --- a/packages/jest-runtime/src/__tests__/runtime_import_assertions.test.js +++ b/packages/jest-runtime/src/__tests__/runtime_import_assertions.test.js @@ -6,6 +6,7 @@ * */ +import {pathToFileURL} from 'url'; import {onNodeVersions} from '@jest/test-utils'; let runtime; @@ -19,7 +20,9 @@ onNodeVersions('>=16.12.0', () => { }); describe('import assertions', () => { + const fileUrl = pathToFileURL(__filename).href; const jsonFileName = `${__filename}on`; + const jsonFileUrl = pathToFileURL(jsonFileName).href; it('works if passed correct import assertion', () => { expect(() => @@ -31,38 +34,45 @@ onNodeVersions('>=16.12.0', () => { expect(() => runtime.validateImportAssertions(__filename, '', undefined), ).not.toThrow(); + expect(() => + runtime.validateImportAssertions(__filename, '', {}), + ).not.toThrow(); }); it('throws if invalid assertions are passed', () => { expect(() => - runtime.validateImportAssertions(__filename, '', {}), - ).toThrow('Import assertion value must be a string'); - expect(() => - runtime.validateImportAssertions(__filename, '', { - somethingElse: 'json', - }), - ).toThrow('Import assertion value must be a string'); - expect(() => - runtime.validateImportAssertions(__filename, '', {type: null}), + runtime.validateImportAssertions(jsonFileName, '', {type: null}), ).toThrow('Import assertion value must be a string'); expect(() => - runtime.validateImportAssertions(__filename, '', {type: 42}), + runtime.validateImportAssertions(jsonFileName, '', {type: 42}), ).toThrow('Import assertion value must be a string'); expect(() => - runtime.validateImportAssertions(__filename, '', {type: 'javascript'}), + runtime.validateImportAssertions(jsonFileName, '', { + type: 'javascript', + }), ).toThrow('Import assertion type "javascript" is unsupported'); }); it('throws if missing json assertions', () => { + const errorMessage = `Module "${jsonFileUrl}" needs an import assertion of type "json"`; + + expect(() => + runtime.validateImportAssertions(jsonFileName, '', {}), + ).toThrow(errorMessage); + expect(() => + runtime.validateImportAssertions(jsonFileName, '', { + somethingElse: 'json', + }), + ).toThrow(errorMessage); expect(() => runtime.validateImportAssertions(jsonFileName, '')).toThrow( - `Module "${jsonFileName}" needs an import assertion of type "json"`, + errorMessage, ); }); it('throws if json assertion passed on wrong file', () => { expect(() => runtime.validateImportAssertions(__filename, '', {type: 'json'}), - ).toThrow(`Module "${__filename}" is not of type "json"`); + ).toThrow(`Module "${fileUrl}" is not of type "json"`); }); }); }); diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index cf4efbe5e420..6471b7c76a96 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -7,6 +7,7 @@ import nativeModule = require('module'); import * as path from 'path'; +import {extname} from 'path'; import {URL, fileURLToPath, pathToFileURL} from 'url'; import { Script, @@ -159,6 +160,23 @@ const supportsNodeColonModulePrefixInRequire = (() => { } })(); +const kImplicitAssertType = Symbol('kImplicitAssertType'); + +// copied from https://github.com/nodejs/node/blob/7dd458382580f68cf7d718d96c8f4d2d3fe8b9db/lib/internal/modules/esm/assert.js#L20-L32 +const formatTypeMap: {[type: string]: string | typeof kImplicitAssertType} = { + // @ts-expect-error - copied + __proto__: null, + builtin: kImplicitAssertType, + commonjs: kImplicitAssertType, + json: 'json', + module: kImplicitAssertType, + wasm: kImplicitAssertType, +}; + +const supportedAssertionTypes = new Set( + Object.values(formatTypeMap).filter(type => type !== kImplicitAssertType), +); + export default class Runtime { private readonly _cacheFS: Map; private readonly _cacheFSBuffer = new Map(); @@ -564,48 +582,78 @@ export default class Runtime { private validateImportAssertions( modulePath: string, query: string, - importAssertions: ImportAssertions | undefined, + importAssertions: ImportAssertions = { + // @ts-expect-error - copy https://github.com/nodejs/node/blob/7dd458382580f68cf7d718d96c8f4d2d3fe8b9db/lib/internal/modules/esm/assert.js#LL55C50-L55C65 + __proto__: null, + }, ) { - const assertionType = importAssertions?.type; + const format = this.getModuleFormat(modulePath); + const validType = formatTypeMap[format]; + const url = pathToFileURL(modulePath); - if (importAssertions) { - if (typeof assertionType !== 'string') { - throw new TypeError('Import assertion value must be a string'); - } + if (query) { + url.search = query; + } - // Only `json` is supported - // https://github.com/nodejs/node/blob/7dd458382580f68cf7d718d96c8f4d2d3fe8b9db/lib/internal/modules/esm/assert.js#L20-L32 - if (assertionType !== 'json') { - const error: NodeJS.ErrnoException = new Error( - `Import assertion type "${assertionType}" is unsupported`, - ); + const urlString = url.href; - error.code = 'ERR_IMPORT_ASSERTION_TYPE_UNSUPPORTED'; + const assertionType = importAssertions.type; - throw error; - } + switch (validType) { + case undefined: + // Ignore assertions for module formats we don't recognize, to allow new + // formats in the future. + return; - if (!modulePath.endsWith('.json')) { - const error: NodeJS.ErrnoException = new Error( - `Module "${modulePath}" is not of type "${assertionType}"`, - ); + case kImplicitAssertType: + // This format doesn't allow an import assertion type, so the property + // must not be set on the import assertions object. + if (Object.prototype.hasOwnProperty.call(importAssertions, 'type')) { + handleInvalidAssertionType(urlString, assertionType); + } + return; + + case assertionType: + // The asserted type is the valid type for this format. + return; + + default: + // There is an expected type for this format, but the value of + // `importAssertions.type` might not have been it. + if (!Object.prototype.hasOwnProperty.call(importAssertions, 'type')) { + // `type` wasn't specified at all. + const error: NodeJS.ErrnoException = new Error( + `Module "${urlString}" needs an import assertion of type "json"`, + ); + error.code = 'ERR_IMPORT_ASSERTION_TYPE_MISSING'; - error.code = 'ERR_IMPORT_ASSERTION_TYPE_FAILED'; + throw error; + } + handleInvalidAssertionType(urlString, assertionType); + } + } - throw error; - } + private getModuleFormat(modulePath: string) { + if (this._resolver.isCoreModule(modulePath)) { + return 'builtin'; } - if (modulePath.endsWith('.json') && assertionType !== 'json') { - const error: NodeJS.ErrnoException = new Error( - `Module "${ - modulePath + (query ? `?${query}` : '') - }" needs an import assertion of type "json"`, - ); - error.code = 'ERR_IMPORT_ASSERTION_TYPE_MISSING'; + if (isWasm(modulePath)) { + return 'wasm'; + } - throw error; + const fileExtension = extname(modulePath); + + if (fileExtension === '.json') { + return 'json'; } + + if (this.unstable_shouldLoadAsEsm(modulePath)) { + return 'module'; + } + + // any unknown format should be treated as JS + return 'commonjs'; } private async resolveModule( @@ -2549,3 +2597,29 @@ async function evaluateSyntheticModule(module: SyntheticModule) { return module; } + +function handleInvalidAssertionType(url: string, type: unknown) { + if (typeof type !== 'string') { + throw new TypeError('Import assertion value must be a string'); + } + + // `type` might not have been one of the types we understand. + if (!supportedAssertionTypes.has(type)) { + const error: NodeJS.ErrnoException = new Error( + `Import assertion type "${type}" is unsupported`, + ); + + error.code = 'ERR_IMPORT_ASSERTION_TYPE_UNSUPPORTED'; + + throw error; + } + + // `type` was the wrong value for this format. + const error: NodeJS.ErrnoException = new Error( + `Module "${url}" is not of type "${type}"`, + ); + + error.code = 'ERR_IMPORT_ASSERTION_TYPE_FAILED'; + + throw error; +} From 9a3196e7104335d3c536f82a34b001e9504e685a Mon Sep 17 00:00:00 2001 From: Simen Bekkhus Date: Tue, 24 Jan 2023 11:05:23 +0100 Subject: [PATCH 4/4] use single import --- packages/jest-runtime/src/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index 6471b7c76a96..5b57dc3546ca 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -7,7 +7,6 @@ import nativeModule = require('module'); import * as path from 'path'; -import {extname} from 'path'; import {URL, fileURLToPath, pathToFileURL} from 'url'; import { Script, @@ -642,7 +641,7 @@ export default class Runtime { return 'wasm'; } - const fileExtension = extname(modulePath); + const fileExtension = path.extname(modulePath); if (fileExtension === '.json') { return 'json';