Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Jest-internal sandbox escape hatch #9907

Merged
merged 8 commits into from May 25, 2020
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
@@ -0,0 +1,84 @@
/**
* 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 type {Config} from '@jest/types';
import type Runtime from '..';
jeysal marked this conversation as resolved.
Show resolved Hide resolved
import {createOutsideJestVmPath} from '../helpers';

let createRuntime: (
path: string,
config?: Config.InitialOptions,
) => Promise<Runtime & {__mockRootPath: string}>;

describe('Runtime require.resolve', () => {
beforeEach(() => {
createRuntime = require('createRuntime');
});

it('resolves a module path', async () => {
const runtime = await createRuntime(__filename);
const resolved = runtime.requireModule(
runtime.__mockRootPath,
'./resolve_self.js',
);
expect(resolved).toEqual(require.resolve('./test_root/resolve_self.js'));
});

it('resolves a module path with moduleNameMapper', async () => {
const runtime = await createRuntime(__filename, {
moduleNameMapper: {
'^testMapped/(.*)': '<rootDir>/mapped_dir/$1',
},
});
const resolved = runtime.requireModule(
runtime.__mockRootPath,
'./resolve_mapped.js',
);
expect(resolved).toEqual(
require.resolve('./test_root/mapped_dir/moduleInMapped.js'),
);
});

describe('with the OUTSIDE_JEST_VM_RESOLVE_OPTION', () => {
it('forwards to the real Node require in an internal context', async () => {
const runtime = await createRuntime(__filename);
const module = runtime.requireInternalModule<any>(
runtime.__mockRootPath,
'./resolve_and_require_outside.js',
);
expect(module.required).toBe(
require('./test_root/create_require_module'),
);
});

it('ignores the option in an external context', async () => {
const runtime = await createRuntime(__filename);
const module = runtime.requireModule<any>(
runtime.__mockRootPath,
'./resolve_and_require_outside.js',
);
expect(module.required.foo).toBe('foo');
expect(module.required).not.toBe(
require('./test_root/create_require_module'),
);
});

// make sure we also check isInternal during require, not just during resolve
it('does not understand a self-constructed outsideJestVmPath in an external context', async () => {
const runtime = await createRuntime(__filename);
expect(() =>
runtime.requireModule<any>(
runtime.__mockRootPath,
createOutsideJestVmPath(
require.resolve('./test_root/create_require_module.js'),
),
),
).toThrow(/cannot find.+create_require_module/i);
});
});
});
@@ -0,0 +1,19 @@
/**
* 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.
*/

'use strict';

const resolved = require.resolve('./create_require_module', {
[Symbol.for('OUTSIDE_JEST_VM_RESOLVE_OPTION')]: true,
});
if (typeof resolved !== 'string') {
throw new Error('require.resolve not spec-compliant: must return a string');
}
module.exports = {
required: require(resolved),
resolved,
};
10 changes: 10 additions & 0 deletions packages/jest-runtime/src/__tests__/test_root/resolve_mapped.js
@@ -0,0 +1,10 @@
/**
* 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.
*/

'use strict';

module.exports = require.resolve('testMapped/moduleInMapped');
10 changes: 10 additions & 0 deletions packages/jest-runtime/src/__tests__/test_root/resolve_self.js
@@ -0,0 +1,10 @@
/**
* 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.
*/

'use strict';

module.exports = require.resolve('./resolve_self');
19 changes: 19 additions & 0 deletions packages/jest-runtime/src/helpers.ts
Expand Up @@ -10,6 +10,25 @@ import slash = require('slash');
import glob = require('glob');
import type {Config} from '@jest/types';

const OUTSIDE_JEST_VM_PROTOCOL = 'jest-main:';
// String manipulation is easier here, fileURLToPath is only in newer Nodes,
// plus setting non-standard protocols on URL objects is difficult.
jeysal marked this conversation as resolved.
Show resolved Hide resolved
export const createOutsideJestVmPath = (path: string): string =>
OUTSIDE_JEST_VM_PROTOCOL + '//' + encodeURIComponent(path);
export const decodePossibleOutsideJestVmPath = (
outsideJestVmPath: string,
): string | undefined => {
if (outsideJestVmPath.startsWith(OUTSIDE_JEST_VM_PROTOCOL)) {
return decodeURIComponent(
outsideJestVmPath.replace(
new RegExp('^' + OUTSIDE_JEST_VM_PROTOCOL + '//'),
'',
),
);
}
return undefined;
};

export const findSiblingsWithFileExtension = (
moduleFileExtensions: Config.ProjectConfig['moduleFileExtensions'],
from: Config.Path,
Expand Down
38 changes: 33 additions & 5 deletions packages/jest-runtime/src/index.ts
Expand Up @@ -45,7 +45,11 @@ import {CoverageInstrumenter, V8Coverage} from 'collect-v8-coverage';
import * as fs from 'graceful-fs';
import {run as cliRun} from './cli';
import {options as cliOptions} from './cli/args';
import {findSiblingsWithFileExtension} from './helpers';
import {
createOutsideJestVmPath,
decodePossibleOutsideJestVmPath,
findSiblingsWithFileExtension,
} from './helpers';
import type {Context as JestContext} from './types';
import jestMock = require('jest-mock');
import HasteMap = require('jest-haste-map');
Expand Down Expand Up @@ -81,7 +85,13 @@ const defaultTransformOptions: InternalModuleOptions = {
type InitialModule = Partial<Module> &
Pick<Module, 'children' | 'exports' | 'filename' | 'id' | 'loaded'>;
type ModuleRegistry = Map<string, InitialModule | Module>;
type ResolveOptions = Parameters<typeof require.resolve>[1];

const OUTSIDE_JEST_VM_RESOLVE_OPTION = Symbol.for(
'OUTSIDE_JEST_VM_RESOLVE_OPTION',
jeysal marked this conversation as resolved.
Show resolved Hide resolved
);
type ResolveOptions = Parameters<typeof require.resolve>[1] & {
[OUTSIDE_JEST_VM_RESOLVE_OPTION]?: true;
};

type BooleanObject = Record<string, boolean>;
type CacheFS = {[path: string]: string};
Expand Down Expand Up @@ -534,6 +544,13 @@ class Runtime {
}

requireInternalModule<T = unknown>(from: Config.Path, to?: string): T {
if (to) {
const outsideJestVmPath = decodePossibleOutsideJestVmPath(to);
if (outsideJestVmPath) {
return require(outsideJestVmPath);
}
}

return this.requireModule(from, to, {
isInternalModule: true,
supportsDynamicImport: false,
Expand Down Expand Up @@ -1269,9 +1286,20 @@ class Runtime {
from: InitialModule,
options?: InternalModuleOptions,
): LocalModuleRequire {
// TODO: somehow avoid having to type the arguments - they should come from `NodeRequire/LocalModuleRequire.resolve`
const resolve = (moduleName: string, options: ResolveOptions) =>
this._requireResolve(from.filename, moduleName, options);
const resolve = (moduleName: string, resolveOptions?: ResolveOptions) => {
const resolved = this._requireResolve(
from.filename,
moduleName,
resolveOptions,
);
if (
resolveOptions?.[OUTSIDE_JEST_VM_RESOLVE_OPTION] &&
options?.isInternalModule
) {
return createOutsideJestVmPath(resolved);
}
return resolved;
};
resolve.paths = (moduleName: string) =>
this._requireResolvePaths(from.filename, moduleName);

Expand Down