ccc9fe
From 730dd78c897a28c3df0468ed1fc42d5817badefe Mon Sep 17 00:00:00 2001
ccc9fe
From: Ruy Adorno <ruyadorno@hotmail.com>
ccc9fe
Date: Wed, 2 Feb 2022 22:10:22 -0500
ccc9fe
Subject: [PATCH] fix(ci): lock file validation
ccc9fe
MIME-Version: 1.0
ccc9fe
Content-Type: text/plain; charset=UTF-8
ccc9fe
Content-Transfer-Encoding: 8bit
ccc9fe
ccc9fe
Make sure to validate any lock file (either package-lock.json or
ccc9fe
npm-shrinkwrap.json) against the current install. This will properly
ccc9fe
throw an error in case any of the dependencies being installed don't
ccc9fe
match the dependencies that are currently listed in the lock file.
ccc9fe
ccc9fe
Fixes: https://github.com/npm/cli/issues/2701
ccc9fe
Fixes: https://github.com/npm/cli/issues/3947
ccc9fe
Signed-off-by: Jan Staněk <jstanek@redhat.com>
ccc9fe
---
ccc9fe
 deps/npm/lib/commands/ci.js                   | 23 ++++++
ccc9fe
 deps/npm/lib/utils/validate-lockfile.js       | 29 +++++++
ccc9fe
 .../smoke-tests/index.js.test.cjs             | 11 +++
ccc9fe
 .../test/lib/commands/ci.js.test.cjs          | 13 +++
ccc9fe
 .../lib/utils/validate-lockfile.js.test.cjs   | 35 ++++++++
ccc9fe
 deps/npm/test/lib/commands/ci.js              | 82 +++++++++++++++++++
ccc9fe
 deps/npm/test/lib/utils/validate-lockfile.js  | 82 +++++++++++++++++++
ccc9fe
 7 files changed, 275 insertions(+)
ccc9fe
 create mode 100644 deps/npm/lib/utils/validate-lockfile.js
ccc9fe
 create mode 100644 deps/npm/tap-snapshots/test/lib/commands/ci.js.test.cjs
ccc9fe
 create mode 100644 deps/npm/tap-snapshots/test/lib/utils/validate-lockfile.js.test.cjs
ccc9fe
 create mode 100644 deps/npm/test/lib/utils/validate-lockfile.js
ccc9fe
ccc9fe
diff --git a/deps/npm/lib/commands/ci.js b/deps/npm/lib/commands/ci.js
ccc9fe
index 2c2f8da..376a85d 100644
ccc9fe
--- a/deps/npm/lib/commands/ci.js
ccc9fe
+++ b/deps/npm/lib/commands/ci.js
ccc9fe
@@ -6,6 +6,7 @@ const runScript = require('@npmcli/run-script')
ccc9fe
 const fs = require('fs')
ccc9fe
 const readdir = util.promisify(fs.readdir)
ccc9fe
 const log = require('../utils/log-shim.js')
ccc9fe
+const validateLockfile = require('../utils/validate-lockfile.js')
ccc9fe
 
ccc9fe
 const removeNodeModules = async where => {
ccc9fe
   const rimrafOpts = { glob: false }
ccc9fe
@@ -55,6 +56,28 @@ class CI extends ArboristWorkspaceCmd {
ccc9fe
       }),
ccc9fe
       removeNodeModules(where),
ccc9fe
     ])
ccc9fe
+
ccc9fe
+    // retrieves inventory of packages from loaded virtual tree (lock file)
ccc9fe
+    const virtualInventory = new Map(arb.virtualTree.inventory)
ccc9fe
+
ccc9fe
+    // build ideal tree step needs to come right after retrieving the virtual
ccc9fe
+    // inventory since it's going to erase the previous ref to virtualTree
ccc9fe
+    await arb.buildIdealTree()
ccc9fe
+
ccc9fe
+    // verifies that the packages from the ideal tree will match
ccc9fe
+    // the same versions that are present in the virtual tree (lock file)
ccc9fe
+    // throws a validation error in case of mismatches
ccc9fe
+    const errors = validateLockfile(virtualInventory, arb.idealTree.inventory)
ccc9fe
+    if (errors.length) {
ccc9fe
+      throw new Error(
ccc9fe
+        '`npm ci` can only install packages when your package.json and ' +
ccc9fe
+        'package-lock.json or npm-shrinkwrap.json are in sync. Please ' +
ccc9fe
+        'update your lock file with `npm install` ' +
ccc9fe
+        'before continuing.\n\n' +
ccc9fe
+        errors.join('\n') + '\n'
ccc9fe
+      )
ccc9fe
+    }
ccc9fe
+
ccc9fe
     await arb.reify(opts)
