Skip to content

Commit 457e0ae

Browse files
committed
fix(ci): lock file validation
Make sure to validate any lock file (either package-lock.json or npm-shrinkwrap.json) against the current install. This will properly throw an error in case any of the dependencies being installed don't match the dependencies that are currently listed in the lock file. Fixes: #2701 Fixes: #3947
1 parent 0b0a7cc commit 457e0ae

File tree

8 files changed

+304
-0
lines changed

8 files changed

+304
-0
lines changed

lib/commands/ci.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const runScript = require('@npmcli/run-script')
66
const fs = require('fs')
77
const readdir = util.promisify(fs.readdir)
88
const log = require('../utils/log-shim.js')
9+
const validateLockfile = require('../utils/validate-lockfile.js')
910

1011
const removeNodeModules = async where => {
1112
const rimrafOpts = { glob: false }
@@ -55,6 +56,28 @@ class CI extends ArboristWorkspaceCmd {
5556
}),
5657
removeNodeModules(where),
5758
])
59+
60+
// retrieves inventory of packages from loaded virtual tree (lock file)
61+
const virtualInventory = new Map(arb.virtualTree.inventory)
62+
63+
// build ideal tree step needs to come right after retrieving the virtual
64+
// inventory since it's going to erase the previous ref to virtualTree
65+
await arb.buildIdealTree()
66+
67+
// verifies that the packages from the ideal tree will match
68+
// the same versions that are present in the virtual tree (lock file)
69+
// throws a validation error in case of mismatches
70+
const errors = validateLockfile(virtualInventory, arb.idealTree.inventory)
71+
if (errors.length) {
72+
throw new Error(
73+
'`npm ci` can only install packages when your package.json and ' +
74+
'package-lock.json or npm-shrinkwrap.json are in sync. Please ' +
75+
'update your lock file with `npm install` ' +
76+
'before continuing.\n\n' +
77+
errors.join('\n') + '\n'
78+
)
79+
}
80+
5881
await arb.reify(opts)
5982

6083
const ignoreScripts = this.npm.config.get('ignore-scripts')

lib/utils/validate-lockfile.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// compares the inventory of package items in the tree
2+
// that is about to be installed (idealTree) with the inventory
3+
// of items stored in the package-lock file (virtualTree)
4+
//
5+
// Returns empty array if no errors found or an array populated
6+
// with an entry for each validation error found.
7+
function validateLockfile (virtualTree, idealTree) {
8+
const errors = []
9+
10+
// loops through the inventory of packages resulted by ideal tree,
11+
// for each package compares the versions with the version stored in the
12+
// package-lock and adds an error to the list in case of mismatches
13+
for (const [key, entry] of idealTree.entries()) {
14+
const lock = virtualTree.get(key)
15+
16+
if (!lock) {
17+
errors.push(`Missing: ${entry.name}@${entry.version} from lock file`)
18+
continue
19+
}
20+
21+
if (entry.version !== lock.version) {
22+
errors.push(`Invalid: lock file's ${lock.name}@${lock.version} does ` +
23+
`not satisfy ${entry.name}@${entry.version}`)
24+
}
25+
}
26+
return errors
27+
}
28+
29+
module.exports = validateLockfile

smoke-tests/index.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ t.cleanSnapshot = s =>
2121
.replace(/\r\n/g, '\n')
2222
.replace(/ \(in a browser\)/g, '')
2323
.replace(/^npm@.* /gm, 'npm ')
24+
.replace(/^.*debug-[0-9]+.log$/gm, '')
2425

2526
// setup server
2627
const { start, stop, registry } = require('./server.js')
@@ -320,3 +321,31 @@ t.test('npm update --save', async t => {
320321
'should have expected update --save lockfile result'
321322
)
322323
})
324+
325+
t.test('npm ci', async t => {
326+
await exec(`${npmBin} uninstall abbrev`)
327+
await exec(`${npmBin} install [email protected] --save-exact`)
328+
329+
t.equal(
330+
JSON.parse(readFile('package-lock.json')).packages['node_modules/abbrev'].version,
331+
'1.0.4',
332+
'should have stored exact installed version'
333+
)
334+
335+
await exec(`${npmBin} pkg set "dependencies.abbrev=^1.1.1"`)
336+
337+
try {
338+
const npmOpts = [
339+
`--registry=${registry}`,
340+
`--cache="${cacheLocation}"`,
341+
`--userconfig="${userconfigLocation}"`,
342+
'--no-audit',
343+
'--no-update-notifier',
344+
'--loglevel=error',
345+
].join(' ')
346+
const npmBin = `"${process.execPath}" "${npmLocation}" ${npmOpts}`
347+
await exec(`${npmBin} ci`)
348+
} catch (err) {
349+
t.matchSnapshot(err.stderr, 'should throw mismatch deps in lock file error')
350+
}
351+
})

