From eea1cc92e05ace23d3132c06f41362fc06fe2136 Mon Sep 17 00:00:00 2001 From: Rich Trott Date: Mon, 8 Nov 2021 14:41:21 -0800 Subject: [PATCH] test: fix flaky test-policy-integrity Split the test into seven tests so that it doesn't time out. Fixes: https://github.com/nodejs/node/issues/40694 Fixes: https://github.com/nodejs/node/issues/38088 --- test/pummel/pummel.status | 2 - ...egrity.js => test-policy-integrity-dep.js} | 62 +-- .../test-policy-integrity-parent-commonjs.js | 353 ++++++++++++++++ .../test-policy-integrity-parent-module.js | 353 ++++++++++++++++ ...policy-integrity-parent-no-package-json.js | 324 +++++++++++++++ .../test-policy-integrity-worker-commonjs.js | 376 ++++++++++++++++++ .../test-policy-integrity-worker-module.js | 374 +++++++++++++++++ ...policy-integrity-worker-no-package-json.js | 345 ++++++++++++++++ 8 files changed, 2139 insertions(+), 50 deletions(-) rename test/pummel/{test-policy-integrity.js => test-policy-integrity-dep.js} (84%) create mode 100644 test/pummel/test-policy-integrity-parent-commonjs.js create mode 100644 test/pummel/test-policy-integrity-parent-module.js create mode 100644 test/pummel/test-policy-integrity-parent-no-package-json.js create mode 100644 test/pummel/test-policy-integrity-worker-commonjs.js create mode 100644 test/pummel/test-policy-integrity-worker-module.js create mode 100644 test/pummel/test-policy-integrity-worker-no-package-json.js diff --git a/test/pummel/pummel.status b/test/pummel/pummel.status index a447b56498c123..5ff3cdbe0e5a7a 100644 --- a/test/pummel/pummel.status +++ b/test/pummel/pummel.status @@ -7,8 +7,6 @@ prefix pummel [true] # This section applies to all platforms [$system==win32] -# https://github.com/nodejs/node/issues/40694 -test-policy-integrity: PASS,FLAKY [$system==linux] # https://github.com/nodejs/node/issues/38226 diff --git a/test/pummel/test-policy-integrity.js b/test/pummel/test-policy-integrity-dep.js similarity index 84% rename from test/pummel/test-policy-integrity.js rename to test/pummel/test-policy-integrity-dep.js index 1626a4a4158f90..42948ce896e151 100644 --- a/test/pummel/test-policy-integrity.js +++ b/test/pummel/test-policy-integrity-dep.js @@ -60,24 +60,6 @@ const parentBody = { import(process.env.DEP_FILE) `, }; -const workerSpawningBody = ` - const path = require('path'); - const { Worker } = require('worker_threads'); - if (!process.env.PARENT_FILE) { - console.error( - 'missing required PARENT_FILE env to determine worker entry point' - ); - process.exit(33); - } - if (!process.env.DELETABLE_POLICY_FILE) { - console.error( - 'missing required DELETABLE_POLICY_FILE env to check reloading' - ); - process.exit(33); - } - const w = new Worker(path.resolve(process.env.PARENT_FILE)); - w.on('exit', (status) => process.exit(status === 0 ? 0 : 1)); -`; let nextTestId = 1; function newTestId() { @@ -100,12 +82,11 @@ function drainQueue() { if (toSpawn.length) { const config = toSpawn.shift(); const { - shouldSucceed, // = (() => { throw new Error('required')})(), - preloads, // = (() =>{ throw new Error('required')})(), - entryPath, // = (() => { throw new Error('required')})(), - willDeletePolicy, // = (() => { throw new Error('required')})(), - onError, // = (() => { throw new Error('required')})(), - resources, // = (() => { throw new Error('required')})(), + shouldSucceed, + preloads, + entryPath, + onError, + resources, parentPath, depPath, } = config; @@ -118,7 +99,7 @@ function drainQueue() { tmpdir.path, `deletable-policy-${testId}.json` ); - const cliPolicy = willDeletePolicy ? tmpPolicyPath : policyPath; + fs.rmSync(configDirPath, { maxRetries: 3, recursive: true, force: true }); fs.mkdirSync(configDirPath, { recursive: true }); const manifest = { @@ -140,7 +121,7 @@ function drainQueue() { } const manifestBody = JSON.stringify(manifest); fs.writeFileSync(manifestPath, manifestBody); - if (cliPolicy === tmpPolicyPath) { + if (policyPath === tmpPolicyPath) { fs.writeFileSync(tmpPolicyPath, manifestBody); } const spawnArgs = [ @@ -148,7 +129,7 @@ function drainQueue() { [ '--unhandled-rejections=strict', '--experimental-policy', - cliPolicy, + policyPath, ...preloads.flatMap((m) => ['-r', m]), entryPath, '--', @@ -255,7 +236,6 @@ function fileExtensionFormat(extension, packageType) { throw new Error('unknown format ' + extension); } for (const permutation of permutations({ - entry: ['worker', 'parent', 'dep'], preloads: [[], ['parent'], ['dep']], onError: ['log', 'exit'], parentExtension: ['.js', '.mjs', '.cjs'], @@ -282,14 +262,9 @@ for (const permutation of permutations({ continue; } const depPath = `./dep${permutation.depExtension}`; - const workerSpawnerPath = './worker-spawner.cjs'; - const entryPath = { - dep: depPath, - parent: parentPath, - worker: workerSpawnerPath, - }[permutation.entry]; + const packageJSON = { - main: entryPath, + main: depPath, type: permutation.packageType, }; if (permutation.packageType === 'no-field') { @@ -314,8 +289,7 @@ for (const permutation of permutations({ if (parentFormat !== 'commonjs') { permutation.preloads = permutation.preloads.filter((_) => _ !== 'parent'); } - const hasParent = - permutation.entry !== 'dep' || permutation.preloads.includes('parent'); + const hasParent = permutation.preloads.includes('parent'); if (hasParent) { resources[parentPath] = { body: parentBody[parentFormat], @@ -332,12 +306,7 @@ for (const permutation of permutations({ throw new Error('unreachable'); } } - if (permutation.entry === 'worker') { - resources[workerSpawnerPath] = { - body: workerSpawningBody, - integrities: hash('sha256', workerSpawningBody), - }; - } + if (permutation.packageType !== 'no-package-json') { let packageBody = JSON.stringify(packageJSON, null, 2); let packageIntegrities = hash('sha256', packageBody); @@ -364,18 +333,15 @@ for (const permutation of permutations({ integrities: packageIntegrities, }; } - const willDeletePolicy = permutation.entry === 'worker'; + if (permutation.onError === 'log') { shouldSucceed = true; } tests.add( JSON.stringify({ - // hasParent, - // original: permutation, onError: permutation.onError, shouldSucceed, - entryPath, - willDeletePolicy, + entryPath: depPath, preloads: permutation.preloads .map((_) => { return { diff --git a/test/pummel/test-policy-integrity-parent-commonjs.js b/test/pummel/test-policy-integrity-parent-commonjs.js new file mode 100644 index 00000000000000..9ea62e4423219c --- /dev/null +++ b/test/pummel/test-policy-integrity-parent-commonjs.js @@ -0,0 +1,353 @@ +'use strict'; + +const common = require('../common'); + +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +if (process.config.variables.arm_version === '7') { + common.skip('Too slow for armv7 bots'); +} + +common.requireNoPackageJSONAbove(); + +const { debuglog } = require('util'); +const debug = debuglog('test'); +const tmpdir = require('../common/tmpdir'); +const assert = require('assert'); +const { spawnSync, spawn } = require('child_process'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { pathToFileURL } = require('url'); + +const cpus = require('os').cpus().length; + +function hash(algo, body) { + const values = []; + { + const h = crypto.createHash(algo); + h.update(body); + values.push(`${algo}-${h.digest('base64')}`); + } + { + const h = crypto.createHash(algo); + h.update(body.replace('\n', '\r\n')); + values.push(`${algo}-${h.digest('base64')}`); + } + return values; +} + +const policyPath = './policy.json'; +const parentBody = { + commonjs: ` + if (!process.env.DEP_FILE) { + console.error( + 'missing required DEP_FILE env to determine dependency' + ); + process.exit(33); + } + require(process.env.DEP_FILE) + `, + module: ` + if (!process.env.DEP_FILE) { + console.error( + 'missing required DEP_FILE env to determine dependency' + ); + process.exit(33); + } + import(process.env.DEP_FILE) + `, +}; + +let nextTestId = 1; +function newTestId() { + return nextTestId++; +} +tmpdir.refresh(); +common.requireNoPackageJSONAbove(tmpdir.path); + +let spawned = 0; +const toSpawn = []; +function queueSpawn(opts) { + toSpawn.push(opts); + drainQueue(); +} + +function drainQueue() { + if (spawned > cpus) { + return; + } + if (toSpawn.length) { + const config = toSpawn.shift(); + const { + shouldSucceed, + preloads, + entryPath, + willDeletePolicy, + onError, + resources, + parentPath, + depPath, + } = config; + const testId = newTestId(); + const configDirPath = path.join( + tmpdir.path, + `test-policy-integrity-permutation-${testId}` + ); + const tmpPolicyPath = path.join( + tmpdir.path, + `deletable-policy-${testId}.json` + ); + const cliPolicy = willDeletePolicy ? tmpPolicyPath : policyPath; + fs.rmSync(configDirPath, { maxRetries: 3, recursive: true, force: true }); + fs.mkdirSync(configDirPath, { recursive: true }); + const manifest = { + onerror: onError, + resources: {}, + }; + const manifestPath = path.join(configDirPath, policyPath); + for (const [resourcePath, { body, integrities }] of Object.entries( + resources + )) { + const filePath = path.join(configDirPath, resourcePath); + if (integrities !== null) { + manifest.resources[pathToFileURL(filePath).href] = { + integrity: integrities.join(' '), + dependencies: true, + }; + } + fs.writeFileSync(filePath, body, 'utf8'); + } + const manifestBody = JSON.stringify(manifest); + fs.writeFileSync(manifestPath, manifestBody); + if (cliPolicy === tmpPolicyPath) { + fs.writeFileSync(tmpPolicyPath, manifestBody); + } + const spawnArgs = [ + process.execPath, + [ + '--unhandled-rejections=strict', + '--experimental-policy', + cliPolicy, + ...preloads.flatMap((m) => ['-r', m]), + entryPath, + '--', + testId, + configDirPath, + ], + { + env: { + ...process.env, + DELETABLE_POLICY_FILE: tmpPolicyPath, + PARENT_FILE: parentPath, + DEP_FILE: depPath, + }, + cwd: configDirPath, + stdio: 'pipe', + }, + ]; + spawned++; + const stdout = []; + const stderr = []; + const child = spawn(...spawnArgs); + child.stdout.on('data', (d) => stdout.push(d)); + child.stderr.on('data', (d) => stderr.push(d)); + child.on('exit', (status, signal) => { + spawned--; + try { + if (shouldSucceed) { + assert.strictEqual(status, 0); + } else { + assert.notStrictEqual(status, 0); + } + } catch (e) { + console.log( + 'permutation', + testId, + 'failed' + ); + console.dir( + { config, manifest }, + { depth: null } + ); + console.log('exit code:', status, 'signal:', signal); + console.log(`stdout: ${Buffer.concat(stdout)}`); + console.log(`stderr: ${Buffer.concat(stderr)}`); + throw e; + } + fs.rmSync(configDirPath, { maxRetries: 3, recursive: true, force: true }); + drainQueue(); + }); + } +} + +{ + const { status } = spawnSync( + process.execPath, + ['--experimental-policy', policyPath, '--experimental-policy', policyPath], + { + stdio: 'pipe', + } + ); + assert.notStrictEqual(status, 0, 'Should not allow multiple policies'); +} +{ + const enoentFilepath = path.join(tmpdir.path, 'enoent'); + try { + fs.unlinkSync(enoentFilepath); + } catch { } + const { status } = spawnSync( + process.execPath, + ['--experimental-policy', enoentFilepath, '-e', ''], + { + stdio: 'pipe', + } + ); + assert.notStrictEqual(status, 0, 'Should not allow missing policies'); +} + +/** + * @template {Record>} T + * @param {T} configurations + * @param {object} path + * @returns {Array<{[key: keyof T]: T[keyof configurations]}>} + */ +function permutations(configurations, path = {}) { + const keys = Object.keys(configurations); + if (keys.length === 0) { + return path; + } + const config = keys[0]; + const { [config]: values, ...otherConfigs } = configurations; + return values.flatMap((value) => { + return permutations(otherConfigs, { ...path, [config]: value }); + }); +} +const tests = new Set(); +function fileExtensionFormat(extension) { + if (extension === '.js') { + return 'commonjs'; + } else if (extension === '.mjs') { + return 'module'; + } else if (extension === '.cjs') { + return 'commonjs'; + } + throw new Error('unknown format ' + extension); +} +for (const permutation of permutations({ + preloads: [[], ['parent'], ['dep']], + onError: ['log', 'exit'], + parentExtension: ['.js', '.mjs', '.cjs'], + parentIntegrity: ['match', 'invalid', 'missing'], + depExtension: ['.js', '.mjs', '.cjs'], + depIntegrity: ['match', 'invalid', 'missing'], + packageIntegrity: ['match', 'invalid', 'missing'], +})) { + let shouldSucceed = true; + const parentPath = `./parent${permutation.parentExtension}`; + const parentFormat = fileExtensionFormat(permutation.parentExtension); + const depFormat = fileExtensionFormat(permutation.depExtension); + + // non-sensical attempt to require ESM + if (depFormat === 'module' && parentFormat === 'commonjs') { + continue; + } + const depPath = `./dep${permutation.depExtension}`; + const entryPath = parentPath; + const packageJSON = { + main: entryPath, + type: 'commonjs', + }; + + const resources = { + [depPath]: { + body: '', + integrities: hash('sha256', ''), + }, + }; + if (permutation.depIntegrity === 'invalid') { + resources[depPath].body += '\n// INVALID INTEGRITY'; + shouldSucceed = false; + } else if (permutation.depIntegrity === 'missing') { + resources[depPath].integrities = null; + shouldSucceed = false; + } else if (permutation.depIntegrity === 'match') { + } else { + throw new Error('unreachable'); + } + if (parentFormat !== 'commonjs') { + permutation.preloads = permutation.preloads.filter((_) => _ !== 'parent'); + } + + resources[parentPath] = { + body: parentBody[parentFormat], + integrities: hash('sha256', parentBody[parentFormat]), + }; + if (permutation.parentIntegrity === 'invalid') { + resources[parentPath].body += '\n// INVALID INTEGRITY'; + shouldSucceed = false; + } else if (permutation.parentIntegrity === 'missing') { + resources[parentPath].integrities = null; + shouldSucceed = false; + } else if (permutation.parentIntegrity === 'match') { + } else { + throw new Error('unreachable'); + } + + let packageBody = JSON.stringify(packageJSON, null, 2); + let packageIntegrities = hash('sha256', packageBody); + if ( + permutation.parentExtension !== '.js' || + permutation.depExtension !== '.js' + ) { + // NO PACKAGE LOOKUP + continue; + } + if (permutation.packageIntegrity === 'invalid') { + packageJSON['//'] = 'INVALID INTEGRITY'; + packageBody = JSON.stringify(packageJSON, null, 2); + shouldSucceed = false; + } else if (permutation.packageIntegrity === 'missing') { + packageIntegrities = []; + shouldSucceed = false; + } else if (permutation.packageIntegrity === 'match') { + } else { + throw new Error('unreachable'); + } + resources['./package.json'] = { + body: packageBody, + integrities: packageIntegrities, + }; + + if (permutation.onError === 'log') { + shouldSucceed = true; + } + tests.add( + JSON.stringify({ + onError: permutation.onError, + shouldSucceed, + entryPath, + willDeletePolicy: false, + preloads: permutation.preloads + .map((_) => { + return { + '': '', + 'parent': parentFormat === 'commonjs' ? parentPath : '', + 'dep': depFormat === 'commonjs' ? depPath : '', + }[_]; + }) + .filter(Boolean), + parentPath, + depPath, + resources, + }) + ); +} +debug(`spawning ${tests.size} policy integrity permutations`); + +for (const config of tests) { + const parsed = JSON.parse(config); + queueSpawn(parsed); +} diff --git a/test/pummel/test-policy-integrity-parent-module.js b/test/pummel/test-policy-integrity-parent-module.js new file mode 100644 index 00000000000000..aed2528bbeeb5c --- /dev/null +++ b/test/pummel/test-policy-integrity-parent-module.js @@ -0,0 +1,353 @@ +'use strict'; + +const common = require('../common'); + +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +if (process.config.variables.arm_version === '7') { + common.skip('Too slow for armv7 bots'); +} + +common.requireNoPackageJSONAbove(); + +const { debuglog } = require('util'); +const debug = debuglog('test'); +const tmpdir = require('../common/tmpdir'); +const assert = require('assert'); +const { spawnSync, spawn } = require('child_process'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { pathToFileURL } = require('url'); + +const cpus = require('os').cpus().length; + +function hash(algo, body) { + const values = []; + { + const h = crypto.createHash(algo); + h.update(body); + values.push(`${algo}-${h.digest('base64')}`); + } + { + const h = crypto.createHash(algo); + h.update(body.replace('\n', '\r\n')); + values.push(`${algo}-${h.digest('base64')}`); + } + return values; +} + +const policyPath = './policy.json'; +const parentBody = { + commonjs: ` + if (!process.env.DEP_FILE) { + console.error( + 'missing required DEP_FILE env to determine dependency' + ); + process.exit(33); + } + require(process.env.DEP_FILE) + `, + module: ` + if (!process.env.DEP_FILE) { + console.error( + 'missing required DEP_FILE env to determine dependency' + ); + process.exit(33); + } + import(process.env.DEP_FILE) + `, +}; + +let nextTestId = 1; +function newTestId() { + return nextTestId++; +} +tmpdir.refresh(); +common.requireNoPackageJSONAbove(tmpdir.path); + +let spawned = 0; +const toSpawn = []; +function queueSpawn(opts) { + toSpawn.push(opts); + drainQueue(); +} + +function drainQueue() { + if (spawned > cpus) { + return; + } + if (toSpawn.length) { + const config = toSpawn.shift(); + const { + shouldSucceed, + preloads, + entryPath, + willDeletePolicy, + onError, + resources, + parentPath, + depPath, + } = config; + const testId = newTestId(); + const configDirPath = path.join( + tmpdir.path, + `test-policy-integrity-permutation-${testId}` + ); + const tmpPolicyPath = path.join( + tmpdir.path, + `deletable-policy-${testId}.json` + ); + const cliPolicy = willDeletePolicy ? tmpPolicyPath : policyPath; + fs.rmSync(configDirPath, { maxRetries: 3, recursive: true, force: true }); + fs.mkdirSync(configDirPath, { recursive: true }); + const manifest = { + onerror: onError, + resources: {}, + }; + const manifestPath = path.join(configDirPath, policyPath); + for (const [resourcePath, { body, integrities }] of Object.entries( + resources + )) { + const filePath = path.join(configDirPath, resourcePath); + if (integrities !== null) { + manifest.resources[pathToFileURL(filePath).href] = { + integrity: integrities.join(' '), + dependencies: true, + }; + } + fs.writeFileSync(filePath, body, 'utf8'); + } + const manifestBody = JSON.stringify(manifest); + fs.writeFileSync(manifestPath, manifestBody); + if (cliPolicy === tmpPolicyPath) { + fs.writeFileSync(tmpPolicyPath, manifestBody); + } + const spawnArgs = [ + process.execPath, + [ + '--unhandled-rejections=strict', + '--experimental-policy', + cliPolicy, + ...preloads.flatMap((m) => ['-r', m]), + entryPath, + '--', + testId, + configDirPath, + ], + { + env: { + ...process.env, + DELETABLE_POLICY_FILE: tmpPolicyPath, + PARENT_FILE: parentPath, + DEP_FILE: depPath, + }, + cwd: configDirPath, + stdio: 'pipe', + }, + ]; + spawned++; + const stdout = []; + const stderr = []; + const child = spawn(...spawnArgs); + child.stdout.on('data', (d) => stdout.push(d)); + child.stderr.on('data', (d) => stderr.push(d)); + child.on('exit', (status, signal) => { + spawned--; + try { + if (shouldSucceed) { + assert.strictEqual(status, 0); + } else { + assert.notStrictEqual(status, 0); + } + } catch (e) { + console.log( + 'permutation', + testId, + 'failed' + ); + console.dir( + { config, manifest }, + { depth: null } + ); + console.log('exit code:', status, 'signal:', signal); + console.log(`stdout: ${Buffer.concat(stdout)}`); + console.log(`stderr: ${Buffer.concat(stderr)}`); + throw e; + } + fs.rmSync(configDirPath, { maxRetries: 3, recursive: true, force: true }); + drainQueue(); + }); + } +} + +{ + const { status } = spawnSync( + process.execPath, + ['--experimental-policy', policyPath, '--experimental-policy', policyPath], + { + stdio: 'pipe', + } + ); + assert.notStrictEqual(status, 0, 'Should not allow multiple policies'); +} +{ + const enoentFilepath = path.join(tmpdir.path, 'enoent'); + try { + fs.unlinkSync(enoentFilepath); + } catch { } + const { status } = spawnSync( + process.execPath, + ['--experimental-policy', enoentFilepath, '-e', ''], + { + stdio: 'pipe', + } + ); + assert.notStrictEqual(status, 0, 'Should not allow missing policies'); +} + +/** + * @template {Record>} T + * @param {T} configurations + * @param {object} path + * @returns {Array<{[key: keyof T]: T[keyof configurations]}>} + */ +function permutations(configurations, path = {}) { + const keys = Object.keys(configurations); + if (keys.length === 0) { + return path; + } + const config = keys[0]; + const { [config]: values, ...otherConfigs } = configurations; + return values.flatMap((value) => { + return permutations(otherConfigs, { ...path, [config]: value }); + }); +} +const tests = new Set(); +function fileExtensionFormat(extension) { + if (extension === '.js') { + return 'module'; + } else if (extension === '.mjs') { + return 'module'; + } else if (extension === '.cjs') { + return 'commonjs'; + } + throw new Error('unknown format ' + extension); +} +for (const permutation of permutations({ + preloads: [[], ['parent'], ['dep']], + onError: ['log', 'exit'], + parentExtension: ['.js', '.mjs', '.cjs'], + parentIntegrity: ['match', 'invalid', 'missing'], + depExtension: ['.js', '.mjs', '.cjs'], + depIntegrity: ['match', 'invalid', 'missing'], + packageIntegrity: ['match', 'invalid', 'missing'], +})) { + let shouldSucceed = true; + const parentPath = `./parent${permutation.parentExtension}`; + const parentFormat = fileExtensionFormat(permutation.parentExtension); + const depFormat = fileExtensionFormat(permutation.depExtension); + + // non-sensical attempt to require ESM + if (depFormat === 'module' && parentFormat === 'commonjs') { + continue; + } + const depPath = `./dep${permutation.depExtension}`; + const entryPath = parentPath; + const packageJSON = { + main: entryPath, + type: 'module', + }; + + const resources = { + [depPath]: { + body: '', + integrities: hash('sha256', ''), + }, + }; + if (permutation.depIntegrity === 'invalid') { + resources[depPath].body += '\n// INVALID INTEGRITY'; + shouldSucceed = false; + } else if (permutation.depIntegrity === 'missing') { + resources[depPath].integrities = null; + shouldSucceed = false; + } else if (permutation.depIntegrity === 'match') { + } else { + throw new Error('unreachable'); + } + if (parentFormat !== 'commonjs') { + permutation.preloads = permutation.preloads.filter((_) => _ !== 'parent'); + } + + resources[parentPath] = { + body: parentBody[parentFormat], + integrities: hash('sha256', parentBody[parentFormat]), + }; + if (permutation.parentIntegrity === 'invalid') { + resources[parentPath].body += '\n// INVALID INTEGRITY'; + shouldSucceed = false; + } else if (permutation.parentIntegrity === 'missing') { + resources[parentPath].integrities = null; + shouldSucceed = false; + } else if (permutation.parentIntegrity === 'match') { + } else { + throw new Error('unreachable'); + } + + let packageBody = JSON.stringify(packageJSON, null, 2); + let packageIntegrities = hash('sha256', packageBody); + if ( + permutation.parentExtension !== '.js' || + permutation.depExtension !== '.js' + ) { + // NO PACKAGE LOOKUP + continue; + } + if (permutation.packageIntegrity === 'invalid') { + packageJSON['//'] = 'INVALID INTEGRITY'; + packageBody = JSON.stringify(packageJSON, null, 2); + shouldSucceed = false; + } else if (permutation.packageIntegrity === 'missing') { + packageIntegrities = []; + shouldSucceed = false; + } else if (permutation.packageIntegrity === 'match') { + } else { + throw new Error('unreachable'); + } + resources['./package.json'] = { + body: packageBody, + integrities: packageIntegrities, + }; + + if (permutation.onError === 'log') { + shouldSucceed = true; + } + tests.add( + JSON.stringify({ + onError: permutation.onError, + shouldSucceed, + entryPath, + willDeletePolicy: false, + preloads: permutation.preloads + .map((_) => { + return { + '': '', + 'parent': parentFormat === 'commonjs' ? parentPath : '', + 'dep': depFormat === 'commonjs' ? depPath : '', + }[_]; + }) + .filter(Boolean), + parentPath, + depPath, + resources, + }) + ); +} +debug(`spawning ${tests.size} policy integrity permutations`); + +for (const config of tests) { + const parsed = JSON.parse(config); + queueSpawn(parsed); +} diff --git a/test/pummel/test-policy-integrity-parent-no-package-json.js b/test/pummel/test-policy-integrity-parent-no-package-json.js new file mode 100644 index 00000000000000..c00a5e31921efc --- /dev/null +++ b/test/pummel/test-policy-integrity-parent-no-package-json.js @@ -0,0 +1,324 @@ +'use strict'; + +const common = require('../common'); + +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +if (process.config.variables.arm_version === '7') { + common.skip('Too slow for armv7 bots'); +} + +common.requireNoPackageJSONAbove(); + +const { debuglog } = require('util'); +const debug = debuglog('test'); +const tmpdir = require('../common/tmpdir'); +const assert = require('assert'); +const { spawnSync, spawn } = require('child_process'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { pathToFileURL } = require('url'); + +const cpus = require('os').cpus().length; + +function hash(algo, body) { + const values = []; + { + const h = crypto.createHash(algo); + h.update(body); + values.push(`${algo}-${h.digest('base64')}`); + } + { + const h = crypto.createHash(algo); + h.update(body.replace('\n', '\r\n')); + values.push(`${algo}-${h.digest('base64')}`); + } + return values; +} + +const policyPath = './policy.json'; +const parentBody = { + commonjs: ` + if (!process.env.DEP_FILE) { + console.error( + 'missing required DEP_FILE env to determine dependency' + ); + process.exit(33); + } + require(process.env.DEP_FILE) + `, + module: ` + if (!process.env.DEP_FILE) { + console.error( + 'missing required DEP_FILE env to determine dependency' + ); + process.exit(33); + } + import(process.env.DEP_FILE) + `, +}; + +let nextTestId = 1; +function newTestId() { + return nextTestId++; +} +tmpdir.refresh(); +common.requireNoPackageJSONAbove(tmpdir.path); + +let spawned = 0; +const toSpawn = []; +function queueSpawn(opts) { + toSpawn.push(opts); + drainQueue(); +} + +function drainQueue() { + if (spawned > cpus) { + return; + } + if (toSpawn.length) { + const config = toSpawn.shift(); + const { + shouldSucceed, + preloads, + entryPath, + willDeletePolicy, + onError, + resources, + parentPath, + depPath, + } = config; + const testId = newTestId(); + const configDirPath = path.join( + tmpdir.path, + `test-policy-integrity-permutation-${testId}` + ); + const tmpPolicyPath = path.join( + tmpdir.path, + `deletable-policy-${testId}.json` + ); + const cliPolicy = willDeletePolicy ? tmpPolicyPath : policyPath; + fs.rmSync(configDirPath, { maxRetries: 3, recursive: true, force: true }); + fs.mkdirSync(configDirPath, { recursive: true }); + const manifest = { + onerror: onError, + resources: {}, + }; + const manifestPath = path.join(configDirPath, policyPath); + for (const [resourcePath, { body, integrities }] of Object.entries( + resources + )) { + const filePath = path.join(configDirPath, resourcePath); + if (integrities !== null) { + manifest.resources[pathToFileURL(filePath).href] = { + integrity: integrities.join(' '), + dependencies: true, + }; + } + fs.writeFileSync(filePath, body, 'utf8'); + } + const manifestBody = JSON.stringify(manifest); + fs.writeFileSync(manifestPath, manifestBody); + if (cliPolicy === tmpPolicyPath) { + fs.writeFileSync(tmpPolicyPath, manifestBody); + } + const spawnArgs = [ + process.execPath, + [ + '--unhandled-rejections=strict', + '--experimental-policy', + cliPolicy, + ...preloads.flatMap((m) => ['-r', m]), + entryPath, + '--', + testId, + configDirPath, + ], + { + env: { + ...process.env, + DELETABLE_POLICY_FILE: tmpPolicyPath, + PARENT_FILE: parentPath, + DEP_FILE: depPath, + }, + cwd: configDirPath, + stdio: 'pipe', + }, + ]; + spawned++; + const stdout = []; + const stderr = []; + const child = spawn(...spawnArgs); + child.stdout.on('data', (d) => stdout.push(d)); + child.stderr.on('data', (d) => stderr.push(d)); + child.on('exit', (status, signal) => { + spawned--; + try { + if (shouldSucceed) { + assert.strictEqual(status, 0); + } else { + assert.notStrictEqual(status, 0); + } + } catch (e) { + console.log( + 'permutation', + testId, + 'failed' + ); + console.dir( + { config, manifest }, + { depth: null } + ); + console.log('exit code:', status, 'signal:', signal); + console.log(`stdout: ${Buffer.concat(stdout)}`); + console.log(`stderr: ${Buffer.concat(stderr)}`); + throw e; + } + fs.rmSync(configDirPath, { maxRetries: 3, recursive: true, force: true }); + drainQueue(); + }); + } +} + +{ + const { status } = spawnSync( + process.execPath, + ['--experimental-policy', policyPath, '--experimental-policy', policyPath], + { + stdio: 'pipe', + } + ); + assert.notStrictEqual(status, 0, 'Should not allow multiple policies'); +} +{ + const enoentFilepath = path.join(tmpdir.path, 'enoent'); + try { + fs.unlinkSync(enoentFilepath); + } catch { } + const { status } = spawnSync( + process.execPath, + ['--experimental-policy', enoentFilepath, '-e', ''], + { + stdio: 'pipe', + } + ); + assert.notStrictEqual(status, 0, 'Should not allow missing policies'); +} + +/** + * @template {Record>} T + * @param {T} configurations + * @param {object} path + * @returns {Array<{[key: keyof T]: T[keyof configurations]}>} + */ +function permutations(configurations, path = {}) { + const keys = Object.keys(configurations); + if (keys.length === 0) { + return path; + } + const config = keys[0]; + const { [config]: values, ...otherConfigs } = configurations; + return values.flatMap((value) => { + return permutations(otherConfigs, { ...path, [config]: value }); + }); +} +const tests = new Set(); +function fileExtensionFormat(extension) { + if (extension === '.js') { + return 'commonjs'; + } else if (extension === '.mjs') { + return 'module'; + } else if (extension === '.cjs') { + return 'commonjs'; + } + throw new Error('unknown format ' + extension); +} +for (const permutation of permutations({ + preloads: [[], ['parent'], ['dep']], + onError: ['log', 'exit'], + parentExtension: ['.js', '.mjs', '.cjs'], + parentIntegrity: ['match', 'invalid', 'missing'], + depExtension: ['.js', '.mjs', '.cjs'], + depIntegrity: ['match', 'invalid', 'missing'], + packageIntegrity: ['match', 'invalid', 'missing'], +})) { + let shouldSucceed = true; + const parentPath = `./parent${permutation.parentExtension}`; + const parentFormat = fileExtensionFormat(permutation.parentExtension); + const depFormat = fileExtensionFormat(permutation.depExtension); + + // non-sensical attempt to require ESM + if (depFormat === 'module' && parentFormat === 'commonjs') { + continue; + } + const depPath = `./dep${permutation.depExtension}`; + const entryPath = parentPath; + + const resources = { + [depPath]: { + body: '', + integrities: hash('sha256', ''), + }, + }; + if (permutation.depIntegrity === 'invalid') { + resources[depPath].body += '\n// INVALID INTEGRITY'; + shouldSucceed = false; + } else if (permutation.depIntegrity === 'missing') { + resources[depPath].integrities = null; + shouldSucceed = false; + } else if (permutation.depIntegrity === 'match') { + } else { + throw new Error('unreachable'); + } + if (parentFormat !== 'commonjs') { + permutation.preloads = permutation.preloads.filter((_) => _ !== 'parent'); + } + + resources[parentPath] = { + body: parentBody[parentFormat], + integrities: hash('sha256', parentBody[parentFormat]), + }; + if (permutation.parentIntegrity === 'invalid') { + resources[parentPath].body += '\n// INVALID INTEGRITY'; + shouldSucceed = false; + } else if (permutation.parentIntegrity === 'missing') { + resources[parentPath].integrities = null; + shouldSucceed = false; + } else if (permutation.parentIntegrity === 'match') { + } else { + throw new Error('unreachable'); + } + + if (permutation.onError === 'log') { + shouldSucceed = true; + } + tests.add( + JSON.stringify({ + onError: permutation.onError, + shouldSucceed, + entryPath, + willDeletePolicy: false, + preloads: permutation.preloads + .map((_) => { + return { + '': '', + 'parent': parentFormat === 'commonjs' ? parentPath : '', + 'dep': depFormat === 'commonjs' ? depPath : '', + }[_]; + }) + .filter(Boolean), + parentPath, + depPath, + resources, + }) + ); +} +debug(`spawning ${tests.size} policy integrity permutations`); + +for (const config of tests) { + const parsed = JSON.parse(config); + queueSpawn(parsed); +} diff --git a/test/pummel/test-policy-integrity-worker-commonjs.js b/test/pummel/test-policy-integrity-worker-commonjs.js new file mode 100644 index 00000000000000..6ddcb74c9dae40 --- /dev/null +++ b/test/pummel/test-policy-integrity-worker-commonjs.js @@ -0,0 +1,376 @@ +'use strict'; + +const common = require('../common'); + +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +if (process.config.variables.arm_version === '7') { + common.skip('Too slow for armv7 bots'); +} + +common.requireNoPackageJSONAbove(); + +const { debuglog } = require('util'); +const debug = debuglog('test'); +const tmpdir = require('../common/tmpdir'); +const assert = require('assert'); +const { spawnSync, spawn } = require('child_process'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { pathToFileURL } = require('url'); + +const cpus = require('os').cpus().length; + +function hash(algo, body) { + const values = []; + { + const h = crypto.createHash(algo); + h.update(body); + values.push(`${algo}-${h.digest('base64')}`); + } + { + const h = crypto.createHash(algo); + h.update(body.replace('\n', '\r\n')); + values.push(`${algo}-${h.digest('base64')}`); + } + return values; +} + +const policyPath = './policy.json'; +const parentBody = { + commonjs: ` + if (!process.env.DEP_FILE) { + console.error( + 'missing required DEP_FILE env to determine dependency' + ); + process.exit(33); + } + require(process.env.DEP_FILE) + `, + module: ` + if (!process.env.DEP_FILE) { + console.error( + 'missing required DEP_FILE env to determine dependency' + ); + process.exit(33); + } + import(process.env.DEP_FILE) + `, +}; +const workerSpawningBody = ` + const path = require('path'); + const { Worker } = require('worker_threads'); + if (!process.env.PARENT_FILE) { + console.error( + 'missing required PARENT_FILE env to determine worker entry point' + ); + process.exit(33); + } + if (!process.env.DELETABLE_POLICY_FILE) { + console.error( + 'missing required DELETABLE_POLICY_FILE env to check reloading' + ); + process.exit(33); + } + const w = new Worker(path.resolve(process.env.PARENT_FILE)); + w.on('exit', (status) => process.exit(status === 0 ? 0 : 1)); +`; + +let nextTestId = 1; +function newTestId() { + return nextTestId++; +} +tmpdir.refresh(); +common.requireNoPackageJSONAbove(tmpdir.path); + +let spawned = 0; +const toSpawn = []; +function queueSpawn(opts) { + toSpawn.push(opts); + drainQueue(); +} + +function drainQueue() { + if (spawned > cpus) { + return; + } + if (toSpawn.length) { + const config = toSpawn.shift(); + const { + shouldSucceed, + preloads, + entryPath, + onError, + resources, + parentPath, + depPath, + } = config; + const testId = newTestId(); + const configDirPath = path.join( + tmpdir.path, + `test-policy-integrity-permutation-${testId}` + ); + const tmpPolicyPath = path.join( + tmpdir.path, + `deletable-policy-${testId}.json` + ); + + fs.rmSync(configDirPath, { maxRetries: 3, recursive: true, force: true }); + fs.mkdirSync(configDirPath, { recursive: true }); + const manifest = { + onerror: onError, + resources: {}, + }; + const manifestPath = path.join(configDirPath, policyPath); + for (const [resourcePath, { body, integrities }] of Object.entries( + resources + )) { + const filePath = path.join(configDirPath, resourcePath); + if (integrities !== null) { + manifest.resources[pathToFileURL(filePath).href] = { + integrity: integrities.join(' '), + dependencies: true, + }; + } + fs.writeFileSync(filePath, body, 'utf8'); + } + const manifestBody = JSON.stringify(manifest); + fs.writeFileSync(manifestPath, manifestBody); + + fs.writeFileSync(tmpPolicyPath, manifestBody); + + const spawnArgs = [ + process.execPath, + [ + '--unhandled-rejections=strict', + '--experimental-policy', + tmpPolicyPath, + ...preloads.flatMap((m) => ['-r', m]), + entryPath, + '--', + testId, + configDirPath, + ], + { + env: { + ...process.env, + DELETABLE_POLICY_FILE: tmpPolicyPath, + PARENT_FILE: parentPath, + DEP_FILE: depPath, + }, + cwd: configDirPath, + stdio: 'pipe', + }, + ]; + spawned++; + const stdout = []; + const stderr = []; + const child = spawn(...spawnArgs); + child.stdout.on('data', (d) => stdout.push(d)); + child.stderr.on('data', (d) => stderr.push(d)); + child.on('exit', (status, signal) => { + spawned--; + try { + if (shouldSucceed) { + assert.strictEqual(status, 0); + } else { + assert.notStrictEqual(status, 0); + } + } catch (e) { + console.log( + 'permutation', + testId, + 'failed' + ); + console.dir( + { config, manifest }, + { depth: null } + ); + console.log('exit code:', status, 'signal:', signal); + console.log(`stdout: ${Buffer.concat(stdout)}`); + console.log(`stderr: ${Buffer.concat(stderr)}`); + throw e; + } + fs.rmSync(configDirPath, { maxRetries: 3, recursive: true, force: true }); + drainQueue(); + }); + } +} + +{ + const { status } = spawnSync( + process.execPath, + ['--experimental-policy', policyPath, '--experimental-policy', policyPath], + { + stdio: 'pipe', + } + ); + assert.notStrictEqual(status, 0, 'Should not allow multiple policies'); +} +{ + const enoentFilepath = path.join(tmpdir.path, 'enoent'); + try { + fs.unlinkSync(enoentFilepath); + } catch { } + const { status } = spawnSync( + process.execPath, + ['--experimental-policy', enoentFilepath, '-e', ''], + { + stdio: 'pipe', + } + ); + assert.notStrictEqual(status, 0, 'Should not allow missing policies'); +} + +/** + * @template {Record>} T + * @param {T} configurations + * @param {object} path + * @returns {Array<{[key: keyof T]: T[keyof configurations]}>} + */ +function permutations(configurations, path = {}) { + const keys = Object.keys(configurations); + if (keys.length === 0) { + return path; + } + const config = keys[0]; + const { [config]: values, ...otherConfigs } = configurations; + return values.flatMap((value) => { + return permutations(otherConfigs, { ...path, [config]: value }); + }); +} +const tests = new Set(); +function fileExtensionFormat(extension) { + if (extension === '.js') { + return 'commonjs'; + } else if (extension === '.mjs') { + return 'module'; + } else if (extension === '.cjs') { + return 'commonjs'; + } + throw new Error('unknown format ' + extension); +} +for (const permutation of permutations({ + preloads: [[], ['parent'], ['dep']], + onError: ['log', 'exit'], + parentExtension: ['.js', '.mjs', '.cjs'], + parentIntegrity: ['match', 'invalid', 'missing'], + depExtension: ['.js', '.mjs', '.cjs'], + depIntegrity: ['match', 'invalid', 'missing'], + packageIntegrity: ['match', 'invalid', 'missing'], +})) { + let shouldSucceed = true; + const parentPath = `./parent${permutation.parentExtension}`; + const parentFormat = fileExtensionFormat(permutation.parentExtension); + const depFormat = fileExtensionFormat(permutation.depExtension); + + // non-sensical attempt to require ESM + if (depFormat === 'module' && parentFormat === 'commonjs') { + continue; + } + const depPath = `./dep${permutation.depExtension}`; + const workerSpawnerPath = './worker-spawner.cjs'; + const packageJSON = { + main: workerSpawnerPath, + type: 'commonjs', + }; + + const resources = { + [depPath]: { + body: '', + integrities: hash('sha256', ''), + }, + }; + if (permutation.depIntegrity === 'invalid') { + resources[depPath].body += '\n// INVALID INTEGRITY'; + shouldSucceed = false; + } else if (permutation.depIntegrity === 'missing') { + resources[depPath].integrities = null; + shouldSucceed = false; + } else if (permutation.depIntegrity === 'match') { + } else { + throw new Error('unreachable'); + } + if (parentFormat !== 'commonjs') { + permutation.preloads = permutation.preloads.filter((_) => _ !== 'parent'); + } + + resources[parentPath] = { + body: parentBody[parentFormat], + integrities: hash('sha256', parentBody[parentFormat]), + }; + if (permutation.parentIntegrity === 'invalid') { + resources[parentPath].body += '\n// INVALID INTEGRITY'; + shouldSucceed = false; + } else if (permutation.parentIntegrity === 'missing') { + resources[parentPath].integrities = null; + shouldSucceed = false; + } else if (permutation.parentIntegrity === 'match') { + } else { + throw new Error('unreachable'); + } + + resources[workerSpawnerPath] = { + body: workerSpawningBody, + integrities: hash('sha256', workerSpawningBody), + }; + + + let packageBody = JSON.stringify(packageJSON, null, 2); + let packageIntegrities = hash('sha256', packageBody); + if ( + permutation.parentExtension !== '.js' || + permutation.depExtension !== '.js' + ) { + // NO PACKAGE LOOKUP + continue; + } + if (permutation.packageIntegrity === 'invalid') { + packageJSON['//'] = 'INVALID INTEGRITY'; + packageBody = JSON.stringify(packageJSON, null, 2); + shouldSucceed = false; + } else if (permutation.packageIntegrity === 'missing') { + packageIntegrities = []; + shouldSucceed = false; + } else if (permutation.packageIntegrity === 'match') { + } else { + throw new Error('unreachable'); + } + resources['./package.json'] = { + body: packageBody, + integrities: packageIntegrities, + }; + + + if (permutation.onError === 'log') { + shouldSucceed = true; + } + tests.add( + JSON.stringify({ + onError: permutation.onError, + shouldSucceed, + entryPath: workerSpawnerPath, + preloads: permutation.preloads + .map((_) => { + return { + '': '', + 'parent': parentFormat === 'commonjs' ? parentPath : '', + 'dep': depFormat === 'commonjs' ? depPath : '', + }[_]; + }) + .filter(Boolean), + parentPath, + depPath, + resources, + }) + ); +} +debug(`spawning ${tests.size} policy integrity permutations`); + +for (const config of tests) { + const parsed = JSON.parse(config); + queueSpawn(parsed); +} diff --git a/test/pummel/test-policy-integrity-worker-module.js b/test/pummel/test-policy-integrity-worker-module.js new file mode 100644 index 00000000000000..69a7fb448283c3 --- /dev/null +++ b/test/pummel/test-policy-integrity-worker-module.js @@ -0,0 +1,374 @@ +'use strict'; + +const common = require('../common'); + +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +if (process.config.variables.arm_version === '7') { + common.skip('Too slow for armv7 bots'); +} + +common.requireNoPackageJSONAbove(); + +const { debuglog } = require('util'); +const debug = debuglog('test'); +const tmpdir = require('../common/tmpdir'); +const assert = require('assert'); +const { spawnSync, spawn } = require('child_process'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { pathToFileURL } = require('url'); + +const cpus = require('os').cpus().length; + +function hash(algo, body) { + const values = []; + { + const h = crypto.createHash(algo); + h.update(body); + values.push(`${algo}-${h.digest('base64')}`); + } + { + const h = crypto.createHash(algo); + h.update(body.replace('\n', '\r\n')); + values.push(`${algo}-${h.digest('base64')}`); + } + return values; +} + +const policyPath = './policy.json'; +const parentBody = { + commonjs: ` + if (!process.env.DEP_FILE) { + console.error( + 'missing required DEP_FILE env to determine dependency' + ); + process.exit(33); + } + require(process.env.DEP_FILE) + `, + module: ` + if (!process.env.DEP_FILE) { + console.error( + 'missing required DEP_FILE env to determine dependency' + ); + process.exit(33); + } + import(process.env.DEP_FILE) + `, +}; +const workerSpawningBody = ` + const path = require('path'); + const { Worker } = require('worker_threads'); + if (!process.env.PARENT_FILE) { + console.error( + 'missing required PARENT_FILE env to determine worker entry point' + ); + process.exit(33); + } + if (!process.env.DELETABLE_POLICY_FILE) { + console.error( + 'missing required DELETABLE_POLICY_FILE env to check reloading' + ); + process.exit(33); + } + const w = new Worker(path.resolve(process.env.PARENT_FILE)); + w.on('exit', (status) => process.exit(status === 0 ? 0 : 1)); +`; + +let nextTestId = 1; +function newTestId() { + return nextTestId++; +} +tmpdir.refresh(); +common.requireNoPackageJSONAbove(tmpdir.path); + +let spawned = 0; +const toSpawn = []; +function queueSpawn(opts) { + toSpawn.push(opts); + drainQueue(); +} + +function drainQueue() { + if (spawned > cpus) { + return; + } + if (toSpawn.length) { + const config = toSpawn.shift(); + const { + shouldSucceed, + preloads, + entryPath, + onError, + resources, + parentPath, + depPath, + } = config; + const testId = newTestId(); + const configDirPath = path.join( + tmpdir.path, + `test-policy-integrity-permutation-${testId}` + ); + const tmpPolicyPath = path.join( + tmpdir.path, + `deletable-policy-${testId}.json` + ); + + fs.rmSync(configDirPath, { maxRetries: 3, recursive: true, force: true }); + fs.mkdirSync(configDirPath, { recursive: true }); + const manifest = { + onerror: onError, + resources: {}, + }; + const manifestPath = path.join(configDirPath, policyPath); + for (const [resourcePath, { body, integrities }] of Object.entries( + resources + )) { + const filePath = path.join(configDirPath, resourcePath); + if (integrities !== null) { + manifest.resources[pathToFileURL(filePath).href] = { + integrity: integrities.join(' '), + dependencies: true, + }; + } + fs.writeFileSync(filePath, body, 'utf8'); + } + const manifestBody = JSON.stringify(manifest); + fs.writeFileSync(manifestPath, manifestBody); + + fs.writeFileSync(tmpPolicyPath, manifestBody); + + const spawnArgs = [ + process.execPath, + [ + '--unhandled-rejections=strict', + '--experimental-policy', + tmpPolicyPath, + ...preloads.flatMap((m) => ['-r', m]), + entryPath, + '--', + testId, + configDirPath, + ], + { + env: { + ...process.env, + DELETABLE_POLICY_FILE: tmpPolicyPath, + PARENT_FILE: parentPath, + DEP_FILE: depPath, + }, + cwd: configDirPath, + stdio: 'pipe', + }, + ]; + spawned++; + const stdout = []; + const stderr = []; + const child = spawn(...spawnArgs); + child.stdout.on('data', (d) => stdout.push(d)); + child.stderr.on('data', (d) => stderr.push(d)); + child.on('exit', (status, signal) => { + spawned--; + try { + if (shouldSucceed) { + assert.strictEqual(status, 0); + } else { + assert.notStrictEqual(status, 0); + } + } catch (e) { + console.log( + 'permutation', + testId, + 'failed' + ); + console.dir( + { config, manifest }, + { depth: null } + ); + console.log('exit code:', status, 'signal:', signal); + console.log(`stdout: ${Buffer.concat(stdout)}`); + console.log(`stderr: ${Buffer.concat(stderr)}`); + throw e; + } + fs.rmSync(configDirPath, { maxRetries: 3, recursive: true, force: true }); + drainQueue(); + }); + } +} + +{ + const { status } = spawnSync( + process.execPath, + ['--experimental-policy', policyPath, '--experimental-policy', policyPath], + { + stdio: 'pipe', + } + ); + assert.notStrictEqual(status, 0, 'Should not allow multiple policies'); +} +{ + const enoentFilepath = path.join(tmpdir.path, 'enoent'); + try { + fs.unlinkSync(enoentFilepath); + } catch { } + const { status } = spawnSync( + process.execPath, + ['--experimental-policy', enoentFilepath, '-e', ''], + { + stdio: 'pipe', + } + ); + assert.notStrictEqual(status, 0, 'Should not allow missing policies'); +} + +/** + * @template {Record>} T + * @param {T} configurations + * @param {object} path + * @returns {Array<{[key: keyof T]: T[keyof configurations]}>} + */ +function permutations(configurations, path = {}) { + const keys = Object.keys(configurations); + if (keys.length === 0) { + return path; + } + const config = keys[0]; + const { [config]: values, ...otherConfigs } = configurations; + return values.flatMap((value) => { + return permutations(otherConfigs, { ...path, [config]: value }); + }); +} +const tests = new Set(); +function fileExtensionFormat(extension) { + if (extension === '.js') { + return 'module'; + } else if (extension === '.mjs') { + return 'module'; + } else if (extension === '.cjs') { + return 'commonjs'; + } + throw new Error('unknown format ' + extension); +} +for (const permutation of permutations({ + preloads: [[], ['parent'], ['dep']], + onError: ['log', 'exit'], + parentExtension: ['.js', '.mjs', '.cjs'], + parentIntegrity: ['match', 'invalid', 'missing'], + depExtension: ['.js', '.mjs', '.cjs'], + depIntegrity: ['match', 'invalid', 'missing'], + packageIntegrity: ['match', 'invalid', 'missing'], +})) { + let shouldSucceed = true; + const parentPath = `./parent${permutation.parentExtension}`; + const parentFormat = fileExtensionFormat(permutation.parentExtension); + const depFormat = fileExtensionFormat(permutation.depExtension); + + // non-sensical attempt to require ESM + if (depFormat === 'module' && parentFormat === 'commonjs') { + continue; + } + const depPath = `./dep${permutation.depExtension}`; + const workerSpawnerPath = './worker-spawner.cjs'; + const packageJSON = { + main: workerSpawnerPath, + type: 'module', + }; + + const resources = { + [depPath]: { + body: '', + integrities: hash('sha256', ''), + }, + }; + if (permutation.depIntegrity === 'invalid') { + resources[depPath].body += '\n// INVALID INTEGRITY'; + shouldSucceed = false; + } else if (permutation.depIntegrity === 'missing') { + resources[depPath].integrities = null; + shouldSucceed = false; + } else if (permutation.depIntegrity === 'match') { + } else { + throw new Error('unreachable'); + } + if (parentFormat !== 'commonjs') { + permutation.preloads = permutation.preloads.filter((_) => _ !== 'parent'); + } + + resources[parentPath] = { + body: parentBody[parentFormat], + integrities: hash('sha256', parentBody[parentFormat]), + }; + if (permutation.parentIntegrity === 'invalid') { + resources[parentPath].body += '\n// INVALID INTEGRITY'; + shouldSucceed = false; + } else if (permutation.parentIntegrity === 'missing') { + resources[parentPath].integrities = null; + shouldSucceed = false; + } else if (permutation.parentIntegrity === 'match') { + } else { + throw new Error('unreachable'); + } + + resources[workerSpawnerPath] = { + body: workerSpawningBody, + integrities: hash('sha256', workerSpawningBody), + }; + + let packageBody = JSON.stringify(packageJSON, null, 2); + let packageIntegrities = hash('sha256', packageBody); + if ( + permutation.parentExtension !== '.js' || + permutation.depExtension !== '.js' + ) { + // NO PACKAGE LOOKUP + continue; + } + if (permutation.packageIntegrity === 'invalid') { + packageJSON['//'] = 'INVALID INTEGRITY'; + packageBody = JSON.stringify(packageJSON, null, 2); + shouldSucceed = false; + } else if (permutation.packageIntegrity === 'missing') { + packageIntegrities = []; + shouldSucceed = false; + } else if (permutation.packageIntegrity === 'match') { + } else { + throw new Error('unreachable'); + } + resources['./package.json'] = { + body: packageBody, + integrities: packageIntegrities, + }; + + if (permutation.onError === 'log') { + shouldSucceed = true; + } + tests.add( + JSON.stringify({ + onError: permutation.onError, + shouldSucceed, + entryPath: workerSpawnerPath, + preloads: permutation.preloads + .map((_) => { + return { + '': '', + 'parent': parentFormat === 'commonjs' ? parentPath : '', + 'dep': depFormat === 'commonjs' ? depPath : '', + }[_]; + }) + .filter(Boolean), + parentPath, + depPath, + resources, + }) + ); +} +debug(`spawning ${tests.size} policy integrity permutations`); + +for (const config of tests) { + const parsed = JSON.parse(config); + queueSpawn(parsed); +} diff --git a/test/pummel/test-policy-integrity-worker-no-package-json.js b/test/pummel/test-policy-integrity-worker-no-package-json.js new file mode 100644 index 00000000000000..909e58be5650d6 --- /dev/null +++ b/test/pummel/test-policy-integrity-worker-no-package-json.js @@ -0,0 +1,345 @@ +'use strict'; + +const common = require('../common'); + +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +if (process.config.variables.arm_version === '7') { + common.skip('Too slow for armv7 bots'); +} + +common.requireNoPackageJSONAbove(); + +const { debuglog } = require('util'); +const debug = debuglog('test'); +const tmpdir = require('../common/tmpdir'); +const assert = require('assert'); +const { spawnSync, spawn } = require('child_process'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { pathToFileURL } = require('url'); + +const cpus = require('os').cpus().length; + +function hash(algo, body) { + const values = []; + { + const h = crypto.createHash(algo); + h.update(body); + values.push(`${algo}-${h.digest('base64')}`); + } + { + const h = crypto.createHash(algo); + h.update(body.replace('\n', '\r\n')); + values.push(`${algo}-${h.digest('base64')}`); + } + return values; +} + +const policyPath = './policy.json'; +const parentBody = { + commonjs: ` + if (!process.env.DEP_FILE) { + console.error( + 'missing required DEP_FILE env to determine dependency' + ); + process.exit(33); + } + require(process.env.DEP_FILE) + `, + module: ` + if (!process.env.DEP_FILE) { + console.error( + 'missing required DEP_FILE env to determine dependency' + ); + process.exit(33); + } + import(process.env.DEP_FILE) + `, +}; +const workerSpawningBody = ` + const path = require('path'); + const { Worker } = require('worker_threads'); + if (!process.env.PARENT_FILE) { + console.error( + 'missing required PARENT_FILE env to determine worker entry point' + ); + process.exit(33); + } + if (!process.env.DELETABLE_POLICY_FILE) { + console.error( + 'missing required DELETABLE_POLICY_FILE env to check reloading' + ); + process.exit(33); + } + const w = new Worker(path.resolve(process.env.PARENT_FILE)); + w.on('exit', (status) => process.exit(status === 0 ? 0 : 1)); +`; + +let nextTestId = 1; +function newTestId() { + return nextTestId++; +} +tmpdir.refresh(); +common.requireNoPackageJSONAbove(tmpdir.path); + +let spawned = 0; +const toSpawn = []; +function queueSpawn(opts) { + toSpawn.push(opts); + drainQueue(); +} + +function drainQueue() { + if (spawned > cpus) { + return; + } + if (toSpawn.length) { + const config = toSpawn.shift(); + const { + shouldSucceed, + preloads, + entryPath, + onError, + resources, + parentPath, + depPath, + } = config; + const testId = newTestId(); + const configDirPath = path.join( + tmpdir.path, + `test-policy-integrity-permutation-${testId}` + ); + const tmpPolicyPath = path.join( + tmpdir.path, + `deletable-policy-${testId}.json` + ); + + fs.rmSync(configDirPath, { maxRetries: 3, recursive: true, force: true }); + fs.mkdirSync(configDirPath, { recursive: true }); + const manifest = { + onerror: onError, + resources: {}, + }; + const manifestPath = path.join(configDirPath, policyPath); + for (const [resourcePath, { body, integrities }] of Object.entries( + resources + )) { + const filePath = path.join(configDirPath, resourcePath); + if (integrities !== null) { + manifest.resources[pathToFileURL(filePath).href] = { + integrity: integrities.join(' '), + dependencies: true, + }; + } + fs.writeFileSync(filePath, body, 'utf8'); + } + const manifestBody = JSON.stringify(manifest); + fs.writeFileSync(manifestPath, manifestBody); + + fs.writeFileSync(tmpPolicyPath, manifestBody); + + const spawnArgs = [ + process.execPath, + [ + '--unhandled-rejections=strict', + '--experimental-policy', + tmpPolicyPath, + ...preloads.flatMap((m) => ['-r', m]), + entryPath, + '--', + testId, + configDirPath, + ], + { + env: { + ...process.env, + DELETABLE_POLICY_FILE: tmpPolicyPath, + PARENT_FILE: parentPath, + DEP_FILE: depPath, + }, + cwd: configDirPath, + stdio: 'pipe', + }, + ]; + spawned++; + const stdout = []; + const stderr = []; + const child = spawn(...spawnArgs); + child.stdout.on('data', (d) => stdout.push(d)); + child.stderr.on('data', (d) => stderr.push(d)); + child.on('exit', (status, signal) => { + spawned--; + try { + if (shouldSucceed) { + assert.strictEqual(status, 0); + } else { + assert.notStrictEqual(status, 0); + } + } catch (e) { + console.log( + 'permutation', + testId, + 'failed' + ); + console.dir( + { config, manifest }, + { depth: null } + ); + console.log('exit code:', status, 'signal:', signal); + console.log(`stdout: ${Buffer.concat(stdout)}`); + console.log(`stderr: ${Buffer.concat(stderr)}`); + throw e; + } + fs.rmSync(configDirPath, { maxRetries: 3, recursive: true, force: true }); + drainQueue(); + }); + } +} + +{ + const { status } = spawnSync( + process.execPath, + ['--experimental-policy', policyPath, '--experimental-policy', policyPath], + { + stdio: 'pipe', + } + ); + assert.notStrictEqual(status, 0, 'Should not allow multiple policies'); +} +{ + const enoentFilepath = path.join(tmpdir.path, 'enoent'); + try { + fs.unlinkSync(enoentFilepath); + } catch { } + const { status } = spawnSync( + process.execPath, + ['--experimental-policy', enoentFilepath, '-e', ''], + { + stdio: 'pipe', + } + ); + assert.notStrictEqual(status, 0, 'Should not allow missing policies'); +} + +/** + * @template {Record>} T + * @param {T} configurations + * @param {object} path + * @returns {Array<{[key: keyof T]: T[keyof configurations]}>} + */ +function permutations(configurations, path = {}) { + const keys = Object.keys(configurations); + if (keys.length === 0) { + return path; + } + const config = keys[0]; + const { [config]: values, ...otherConfigs } = configurations; + return values.flatMap((value) => { + return permutations(otherConfigs, { ...path, [config]: value }); + }); +} +const tests = new Set(); +function fileExtensionFormat(extension) { + if (extension === '.js') { + return 'commonjs'; + } else if (extension === '.mjs') { + return 'module'; + } else if (extension === '.cjs') { + return 'commonjs'; + } + throw new Error('unknown format ' + extension); +} +for (const permutation of permutations({ + preloads: [[], ['parent'], ['dep']], + onError: ['log', 'exit'], + parentExtension: ['.js', '.mjs', '.cjs'], + parentIntegrity: ['match', 'invalid', 'missing'], + depExtension: ['.js', '.mjs', '.cjs'], + depIntegrity: ['match', 'invalid', 'missing'], + packageIntegrity: ['match', 'invalid', 'missing'], +})) { + let shouldSucceed = true; + const parentPath = `./parent${permutation.parentExtension}`; + const parentFormat = fileExtensionFormat(permutation.parentExtension); + const depFormat = fileExtensionFormat(permutation.depExtension); + + // non-sensical attempt to require ESM + if (depFormat === 'module' && parentFormat === 'commonjs') { + continue; + } + const depPath = `./dep${permutation.depExtension}`; + const workerSpawnerPath = './worker-spawner.cjs'; + + const resources = { + [depPath]: { + body: '', + integrities: hash('sha256', ''), + }, + }; + if (permutation.depIntegrity === 'invalid') { + resources[depPath].body += '\n// INVALID INTEGRITY'; + shouldSucceed = false; + } else if (permutation.depIntegrity === 'missing') { + resources[depPath].integrities = null; + shouldSucceed = false; + } else if (permutation.depIntegrity === 'match') { + } else { + throw new Error('unreachable'); + } + if (parentFormat !== 'commonjs') { + permutation.preloads = permutation.preloads.filter((_) => _ !== 'parent'); + } + + resources[parentPath] = { + body: parentBody[parentFormat], + integrities: hash('sha256', parentBody[parentFormat]), + }; + if (permutation.parentIntegrity === 'invalid') { + resources[parentPath].body += '\n// INVALID INTEGRITY'; + shouldSucceed = false; + } else if (permutation.parentIntegrity === 'missing') { + resources[parentPath].integrities = null; + shouldSucceed = false; + } else if (permutation.parentIntegrity === 'match') { + } else { + throw new Error('unreachable'); + } + + resources[workerSpawnerPath] = { + body: workerSpawningBody, + integrities: hash('sha256', workerSpawningBody), + }; + + if (permutation.onError === 'log') { + shouldSucceed = true; + } + tests.add( + JSON.stringify({ + onError: permutation.onError, + shouldSucceed, + entryPath: workerSpawnerPath, + preloads: permutation.preloads + .map((_) => { + return { + '': '', + 'parent': parentFormat === 'commonjs' ? parentPath : '', + 'dep': depFormat === 'commonjs' ? depPath : '', + }[_]; + }) + .filter(Boolean), + parentPath, + depPath, + resources, + }) + ); +} +debug(`spawning ${tests.size} policy integrity permutations`); + +for (const config of tests) { + const parsed = JSON.parse(config); + queueSpawn(parsed); +}