ccc9fe
 
ccc9fe
     const ignoreScripts = this.npm.config.get('ignore-scripts')
ccc9fe
diff --git a/deps/npm/lib/utils/validate-lockfile.js b/deps/npm/lib/utils/validate-lockfile.js
ccc9fe
new file mode 100644
ccc9fe
index 0000000..29161ec
ccc9fe
--- /dev/null
ccc9fe
+++ b/deps/npm/lib/utils/validate-lockfile.js
ccc9fe
@@ -0,0 +1,29 @@
ccc9fe
+// compares the inventory of package items in the tree
ccc9fe
+// that is about to be installed (idealTree) with the inventory
ccc9fe
+// of items stored in the package-lock file (virtualTree)
ccc9fe
+//
ccc9fe
+// Returns empty array if no errors found or an array populated
ccc9fe
+// with an entry for each validation error found.
ccc9fe
+function validateLockfile (virtualTree, idealTree) {
ccc9fe
+  const errors = []
ccc9fe
+
ccc9fe
+  // loops through the inventory of packages resulted by ideal tree,
ccc9fe
+  // for each package compares the versions with the version stored in the
ccc9fe
+  // package-lock and adds an error to the list in case of mismatches
ccc9fe
+  for (const [key, entry] of idealTree.entries()) {
ccc9fe
+    const lock = virtualTree.get(key)
ccc9fe
+
ccc9fe
+    if (!lock) {
ccc9fe
+      errors.push(`Missing: ${entry.name}@${entry.version} from lock file`)
ccc9fe
+      continue
ccc9fe
+    }
ccc9fe
+
ccc9fe
+    if (entry.version !== lock.version) {
ccc9fe
+      errors.push(`Invalid: lock file's ${lock.name}@${lock.version} does ` +
ccc9fe
+      `not satisfy ${entry.name}@${entry.version}`)
ccc9fe
+    }
ccc9fe
+  }
ccc9fe
+  return errors
ccc9fe
+}
ccc9fe
+
ccc9fe
+module.exports = validateLockfile
ccc9fe
diff --git a/deps/npm/tap-snapshots/smoke-tests/index.js.test.cjs b/deps/npm/tap-snapshots/smoke-tests/index.js.test.cjs
ccc9fe
index c1316e0..5fa3977 100644
ccc9fe
--- a/deps/npm/tap-snapshots/smoke-tests/index.js.test.cjs
ccc9fe
+++ b/deps/npm/tap-snapshots/smoke-tests/index.js.test.cjs
ccc9fe
@@ -40,6 +40,17 @@ Configuration fields: npm help 7 config
ccc9fe
 
ccc9fe
 npm {CWD}
ccc9fe
 
ccc9fe
+`
ccc9fe
+
ccc9fe
+exports[`smoke-tests/index.js TAP npm ci > should throw mismatch deps in lock file error 1`] = `
ccc9fe
+npm ERR! \`npm ci\` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with \`npm install\` before continuing.
ccc9fe
+npm ERR! 
ccc9fe
+npm ERR! Invalid: lock file's abbrev@1.0.4 does not satisfy abbrev@1.1.1
ccc9fe
+npm ERR! 
ccc9fe
+
ccc9fe
+npm ERR! A complete log of this run can be found in:
ccc9fe
+
ccc9fe
+
ccc9fe
 `
ccc9fe
 