tap-snapshots/smoke-tests/index.js.test.cjs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ Configuration fields: npm help 7 config
4040
4141
npm {CWD}
4242
43+
`
44+
45+
exports[`smoke-tests/index.js TAP npm ci > should throw mismatch deps in lock file error 1`] = `
46+
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.
47+
npm ERR!
48+
npm ERR! Invalid: lock file's [email protected] does not satisfy [email protected]
49+
npm ERR!
50+
51+
npm ERR! A complete log of this run can be found in:
52+
53+
4354
`
4455

4556
exports[`smoke-tests/index.js TAP npm diff > should have expected diff output 1`] = `
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/* IMPORTANT
2+
* This snapshot file is auto-generated, but designed for humans.
3+
* It should be checked into source control and tracked carefully.
4+
* Re-generate by setting TAP_SNAPSHOT=1 and running tests.
5+
* Make sure to inspect the output below. Do not ignore changes!
6+
*/
7+
'use strict'
8+
exports[`test/lib/commands/ci.js TAP should throw error when ideal inventory mismatches virtual > must match snapshot 1`] = `
9+
\`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.
10+
11+
Invalid: lock file's [email protected] does not satisfy [email protected]
12+
13+
`
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/* IMPORTANT
2+
* This snapshot file is auto-generated, but designed for humans.
3+
* It should be checked into source control and tracked carefully.
4+
* Re-generate by setting TAP_SNAPSHOT=1 and running tests.
5+
* Make sure to inspect the output below. Do not ignore changes!
6+
*/
7+
'use strict'
8+
exports[`test/lib/utils/validate-lockfile.js TAP extra inventory items on idealTree > should have missing entries error 1`] = `
9+
Array [
10+
"Missing: [email protected] from lock file",
11+
]
12+
`
13+
14+
exports[`test/lib/utils/validate-lockfile.js TAP extra inventory items on virtualTree > should have no errors if finding virtualTree extra items 1`] = `
15+
Array []
16+
`
17+
18+
exports[`test/lib/utils/validate-lockfile.js TAP identical inventory for both idealTree and virtualTree > should have no errors on identical inventories 1`] = `
19+
Array []
20+
`
21+
22+
exports[`test/lib/utils/validate-lockfile.js TAP mismatching versions on inventory > should have errors for each mismatching version 1`] = `
23+
Array [
24+
"Invalid: lock file's [email protected] does not satisfy [email protected]",
25+
"Invalid: lock file's [email protected] does not satisfy [email protected]",
26+
]
27+
`
28+
29+
exports[`test/lib/utils/validate-lockfile.js TAP missing virtualTree inventory > should have errors for each mismatching version 1`] = `
30+
Array [
31+
"Missing: [email protected] from lock file",
32+
"Missing: [email protected] from lock file",
33+
"Missing: [email protected] from lock file",
34+
]
35+
`

test/lib/commands/ci.js

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,17 @@ t.test('should ignore scripts with --ignore-scripts', async t => {
1919
this.reify = () => {
2020
REIFY_CALLED = true
2121
}
22+
this.buildIdealTree = () => {}
23+
this.virtualTree = {
24+
inventory: new Map([
25+
['foo', { name: 'foo', version: '1.0.0' }],
26+
]),
27+
}
28+
this.idealTree = {
29+
inventory: new Map([
30+
['foo', { name: 'foo', version: '1.0.0' }],
31+
]),
32+
}
2233
},
2334
})
2435

