1'use strict'; 2 3const common = require('../common'); 4if (!common.hasCrypto) common.skip('missing crypto'); 5 6const { debuglog } = require('util'); 7const debug = debuglog('test'); 8const tmpdir = require('../common/tmpdir'); 9const assert = require('assert'); 10const { spawnSync, spawn } = require('child_process'); 11const crypto = require('crypto'); 12const fs = require('fs'); 13const path = require('path'); 14const { pathToFileURL } = require('url'); 15 16const cpus = require('os').cpus().length; 17 18function hash(algo, body) { 19 const values = []; 20 { 21 const h = crypto.createHash(algo); 22 h.update(body); 23 values.push(`${algo}-${h.digest('base64')}`); 24 } 25 { 26 const h = crypto.createHash(algo); 27 h.update(body.replace('\n', '\r\n')); 28 values.push(`${algo}-${h.digest('base64')}`); 29 } 30 return values; 31} 32 33const policyPath = './policy.json'; 34const parentBody = { 35 commonjs: ` 36 if (!process.env.DEP_FILE) { 37 console.error( 38 'missing required DEP_FILE env to determine dependency' 39 ); 40 process.exit(33); 41 } 42 require(process.env.DEP_FILE) 43 `, 44 module: ` 45 if (!process.env.DEP_FILE) { 46 console.error( 47 'missing required DEP_FILE env to determine dependency' 48 ); 49 process.exit(33); 50 } 51 import(process.env.DEP_FILE) 52 `, 53}; 54const workerSpawningBody = ` 55 const path = require('path'); 56 const { Worker } = require('worker_threads'); 57 if (!process.env.PARENT_FILE) { 58 console.error( 59 'missing required PARENT_FILE env to determine worker entry point' 60 ); 61 process.exit(33); 62 } 63 if (!process.env.DELETABLE_POLICY_FILE) { 64 console.error( 65 'missing required DELETABLE_POLICY_FILE env to check reloading' 66 ); 67 process.exit(33); 68 } 69 const w = new Worker(path.resolve(process.env.PARENT_FILE)); 70 w.on('exit', (status) => process.exit(status === 0 ? 0 : 1)); 71`; 72 73let nextTestId = 1; 74function newTestId() { 75 return nextTestId++; 76} 77tmpdir.refresh(); 78 79let spawned = 0; 80const toSpawn = []; 81function queueSpawn(opts) { 82 toSpawn.push(opts); 83 drainQueue(); 84} 85 86function drainQueue() { 87 if (spawned > cpus) { 88 return; 89 } 90 if (toSpawn.length) { 91 const config = toSpawn.shift(); 92 const { 93 shouldSucceed, // = (() => { throw new Error('required')})(), 94 preloads, // = (() =>{ throw new Error('required')})(), 95 entryPath, // = (() => { throw new Error('required')})(), 96 willDeletePolicy, // = (() => { throw new Error('required')})(), 97 onError, // = (() => { throw new Error('required')})(), 98 resources, // = (() => { throw new Error('required')})(), 99 parentPath, 100 depPath, 101 } = config; 102 const testId = newTestId(); 103 const configDirPath = path.join( 104 tmpdir.path, 105 `test-policy-integrity-permutation-${testId}` 106 ); 107 const tmpPolicyPath = path.join( 108 tmpdir.path, 109 `deletable-policy-${testId}.json` 110 ); 111 const cliPolicy = willDeletePolicy ? tmpPolicyPath : policyPath; 112 fs.rmdirSync(configDirPath, { maxRetries: 3, recursive: true }); 113 fs.mkdirSync(configDirPath, { recursive: true }); 114 const manifest = { 115 onerror: onError, 116 resources: {}, 117 }; 118 const manifestPath = path.join(configDirPath, policyPath); 119 for (const [resourcePath, { body, integrities }] of Object.entries( 120 resources 121 )) { 122 const filePath = path.join(configDirPath, resourcePath); 123 if (integrities !== null) { 124 manifest.resources[pathToFileURL(filePath).href] = { 125 integrity: integrities.join(' '), 126 dependencies: true, 127 }; 128 } 129 fs.writeFileSync(filePath, body, 'utf8'); 130 } 131 const manifestBody = JSON.stringify(manifest); 132 fs.writeFileSync(manifestPath, manifestBody); 133 if (cliPolicy === tmpPolicyPath) { 134 fs.writeFileSync(tmpPolicyPath, manifestBody); 135 } 136 const spawnArgs = [ 137 process.execPath, 138 [ 139 '--unhandled-rejections=strict', 140 '--experimental-policy', 141 cliPolicy, 142 ...preloads.flatMap((m) => ['-r', m]), 143 entryPath, 144 '--', 145 testId, 146 configDirPath, 147 ], 148 { 149 env: { 150 ...process.env, 151 DELETABLE_POLICY_FILE: tmpPolicyPath, 152 PARENT_FILE: parentPath, 153 DEP_FILE: depPath, 154 }, 155 cwd: configDirPath, 156 stdio: 'pipe', 157 }, 158 ]; 159 spawned++; 160 const stdout = []; 161 const stderr = []; 162 const child = spawn(...spawnArgs); 163 child.stdout.on('data', (d) => stdout.push(d)); 164 child.stderr.on('data', (d) => stderr.push(d)); 165 child.on('exit', (status, signal) => { 166 spawned--; 167 try { 168 if (shouldSucceed) { 169 assert.strictEqual(status, 0); 170 } else { 171 assert.notStrictEqual(status, 0); 172 } 173 } catch (e) { 174 console.log( 175 'permutation', 176 testId, 177 'failed' 178 ); 179 console.dir( 180 { config, manifest }, 181 { depth: null } 182 ); 183 console.log('exit code:', status, 'signal:', signal); 184 console.log(`stdout: ${Buffer.concat(stdout)}`); 185 console.log(`stderr: ${Buffer.concat(stderr)}`); 186 throw e; 187 } 188 fs.rmdirSync(configDirPath, { maxRetries: 3, recursive: true }); 189 drainQueue(); 190 }); 191 } 192} 193 194{ 195 const { status } = spawnSync( 196 process.execPath, 197 ['--experimental-policy', policyPath, '--experimental-policy', policyPath], 198 { 199 stdio: 'pipe', 200 } 201 ); 202 assert.notStrictEqual(status, 0, 'Should not allow multiple policies'); 203} 204{ 205 const enoentFilepath = path.join(tmpdir.path, 'enoent'); 206 try { 207 fs.unlinkSync(enoentFilepath); 208 } catch { } 209 const { status } = spawnSync( 210 process.execPath, 211 ['--experimental-policy', enoentFilepath, '-e', ''], 212 { 213 stdio: 'pipe', 214 } 215 ); 216 assert.notStrictEqual(status, 0, 'Should not allow missing policies'); 217} 218 219/** 220 * @template {Record<string, Array<string | string[] | boolean>>} T 221 * @param {T} configurations 222 * @param {object} path 223 * @returns {Array<{[key: keyof T]: T[keyof configurations]}>} 224 */ 225function permutations(configurations, path = {}) { 226 const keys = Object.keys(configurations); 227 if (keys.length === 0) { 228 return path; 229 } 230 const config = keys[0]; 231 const { [config]: values, ...otherConfigs } = configurations; 232 return values.flatMap((value) => { 233 return permutations(otherConfigs, { ...path, [config]: value }); 234 }); 235} 236const tests = new Set(); 237function fileExtensionFormat(extension, packageType) { 238 if (extension === '.js') { 239 return packageType === 'module' ? 'module' : 'commonjs'; 240 } else if (extension === '.mjs') { 241 return 'module'; 242 } else if (extension === '.cjs') { 243 return 'commonjs'; 244 } 245 throw new Error('unknown format ' + extension); 246} 247for (const permutation of permutations({ 248 entry: ['worker', 'parent', 'dep'], 249 preloads: [[], ['parent'], ['dep']], 250 onError: ['log', 'exit'], 251 parentExtension: ['.js', '.mjs', '.cjs'], 252 parentIntegrity: ['match', 'invalid', 'missing'], 253 depExtension: ['.js', '.mjs', '.cjs'], 254 depIntegrity: ['match', 'invalid', 'missing'], 255 packageType: ['no-package-json', 'module', 'commonjs'], 256 packageIntegrity: ['match', 'invalid', 'missing'], 257})) { 258 let shouldSucceed = true; 259 const parentPath = `./parent${permutation.parentExtension}`; 260 const effectivePackageType = 261 permutation.packageType === 'module' ? 'module' : 'commonjs'; 262 const parentFormat = fileExtensionFormat( 263 permutation.parentExtension, 264 effectivePackageType 265 ); 266 const depFormat = fileExtensionFormat( 267 permutation.depExtension, 268 effectivePackageType 269 ); 270 // non-sensical attempt to require ESM 271 if (depFormat === 'module' && parentFormat === 'commonjs') { 272 continue; 273 } 274 const depPath = `./dep${permutation.depExtension}`; 275 const workerSpawnerPath = './worker-spawner.cjs'; 276 const entryPath = { 277 dep: depPath, 278 parent: parentPath, 279 worker: workerSpawnerPath, 280 }[permutation.entry]; 281 const packageJSON = { 282 main: entryPath, 283 type: permutation.packageType, 284 }; 285 if (permutation.packageType === 'no-field') { 286 delete packageJSON.type; 287 } 288 const resources = { 289 [depPath]: { 290 body: '', 291 integrities: hash('sha256', ''), 292 }, 293 }; 294 if (permutation.depIntegrity === 'invalid') { 295 resources[depPath].body += '\n// INVALID INTEGRITY'; 296 shouldSucceed = false; 297 } else if (permutation.depIntegrity === 'missing') { 298 resources[depPath].integrities = null; 299 shouldSucceed = false; 300 } else if (permutation.depIntegrity === 'match') { 301 } else { 302 throw new Error('unreachable'); 303 } 304 if (parentFormat !== 'commonjs') { 305 permutation.preloads = permutation.preloads.filter((_) => _ !== 'parent'); 306 } 307 const hasParent = 308 permutation.entry !== 'dep' || permutation.preloads.includes('parent'); 309 if (hasParent) { 310 resources[parentPath] = { 311 body: parentBody[parentFormat], 312 integrities: hash('sha256', parentBody[parentFormat]), 313 }; 314 if (permutation.parentIntegrity === 'invalid') { 315 resources[parentPath].body += '\n// INVALID INTEGRITY'; 316 shouldSucceed = false; 317 } else if (permutation.parentIntegrity === 'missing') { 318 resources[parentPath].integrities = null; 319 shouldSucceed = false; 320 } else if (permutation.parentIntegrity === 'match') { 321 } else { 322 throw new Error('unreachable'); 323 } 324 } 325 if (permutation.entry === 'worker') { 326 resources[workerSpawnerPath] = { 327 body: workerSpawningBody, 328 integrities: hash('sha256', workerSpawningBody), 329 }; 330 } 331 if (permutation.packageType !== 'no-package-json') { 332 let packageBody = JSON.stringify(packageJSON, null, 2); 333 let packageIntegrities = hash('sha256', packageBody); 334 if ( 335 permutation.parentExtension !== '.js' || 336 permutation.depExtension !== '.js' 337 ) { 338 // NO PACKAGE LOOKUP 339 continue; 340 } 341 if (permutation.packageIntegrity === 'invalid') { 342 packageJSON['//'] = 'INVALID INTEGRITY'; 343 packageBody = JSON.stringify(packageJSON, null, 2); 344 shouldSucceed = false; 345 } else if (permutation.packageIntegrity === 'missing') { 346 packageIntegrities = []; 347 shouldSucceed = false; 348 } else if (permutation.packageIntegrity === 'match') { 349 } else { 350 throw new Error('unreachable'); 351 } 352 resources['./package.json'] = { 353 body: packageBody, 354 integrities: packageIntegrities, 355 }; 356 } 357 const willDeletePolicy = permutation.entry === 'worker'; 358 if (permutation.onError === 'log') { 359 shouldSucceed = true; 360 } 361 tests.add( 362 JSON.stringify({ 363 // hasParent, 364 // original: permutation, 365 onError: permutation.onError, 366 shouldSucceed, 367 entryPath, 368 willDeletePolicy, 369 preloads: permutation.preloads 370 .map((_) => { 371 return { 372 '': '', 373 'parent': parentFormat === 'commonjs' ? parentPath : '', 374 'dep': depFormat === 'commonjs' ? depPath : '', 375 }[_]; 376 }) 377 .filter(Boolean), 378 parentPath, 379 depPath, 380 resources, 381 }) 382 ); 383} 384debug(`spawning ${tests.size} policy integrity permutations`); 385 386for (const config of tests) { 387 const parsed = JSON.parse(config); 388 queueSpawn(parsed); 389} 390