Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .evergreen/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2021,6 +2021,19 @@ tasks:
- func: bootstrap mongo-orchestration
- func: assume secrets manager role
- func: build and test alpine FLE
- name: test-runtime-independence
tags: []
commands:
- command: expansions.update
type: setup
params:
updates:
- {key: NODE_LTS_VERSION, value: 20.19.0}
- {key: VERSION, value: '7.0'}
- {key: TOPOLOGY, value: replica_set}
- func: install dependencies
- func: bootstrap mongo-orchestration
- func: run tests
- name: test-latest-server-noauth
tags:
- latest
Expand Down
15 changes: 15 additions & 0 deletions .evergreen/generate_evergreen_tasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,21 @@ SINGLETON_TASKS.push({
]
});

SINGLETON_TASKS.push({
name: 'test-runtime-independence',
tags: [],
commands: [
updateExpansions({
NODE_LTS_VERSION: LOWEST_LTS,
VERSION: '7.0',
TOPOLOGY: 'replica_set'
}),
{ func: 'install dependencies' },
{ func: 'bootstrap mongo-orchestration' },
{ func: 'run tests' }
]
});

function addPerformanceTasks() {
const makePerfTask = (name, MONGODB_CLIENT_OPTIONS) => ({
name,
Expand Down
28 changes: 28 additions & 0 deletions .evergreen/run-runtime-independence-tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/bin/bash
# set -o xtrace # Write all commands first to stderr
set -o errexit # Exit the script with error if any of the commands fail

# Supported/used environment variables:
# AUTH Set to enable authentication. Defaults to "noauth"
# MONGODB_URI Set the suggested connection MONGODB_URI (including credentials and topology info)
# MARCH Machine Architecture. Defaults to lowercase uname -m
# SKIP_DEPS Skip installing dependencies

AUTH=${AUTH:-noauth}
MONGODB_URI=${MONGODB_URI:-}
SKIP_DEPS=${SKIP_DEPS:-true}

# run tests
echo "Running $AUTH tests, connecting to $MONGODB_URI"

if [[ -z "${SKIP_DEPS}" ]]; then
source "${PROJECT_DIRECTORY}/.evergreen/install-dependencies.sh"
else
source $DRIVERS_TOOLS/.evergreen/init-node-and-npm-env.sh
fi

export AUTH=$AUTH
export MONGODB_API_VERSION=${MONGODB_API_VERSION}
export MONGODB_URI=${MONGODB_URI}

npx nyc npm run check:runtime-independence
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@
"check:csfle": "nyc mocha --config test/mocha_mongodb.js test/integration/client-side-encryption",
"check:snappy": "nyc mocha test/unit/assorted/snappy.test.js",
"check:x509": "nyc mocha test/manual/x509_auth.test.ts",
"check:runtime-independence": "ts-node test/tools/runner/vm_runner.ts test/integration/change-streams/change_stream.test.ts",
"fix:eslint": "npm run check:eslint -- --fix",
"prepare": "node etc/prepare.js",
"preview:docs": "ts-node etc/docs/preview.ts",
Expand Down
211 changes: 211 additions & 0 deletions test/tools/runner/vm_runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/* eslint-disable no-restricted-globals, @typescript-eslint/no-require-imports */

import * as fs from 'node:fs';
import { isBuiltin } from 'node:module';
import * as path from 'node:path';
import * as vm from 'node:vm';

import * as Mocha from 'mocha';
import * as ts from 'typescript';

import * as mochaConfiguration from '../../mocha_mongodb';

const mocha = new Mocha(mochaConfiguration);
mocha.suite.emit('pre-require', global, 'host-context', mocha);

// mocha hooks and custom "require" modules needs to be loaded and injected separately
require('./throw_rejections.cjs');
require('./chai_addons.ts');
require('./ee_checker.ts');
for (const path of ['./hooks/leak_checker.ts', './hooks/configuration.ts']) {
const mod = require(path);
const hooks = mod.mochaHooks;
const register = (hookName, globalFn) => {
if (hooks[hookName]) {
const list = Array.isArray(hooks[hookName]) ? hooks[hookName] : [hooks[hookName]];
list.forEach(fn => globalFn(fn));
}
};

register('beforeAll', global.before);
register('afterAll', global.after);
register('beforeEach', global.beforeEach);
register('afterEach', global.afterEach);
}

let compilerOptions: ts.CompilerOptions = { module: ts.ModuleKind.CommonJS };
const tsConfigPath = path.join(__dirname, '../../tsconfig.json');
const configFile = ts.readConfigFile(tsConfigPath, ts.sys.readFile);
if (!configFile.error) {
const parsedConfig = ts.parseJsonConfigFileContent(
configFile.config,
ts.sys,
path.dirname(tsConfigPath)
);
compilerOptions = {
...parsedConfig.options,
module: ts.ModuleKind.CommonJS,
sourceMap: false,
// inline source map for stack traces
inlineSourceMap: true
};
}

const moduleCache = new Map();

const sandbox = vm.createContext({
__proto__: null,

console: console,
AbortController: AbortController,
AbortSignal: AbortSignal,
Date: global.Date,
Error: global.Error,
URL: global.URL,
URLSearchParams: global.URLSearchParams,
queueMicrotask: queueMicrotask,

process: process,
Buffer: Buffer,

context: global.context,
describe: global.describe,
xdescribe: global.xdescribe,
it: global.it,
xit: global.xit,
before: global.before,
after: global.after,
beforeEach: global.beforeEach,
afterEach: global.afterEach
});

function createProxiedRequire(parentPath: string) {
const parentDir = path.dirname(parentPath);

return function sandboxRequire(moduleIdentifier: string) {
// allow all code modules be imported by the host environment
if (isBuiltin(moduleIdentifier)) {
return require(moduleIdentifier);
}

// list of dependencies we want to import from within the sandbox
const sandboxedDependencies = ['bson'];
const isSandboxedDep = sandboxedDependencies.some(
dep => moduleIdentifier === dep || moduleIdentifier.startsWith(`${dep}/`)
);
if (!moduleIdentifier.startsWith('.') && !isSandboxedDep) {
return require(moduleIdentifier);
}
Comment on lines +94 to +100
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@addaleax this seems to work kinda of fine, we can list dependencies that should be loaded from within the sandbox. i decided to go with this approach because list of dev and prod dependencies we need to import in host is large. but maybe i'm missing something and it should be the other way around (everything is inside except certain builtin modules)?


// require.resolve throws if module can't be loaded, let it bubble up
const fullPath = require.resolve(moduleIdentifier, { paths: [parentDir] });
return loadInSandbox(fullPath);
};
}

function loadInSandbox(filepath: string) {
const realPath = fs.realpathSync(filepath);

if (moduleCache.has(realPath)) {
return moduleCache.get(realPath);
}

// clientmetadata requires package.json to fetch driver's version
if (realPath.endsWith('.json')) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to do this for any *.json file, or only for package.json?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed to package.json only

const jsonContent = JSON.parse(fs.readFileSync(realPath, 'utf8'));
moduleCache.set(realPath, jsonContent);
return jsonContent;
}

const content = fs.readFileSync(realPath, 'utf8');
let executableCode: string;
if (realPath.endsWith('.ts')) {
executableCode = ts.transpileModule(content, {
compilerOptions: compilerOptions,
fileName: realPath
}).outputText;
} else {
// .js or .cjs should work just fine
executableCode = content;
}

const exportsContainer = {};
const localModule = { exports: exportsContainer };
const localRequire = createProxiedRequire(realPath);
const filename = realPath;
const dirname = path.dirname(realPath);

// prevent recursion
moduleCache.set(realPath, localModule.exports);

try {
const wrapper = `(function(exports, require, module, __filename, __dirname) {
${executableCode}
})`;
const script = new vm.Script(wrapper, { filename: realPath });
const fn = script.runInContext(sandbox);

fn(localModule.exports, localRequire, localModule, filename, dirname);

const result = localModule.exports;
if (realPath.includes('src/error.ts')) {
for (const [key, value] of Object.entries(result)) {
if (typeof value === 'function' && value.name?.startsWith('Mongo')) {
(sandbox as any)[key] = value;

// force instanceof to work across contexts by defining custom `instanceof` function
Object.defineProperty(value, Symbol.hasInstance, {
value: (instance: any) => {
return (
instance && (instance.constructor.name === value.name || instance instanceof value)
);
}
});
Comment on lines 187 to 198
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really like this part, but I found this problem a little bit tricky. The problem is that host environment (and consequently chai) has its' own set of Error classes, so instanceof from inside the sandbox is broken. @addaleax, maybe there is more convenient way to make it work rather than re-defining instanceof?

}
}
}

moduleCache.set(realPath, result);

return result;
} catch (err: any) {
moduleCache.delete(realPath);
console.error(`Error running ${realPath} in sandbox:`, err.stack);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

err.stack doesn't include code, message, and a bunch of other important info. Suggest we log the entire err object.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks!

throw err;
}
}

// use it similar to regular mocha:
// mocha --config test/mocha_mongodb.js test/integration
// ts-node test/runner/vm_context.ts test/integration
const userArgs = process.argv.slice(2);
const searchTargets = userArgs.length > 0 ? userArgs : ['test'];
const testFiles = searchTargets.flatMap(target => {
try {
const stats = fs.statSync(target);
if (stats.isDirectory()) {
const pattern = path.join(target, '**/*.test.{ts,js}').replace(/\\/g, '/');
return fs.globSync(pattern);
}
if (stats.isFile()) {
return [target];
}
} catch {
console.error(`Error: Could not find path "${target}"`);
}
return [];
});

if (testFiles.length === 0) {
console.log('No test files found.');
process.exit(0);
}

testFiles.forEach(file => {
loadInSandbox(path.resolve(file));
});

console.log('Running Tests...');
mocha.run(failures => {
process.exitCode = failures ? 1 : 0;
});