Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
17 changes: 17 additions & 0 deletions .evergreen/config.in.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,18 @@ functions:
args:
- .evergreen/run-tests.sh

"run runtime independence tests":
- <<: *assume_secrets_manager_role
- command: subprocess.exec
type: test
params:
add_expansions_to_env: true
working_dir: "src"
timeout_secs: 300
binary: bash
args:
- .evergreen/run-runtime-independence-tests.sh

"perf send":
- command: s3.put
params:
Expand Down Expand Up @@ -1237,3 +1249,8 @@ buildvariants:
run_on: ubuntu2204-small
tasks:
- .alpine-fle
- name: Runtime Independence Test
display_name: Runtime Independence Test
run_on: ubuntu2204-small
tasks:
- .runtime-independence
32 changes: 32 additions & 0 deletions .evergreen/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,19 @@ functions:
binary: bash
args:
- .evergreen/run-tests.sh
run runtime independence tests:
- command: ec2.assume_role
params:
role_arn: ${DRIVERS_SECRETS_ARN}
- command: subprocess.exec
type: test
params:
add_expansions_to_env: true
working_dir: src
timeout_secs: 300
binary: bash
args:
- .evergreen/run-runtime-independence-tests.sh
perf send:
- command: s3.put
params:
Expand Down Expand Up @@ -2021,6 +2034,20 @@ tasks:
- func: bootstrap mongo-orchestration
- func: assume secrets manager role
- func: build and test alpine FLE
- name: test-runtime-independence
tags:
- runtime-independence
commands:
- command: expansions.update
type: setup
params:
updates:
- {key: NODE_LTS_VERSION, value: '24'}
- {key: VERSION, value: latest}
- {key: TOPOLOGY, value: replica_set}
- func: install dependencies
- func: bootstrap mongo-orchestration
- func: run runtime independence tests
- name: test-latest-server-noauth
tags:
- latest
Expand Down Expand Up @@ -2858,6 +2885,11 @@ buildvariants:
run_on: ubuntu2204-small
tasks:
- .alpine-fle
- name: Runtime Independence Test
display_name: Runtime Independence Test
run_on: ubuntu2204-small
tasks:
- .runtime-independence
- name: rhel80-large-iron
display_name: rhel8 Node20.19.0
run_on: rhel80-large
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: ['runtime-independence'],
commands: [
updateExpansions({
NODE_LTS_VERSION: LATEST_LTS,
VERSION: 'latest',
TOPOLOGY: 'replica_set'
}),
{ func: 'install dependencies' },
{ func: 'bootstrap mongo-orchestration' },
{ func: 'run runtime independence 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}

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 @@ -154,6 +154,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
249 changes: 249 additions & 0 deletions test/tools/runner/vm_runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
/* 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
};
} else {
throw new Error('tsconfig is missing');
}

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,
performance: global.performance,

process: process,

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('package.json')) {
const jsonContent = JSON.parse(fs.readFileSync(realPath, 'utf8'));
moduleCache.set(realPath, jsonContent);
return jsonContent;
}

// js-bson is allowed to use Buffer, only ./src/ is not
const isSourceFile = realPath.includes('/src/') && !realPath.includes('node_modules');
const isTestFile = realPath.includes('.test.ts') || realPath.includes('.test.js');

let localBuffer = Buffer;
if (isSourceFile && !isTestFile) {
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 guess this or similar way we can differentiate between source and test files, and allow 3rd party libraries (like bson) to use globals like Buffer. cc @PavelSafronov

localBuffer = new Proxy(Buffer, {
get(target, prop, receiver) {
if (
typeof prop === 'symbol' ||
['prototype', 'constructor', 'name', 'inspect'].includes(prop as string)
) {
return Reflect.get(target, prop, receiver);
}

throw new Error(
`Forbidden: 'Buffer' usage is not allowed in source files. Use Uint8Array instead. File: ${realPath}`
);
},
construct() {
throw new Error(
`Forbidden: 'Buffer' usage is not allowed in source files. Use Uint8Array instead. File: ${realPath}`
);
}
}) as any;
}

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, Buffer) {
${executableCode}
})`;
const script = new vm.Script(wrapper, { filename: realPath });
const fn = script.runInContext(sandbox);

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

const result = localModule.exports;

const isBSON = realPath.includes('node_modules/bson');
const isError = realPath.includes('src/error.ts');

if (isBSON || isError) {
Copy link
Member Author

@tadjik1 tadjik1 Jan 26, 2026

Choose a reason for hiding this comment

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

In order instanceof to work we have to propagate certain classes between contexts, most notable all Mongo*Errors as well as bson.Long.

for (const [key, value] of Object.entries(result)) {
if (typeof value === 'function' && value.name) {
// force instanceof to work across contexts by defining custom `instanceof` function
Object.defineProperty(value, Symbol.hasInstance, {
value: (i: any) => {
if (!i) return false;
// use isPrototypeOf to avoid triggering the 'instanceof' trap recursively
return (
i.constructor.name === value.name ||
Object.prototype.isPrototypeOf.call(value.prototype, i)
);
},
configurable: true
});

// also inject into global for easier access in tests
(sandbox as any)[key] = value;
}
}
}

moduleCache.set(realPath, result);

return result;
} catch (err: any) {
moduleCache.delete(realPath);
console.error(`Error running ${realPath} in sandbox:`, err);
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;
});