• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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      willDeletePolicy,
89      onError,
90      resources,
91      parentPath,
92      depPath,
93    } = config;
94    const testId = newTestId();
95    const configDirPath = path.join(
96      tmpdir.path,
97      `test-policy-integrity-permutation-${testId}`,
98    );
99    const tmpPolicyPath = path.join(
100      tmpdir.path,
101      `deletable-policy-${testId}.json`,
102    );
103    const cliPolicy = willDeletePolicy ? tmpPolicyPath : policyPath;
104    fs.rmSync(configDirPath, { maxRetries: 3, recursive: true, force: true });
105    fs.mkdirSync(configDirPath, { recursive: true });
106    const manifest = {
107      onerror: onError,
108      resources: {},
109    };
110    const manifestPath = path.join(configDirPath, policyPath);
111    for (const [resourcePath, { body, integrities }] of Object.entries(
112      resources,
113    )) {
114      const filePath = path.join(configDirPath, resourcePath);
115      if (integrities !== null) {
116        manifest.resources[pathToFileURL(filePath).href] = {
117          integrity: integrities.join(' '),
118          dependencies: true,
119        };
120      }
121      fs.writeFileSync(filePath, body, 'utf8');
122    }
123    const manifestBody = JSON.stringify(manifest);
124    fs.writeFileSync(manifestPath, manifestBody);
125    if (cliPolicy === tmpPolicyPath) {
126      fs.writeFileSync(tmpPolicyPath, manifestBody);
127    }
128    const spawnArgs = [
129      process.execPath,
130      [
131        '--unhandled-rejections=strict',
132        '--experimental-policy',
133        cliPolicy,
134        ...preloads.flatMap((m) => ['-r', m]),
135        entryPath,
136        '--',
137        testId,
138        configDirPath,
139      ],
140      {
141        env: {
142          ...process.env,
143          DELETABLE_POLICY_FILE: tmpPolicyPath,
144          PARENT_FILE: parentPath,
145          DEP_FILE: depPath,
146        },
147        cwd: configDirPath,
148        stdio: 'pipe',
149      },
150    ];
151    spawned++;
152    const stdout = [];
153    const stderr = [];
154    const child = spawn(...spawnArgs);
155    child.stdout.on('data', (d) => stdout.push(d));
156    child.stderr.on('data', (d) => stderr.push(d));
157    child.on('exit', (status, signal) => {
158      spawned--;
159      try {
160        if (shouldSucceed) {
161          assert.strictEqual(status, 0);
162        } else {
163          assert.notStrictEqual(status, 0);
164        }
165      } catch (e) {
166        console.log(
167          'permutation',
168          testId,
169          'failed',
170        );
171        console.dir(
172          { config, manifest },
173          { depth: null },
174        );
175        console.log('exit code:', status, 'signal:', signal);
176        console.log(`stdout: ${Buffer.concat(stdout)}`);
177        console.log(`stderr: ${Buffer.concat(stderr)}`);
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) {
232  if (extension === '.js') {
233    return '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  packageIntegrity: ['match', 'invalid', 'missing'],
249})) {
250  let shouldSucceed = true;
251  const parentPath = `./parent${permutation.parentExtension}`;
252  const parentFormat = fileExtensionFormat(permutation.parentExtension);
253  const depFormat = fileExtensionFormat(permutation.depExtension);
254
255  // non-sensical attempt to require ESM
256  if (depFormat === 'module' && parentFormat === 'commonjs') {
257    continue;
258  }
259  const depPath = `./dep${permutation.depExtension}`;
260  const entryPath = parentPath;
261
262  const resources = {
263    [depPath]: {
264      body: '',
265      integrities: hash('sha256', ''),
266    },
267  };
268  if (permutation.depIntegrity === 'invalid') {
269    resources[depPath].body += '\n// INVALID INTEGRITY';
270    shouldSucceed = false;
271  } else if (permutation.depIntegrity === 'missing') {
272    resources[depPath].integrities = null;
273    shouldSucceed = false;
274  } else if (permutation.depIntegrity !== 'match') {
275    throw new Error('unreachable');
276  }
277  if (parentFormat !== 'commonjs') {
278    permutation.preloads = permutation.preloads.filter((_) => _ !== 'parent');
279  }
280
281  resources[parentPath] = {
282    body: parentBody[parentFormat],
283    integrities: hash('sha256', parentBody[parentFormat]),
284  };
285  if (permutation.parentIntegrity === 'invalid') {
286    resources[parentPath].body += '\n// INVALID INTEGRITY';
287    shouldSucceed = false;
288  } else if (permutation.parentIntegrity === 'missing') {
289    resources[parentPath].integrities = null;
290    shouldSucceed = false;
291  } else if (permutation.parentIntegrity !== 'match') {
292    throw new Error('unreachable');
293  }
294
295  if (permutation.onError === 'log') {
296    shouldSucceed = true;
297  }
298  tests.add(
299    JSON.stringify({
300      onError: permutation.onError,
301      shouldSucceed,
302      entryPath,
303      willDeletePolicy: false,
304      preloads: permutation.preloads
305        .map((_) => {
306          return {
307            '': '',
308            'parent': parentFormat === 'commonjs' ? parentPath : '',
309            'dep': depFormat === 'commonjs' ? depPath : '',
310          }[_];
311        })
312        .filter(Boolean),
313      parentPath,
314      depPath,
315      resources,
316    }),
317  );
318}
319debug(`spawning ${tests.size} policy integrity permutations`);
320
321for (const config of tests) {
322  const parsed = JSON.parse(config);
323  queueSpawn(parsed);
324}
325