ccc9fe
 exports[`smoke-tests/index.js TAP npm diff > should have expected diff output 1`] = `
ccc9fe
diff --git a/deps/npm/tap-snapshots/test/lib/commands/ci.js.test.cjs b/deps/npm/tap-snapshots/test/lib/commands/ci.js.test.cjs
ccc9fe
new file mode 100644
ccc9fe
index 0000000..d6a7471
ccc9fe
--- /dev/null
ccc9fe
+++ b/deps/npm/tap-snapshots/test/lib/commands/ci.js.test.cjs
ccc9fe
@@ -0,0 +1,13 @@
ccc9fe
+/* IMPORTANT
ccc9fe
+ * This snapshot file is auto-generated, but designed for humans.
ccc9fe
+ * It should be checked into source control and tracked carefully.
ccc9fe
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
ccc9fe
+ * Make sure to inspect the output below.  Do not ignore changes!
ccc9fe
+ */
ccc9fe
+'use strict'
ccc9fe
+exports[`test/lib/commands/ci.js TAP should throw error when ideal inventory mismatches virtual > must match snapshot 1`] = `
ccc9fe
+\`npm ci\` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with \`npm install\` before continuing.
ccc9fe
+
ccc9fe
+Invalid: lock file's foo@1.0.0 does not satisfy foo@2.0.0
ccc9fe
+
ccc9fe
+`
ccc9fe
diff --git a/deps/npm/tap-snapshots/test/lib/utils/validate-lockfile.js.test.cjs b/deps/npm/tap-snapshots/test/lib/utils/validate-lockfile.js.test.cjs
ccc9fe
new file mode 100644
ccc9fe
index 0000000..98a5126
ccc9fe
--- /dev/null
ccc9fe
+++ b/deps/npm/tap-snapshots/test/lib/utils/validate-lockfile.js.test.cjs
ccc9fe
@@ -0,0 +1,35 @@
ccc9fe
+/* IMPORTANT
ccc9fe
+ * This snapshot file is auto-generated, but designed for humans.
ccc9fe
+ * It should be checked into source control and tracked carefully.
ccc9fe
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
ccc9fe
+ * Make sure to inspect the output below.  Do not ignore changes!
ccc9fe
+ */
ccc9fe
+'use strict'
ccc9fe
+exports[`test/lib/utils/validate-lockfile.js TAP extra inventory items on idealTree > should have missing entries error 1`] = `
ccc9fe
+Array [
ccc9fe
+  "Missing: baz@3.0.0 from lock file",
ccc9fe
+]
ccc9fe
+`
ccc9fe
+
ccc9fe
+exports[`test/lib/utils/validate-lockfile.js TAP extra inventory items on virtualTree > should have no errors if finding virtualTree extra items 1`] = `
ccc9fe
+Array []
ccc9fe
+`
ccc9fe
+
ccc9fe
+exports[`test/lib/utils/validate-lockfile.js TAP identical inventory for both idealTree and virtualTree > should have no errors on identical inventories 1`] = `
ccc9fe
+Array []
ccc9fe
+`
ccc9fe
+
ccc9fe
+exports[`test/lib/utils/validate-lockfile.js TAP mismatching versions on inventory > should have errors for each mismatching version 1`] = `
ccc9fe
+Array [
ccc9fe
+  "Invalid: lock file's foo@1.0.0 does not satisfy foo@2.0.0",
ccc9fe
+  "Invalid: lock file's bar@2.0.0 does not satisfy bar@3.0.0",
ccc9fe
+]
ccc9fe
+`
ccc9fe
+
ccc9fe
+exports[`test/lib/utils/validate-lockfile.js TAP missing virtualTree inventory > should have errors for each mismatching version 1`] = `
ccc9fe
+Array [
ccc9fe
+  "Missing: foo@1.0.0 from lock file",
ccc9fe
+  "Missing: bar@2.0.0 from lock file",
ccc9fe
+  "Missing: baz@3.0.0 from lock file",
ccc9fe
+]
ccc9fe
+`
ccc9fe
diff --git a/deps/npm/test/lib/commands/ci.js b/deps/npm/test/lib/commands/ci.js
ccc9fe
index 537d078..e077c99 100644
ccc9fe
--- a/deps/npm/test/lib/commands/ci.js
ccc9fe
+++ b/deps/npm/test/lib/commands/ci.js
ccc9fe
@@ -19,6 +19,17 @@ t.test('should ignore scripts with --ignore-scripts', async t => {
ccc9fe
       this.reify = () => {
ccc9fe
         REIFY_CALLED = true
ccc9fe
       }
ccc9fe
+      this.buildIdealTree = () => {}
ccc9fe
+      this.virtualTree = {
ccc9fe
+        inventory: new Map([
ccc9fe
+          ['foo', { name: 'foo', version: '1.0.0' }],
ccc9fe
+        ]),
ccc9fe
+      }
ccc9fe
+      this.idealTree = {
ccc9fe
+        inventory: new Map([
ccc9fe
+          ['foo', { name: 'foo', version: '1.0.0' }],
ccc9fe
+        ]),
ccc9fe
+      }
ccc9fe
     },
ccc9fe
   })
ccc9fe
 
