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