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