ccc9fe
@@ -99,6 +110,17 @@ t.test('should use Arborist and run-script', async t => {
ccc9fe
       this.reify = () => {
ccc9fe
         t.ok(true, 'reify is called')
ccc9fe
       }
ccc9fe
+      this.buildIdealTree = () => {}
ccc9fe
+      this.virtualTree = {
ccc9fe
+        inventory: new Map([
ccc9fe
+          ['foo', { name: 'foo', version: '1.0.0' }],
ccc9fe
+        ]),
ccc9fe
+      }
ccc9fe
+      this.idealTree = {
ccc9fe
+        inventory: new Map([
ccc9fe
+          ['foo', { name: 'foo', version: '1.0.0' }],
ccc9fe
+        ]),
ccc9fe
+      }
ccc9fe
     },
ccc9fe
     rimraf: (path, ...args) => {
ccc9fe
       actualRimrafs++
ccc9fe
@@ -138,6 +160,17 @@ t.test('should pass flatOptions to Arborist.reify', async t => {
ccc9fe
       this.reify = async (options) => {
ccc9fe
         t.equal(options.production, true, 'should pass flatOptions to Arborist.reify')
ccc9fe
       }
ccc9fe
+      this.buildIdealTree = () => {}
ccc9fe
+      this.virtualTree = {
ccc9fe
+        inventory: new Map([
ccc9fe
+          ['foo', { name: 'foo', version: '1.0.0' }],
ccc9fe
+        ]),
ccc9fe
+      }
ccc9fe
+      this.idealTree = {
ccc9fe
+        inventory: new Map([
ccc9fe
+          ['foo', { name: 'foo', version: '1.0.0' }],
ccc9fe
+        ]),
ccc9fe
+      }
ccc9fe
     },
ccc9fe
   })
ccc9fe
   const npm = mockNpm({
ccc9fe
@@ -218,6 +251,17 @@ t.test('should remove existing node_modules before installing', async t => {
ccc9fe
         const nodeModules = contents.filter((path) => path.startsWith('node_modules'))
ccc9fe
         t.same(nodeModules, ['node_modules'], 'should only have the node_modules directory')
ccc9fe
       }
ccc9fe
+      this.buildIdealTree = () => {}
ccc9fe
+      this.virtualTree = {
ccc9fe
+        inventory: new Map([
ccc9fe
+          ['foo', { name: 'foo', version: '1.0.0' }],
ccc9fe
+        ]),
ccc9fe
+      }
ccc9fe
+      this.idealTree = {
ccc9fe
+        inventory: new Map([
ccc9fe
+          ['foo', { name: 'foo', version: '1.0.0' }],
ccc9fe
+        ]),
ccc9fe
+      }
ccc9fe
     },
ccc9fe
   })
ccc9fe
 
ccc9fe
@@ -231,3 +275,41 @@ t.test('should remove existing node_modules before installing', async t => {
ccc9fe
 
ccc9fe
   await ci.exec(null)
ccc9fe
 })
ccc9fe
+
ccc9fe
+t.test('should throw error when ideal inventory mismatches virtual', async t => {
ccc9fe
+  const CI = t.mock('../../../lib/commands/ci.js', {
ccc9fe
+    '../../../lib/utils/reify-finish.js': async () => {},
ccc9fe
+    '@npmcli/run-script': ({ event }) => {},
ccc9fe
+    '@npmcli/arborist': function () {
ccc9fe
+      this.loadVirtual = async () => {}
ccc9fe
+      this.reify = () => {}
ccc9fe
+      this.buildIdealTree = () => {}
ccc9fe
+      this.virtualTree = {
ccc9fe
+        inventory: new Map([
ccc9fe
+          ['foo', { name: 'foo', version: '1.0.0' }],
ccc9fe
+        ]),
ccc9fe
+      }
ccc9fe
+      this.idealTree = {
ccc9fe
+        inventory: new Map([
ccc9fe
+          ['foo', { name: 'foo', version: '2.0.0' }],
ccc9fe
+        ]),
ccc9fe
+      }
ccc9fe
+    },
ccc9fe
+  })
ccc9fe
+
ccc9fe
+  const npm = mockNpm({
ccc9fe
+    globalDir: 'path/to/node_modules/',
ccc9fe
+    prefix: 'foo',
ccc9fe
+    config: {
ccc9fe
+      global: false,
ccc9fe
+      'ignore-scripts': true,
ccc9fe
+    },
ccc9fe
+  })
ccc9fe
+  const ci = new CI(npm)
ccc9fe
+
ccc9fe
+  try {
ccc9fe
+    await ci.exec([])
ccc9fe
+  } catch (err) {
ccc9fe
+    t.matchSnapshot(err.message)
ccc9fe
+  }
ccc9fe
+})
ccc9fe
diff --git a/deps/npm/test/lib/utils/validate-lockfile.js b/deps/npm/test/lib/utils/validate-lockfile.js
ccc9fe
new file mode 100644
ccc9fe
index 0000000..25939c5
ccc9fe
--- /dev/null
ccc9fe
+++ b/deps/npm/test/lib/utils/validate-lockfile.js
ccc9fe
@@ -0,0 +1,82 @@
ccc9fe
+const t = require('tap')
ccc9fe
+const validateLockfile = require('../../../lib/utils/validate-lockfile.js')
ccc9fe
+
ccc9fe
+t.test('identical inventory for both idealTree and virtualTree', async t => {
ccc9fe
+  t.matchSnapshot(
ccc9fe
+    validateLockfile(
ccc9fe
+      new Map([
ccc9fe
+        ['foo', { name: 'foo', version: '1.0.0' }],
ccc9fe
+        ['bar', { name: 'bar', version: '2.0.0' }],
ccc9fe
+      ]),
ccc9fe
+      new Map([
ccc9fe
+        ['foo', { name: 'foo', version: '1.0.0' }],
ccc9fe
+        ['bar', { name: 'bar', version: '2.0.0' }],
ccc9fe
+      ])
ccc9fe
+    ),
ccc9fe
+    'should have no errors on identical inventories'
ccc9fe
+  )
ccc9fe
+})
ccc9fe
+
ccc9fe
+t.test('extra inventory items on idealTree', async t => {
ccc9fe
+  t.matchSnapshot(
ccc9fe
+    validateLockfile(
ccc9fe
+      new Map([
ccc9fe
+        ['foo', { name: 'foo', version: '1.0.0' }],
ccc9fe
+        ['bar', { name: 'bar', version: '2.0.0' }],
ccc9fe
+      ]),
ccc9fe
+      new Map([
ccc9fe
+        ['foo', { name: 'foo', version: '1.0.0' }],
ccc9fe
+        ['bar', { name: 'bar', version: '2.0.0' }],
ccc9fe
+        ['baz', { name: 'baz', version: '3.0.0' }],
ccc9fe
+      ])
ccc9fe
+    ),
ccc9fe
+    'should have missing entries error'
ccc9fe
+  )
ccc9fe
+})
ccc9fe
+
ccc9fe
+t.test('extra inventory items on virtualTree', async t => {
ccc9fe
+  t.matchSnapshot(
ccc9fe
+    validateLockfile(
ccc9fe
+      new Map([
ccc9fe
+        ['foo', { name: 'foo', version: '1.0.0' }],
ccc9fe
+        ['bar', { name: 'bar', version: '2.0.0' }],
ccc9fe
+        ['baz', { name: 'baz', version: '3.0.0' }],
ccc9fe
+      ]),
ccc9fe
+      new Map([
ccc9fe
+        ['foo', { name: 'foo', version: '1.0.0' }],
ccc9fe
+        ['bar', { name: 'bar', version: '2.0.0' }],
ccc9fe
+      ])
ccc9fe
+    ),
ccc9fe
+    'should have no errors if finding virtualTree extra items'
ccc9fe
+  )
ccc9fe
+})
ccc9fe
+
ccc9fe
+t.test('mismatching versions on inventory', async t => {
ccc9fe
+  t.matchSnapshot(
ccc9fe
+    validateLockfile(
ccc9fe
+      new Map([
ccc9fe
+        ['foo', { name: 'foo', version: '1.0.0' }],
ccc9fe
+        ['bar', { name: 'bar', version: '2.0.0' }],
ccc9fe
+      ]),
ccc9fe
+      new Map([
ccc9fe
+        ['foo', { name: 'foo', version: '2.0.0' }],
ccc9fe
+        ['bar', { name: 'bar', version: '3.0.0' }],
ccc9fe
+      ])
ccc9fe
+    ),
ccc9fe
+    'should have errors for each mismatching version'
ccc9fe
+  )
ccc9fe
+})
ccc9fe
+
ccc9fe
+t.test('missing virtualTree inventory', async t => {
ccc9fe
+  t.matchSnapshot(
ccc9fe
+    validateLockfile(
ccc9fe
+      new Map([]),
ccc9fe
+      new Map([
ccc9fe
+        ['foo', { name: 'foo', version: '1.0.0' }],
ccc9fe
+        ['bar', { name: 'bar', version: '2.0.0' }],
ccc9fe
+        ['baz', { name: 'baz', version: '3.0.0' }],
ccc9fe
+      ])
ccc9fe
+    ),
ccc9fe
+    'should have errors for each mismatching version'
ccc9fe
+  )
ccc9fe
+})
ccc9fe
-- 
ccc9fe
2.35.1
ccc9fe