@@ -99,6 +110,17 @@ t.test('should use Arborist and run-script', async t => {
99110
this.reify = () => {
100111
t.ok(true, 'reify is called')
101112
}
113+
this.buildIdealTree = () => {}
114+
this.virtualTree = {
115+
inventory: new Map([
116+
['foo', { name: 'foo', version: '1.0.0' }],
117+
]),
118+
}
119+
this.idealTree = {
120+
inventory: new Map([
121+
['foo', { name: 'foo', version: '1.0.0' }],
122+
]),
123+
}
102124
},
103125
rimraf: (path, ...args) => {
104126
actualRimrafs++
@@ -138,6 +160,17 @@ t.test('should pass flatOptions to Arborist.reify', async t => {
138160
this.reify = async (options) => {
139161
t.equal(options.production, true, 'should pass flatOptions to Arborist.reify')
140162
}
163+
this.buildIdealTree = () => {}
164+
this.virtualTree = {
165+
inventory: new Map([
166+
['foo', { name: 'foo', version: '1.0.0' }],
167+
]),
168+
}
169+
this.idealTree = {
170+
inventory: new Map([
171+
['foo', { name: 'foo', version: '1.0.0' }],
172+
]),
173+
}
141174
},
142175
})
143176
const npm = mockNpm({
@@ -218,6 +251,17 @@ t.test('should remove existing node_modules before installing', async t => {
218251
const nodeModules = contents.filter((path) => path.startsWith('node_modules'))
219252
t.same(nodeModules, ['node_modules'], 'should only have the node_modules directory')
220253
}
254+
this.buildIdealTree = () => {}
255+
this.virtualTree = {
256+
inventory: new Map([
257+
['foo', { name: 'foo', version: '1.0.0' }],
258+
]),
259+
}
260+
this.idealTree = {
261+
inventory: new Map([
262+
['foo', { name: 'foo', version: '1.0.0' }],
263+
]),
264+
}
221265
},
222266
})
223267

@@ -231,3 +275,41 @@ t.test('should remove existing node_modules before installing', async t => {
231275

232276
await ci.exec(null)
233277
})
278+
279+
t.test('should throw error when ideal inventory mismatches virtual', async t => {
280+
const CI = t.mock('../../../lib/commands/ci.js', {
281+
'../../../lib/utils/reify-finish.js': async () => {},
282+
'@npmcli/run-script': ({ event }) => {},
283+
'@npmcli/arborist': function () {
284+
this.loadVirtual = async () => {}
285+
this.reify = () => {}
286+
this.buildIdealTree = () => {}
287+
this.virtualTree = {
288+
inventory: new Map([
289+
['foo', { name: 'foo', version: '1.0.0' }],
290+
]),
291+
}
292+
this.idealTree = {
293+
inventory: new Map([
294+
['foo', { name: 'foo', version: '2.0.0' }],
295+
]),
296+
}
297+
},
298+
})
299+
300+
const npm = mockNpm({
301+
globalDir: 'path/to/node_modules/',
302+
prefix: 'foo',
303+
config: {
304+
global: false,
305+
'ignore-scripts': true,
306+
},
307+
})
308+
const ci = new CI(npm)
309+
310+
try {
311+
await ci.exec([])
312+
} catch (err) {
313+
t.matchSnapshot(err.message)
314+
}
315+
})

test/lib/utils/validate-lockfile.js

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
const t = require('tap')
2+
const validateLockfile = require('../../../lib/utils/validate-lockfile.js')
3+
4+
t.test('identical inventory for both idealTree and virtualTree', async t => {
5+
t.matchSnapshot(
6+
validateLockfile(
7+
new Map([
8+
['foo', { name: 'foo', version: '1.0.0' }],
9+
['bar', { name: 'bar', version: '2.0.0' }],
10+
]),
11+
new Map([
12+
['foo', { name: 'foo', version: '1.0.0' }],
13+
['bar', { name: 'bar', version: '2.0.0' }],
14+
])
15+
),
16+
'should have no errors on identical inventories'
17+
)
18+
})
19+
20+
t.test('extra inventory items on idealTree', async t => {
21+
t.matchSnapshot(
22+
validateLockfile(
23+
new Map([
24+
['foo', { name: 'foo', version: '1.0.0' }],
25+
['bar', { name: 'bar', version: '2.0.0' }],
26+
]),
27+
new Map([
28+
['foo', { name: 'foo', version: '1.0.0' }],
29+
['bar', { name: 'bar', version: '2.0.0' }],
30+
['baz', { name: 'baz', version: '3.0.0' }],
31+
])
32+
),
33+
'should have missing entries error'
34+
)
35+
})
36+
37+
t.test('extra inventory items on virtualTree', async t => {
38+
t.matchSnapshot(
39+
validateLockfile(
40+
new Map([
41+
['foo', { name: 'foo', version: '1.0.0' }],
42+
['bar', { name: 'bar', version: '2.0.0' }],
43+
['baz', { name: 'baz', version: '3.0.0' }],
44+
]),
45+
new Map([
46+
['foo', { name: 'foo', version: '1.0.0' }],
47+
['bar', { name: 'bar', version: '2.0.0' }],
48+
])
49+
),
50+
'should have no errors if finding virtualTree extra items'
51+
)
52+
})
53+
54+
t.test('mismatching versions on inventory', async t => {
55+
t.matchSnapshot(
56+
validateLockfile(
57+
new Map([
58+
['foo', { name: 'foo', version: '1.0.0' }],
59+
['bar', { name: 'bar', version: '2.0.0' }],
60+
]),
61+
new Map([
62+
['foo', { name: 'foo', version: '2.0.0' }],
63+
['bar', { name: 'bar', version: '3.0.0' }],
64+
])
65+
),
66+
'should have errors for each mismatching version'
67+
)
68+
})
69+
70+
t.test('missing virtualTree inventory', async t => {
71+
t.matchSnapshot(
72+
validateLockfile(
73+
new Map([]),
74+
new Map([
75+
['foo', { name: 'foo', version: '1.0.0' }],
76+
['bar', { name: 'bar', version: '2.0.0' }],
77+
['baz', { name: 'baz', version: '3.0.0' }],
78+
])
79+
),
80+
'should have errors for each mismatching version'
81+
)
82+
})

0 commit comments

Comments
 (0)