diff --git a/.changeset/rich-monkeys-fry.md b/.changeset/rich-monkeys-fry.md new file mode 100644 index 0000000..5853b59 --- /dev/null +++ b/.changeset/rich-monkeys-fry.md @@ -0,0 +1,5 @@ +--- +'lockfile-lint-api': minor +--- + +Add support for npm lockfile v3 format diff --git a/package-lock.json b/package-lock.json index d823a4b..cc9f3b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ }, "node_modules/@ampproject/remapping": { "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "resolved": "http://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", "dev": true, "dependencies": { @@ -40,8 +40,8 @@ }, "node_modules/@babel/code-frame": { "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.5.tgz", - "integrity": "sha512-Xmwn266vad+6DAqEB2A6V/CcZVp62BbwVmcOJc2RPuwih1kw02TjQvWVWlcKGbBPd+8/0V5DEkOcizRGYsspYQ==", + "resolved": "http://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.5.tgz", + "integrity": "", "dependencies": { "@babel/highlight": "^7.22.5" }, diff --git a/packages/lockfile-lint-api/__tests__/__fixtures__/bad-package-lock-v3.json b/packages/lockfile-lint-api/__tests__/__fixtures__/bad-package-lock-v3.json new file mode 100644 index 0000000..1097ccc --- /dev/null +++ b/packages/lockfile-lint-api/__tests__/__fixtures__/bad-package-lock-v3.json @@ -0,0 +1,23 @@ +{ + "name": "a", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages2": { + "": { + "name": "a", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "debug": "^4.3.4" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved2": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true + } + +} + \ No newline at end of file diff --git a/packages/lockfile-lint-api/__tests__/__fixtures__/package-lock-v3-empty.json b/packages/lockfile-lint-api/__tests__/__fixtures__/package-lock-v3-empty.json new file mode 100644 index 0000000..7728695 --- /dev/null +++ b/packages/lockfile-lint-api/__tests__/__fixtures__/package-lock-v3-empty.json @@ -0,0 +1,17 @@ +{ + "name": "a", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "a", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "debug": "^4.3.4" + } + } + } +} + \ No newline at end of file diff --git a/packages/lockfile-lint-api/__tests__/__fixtures__/package-lock-v3.json b/packages/lockfile-lint-api/__tests__/__fixtures__/package-lock-v3.json new file mode 100644 index 0000000..7d2f3d1 --- /dev/null +++ b/packages/lockfile-lint-api/__tests__/__fixtures__/package-lock-v3.json @@ -0,0 +1,84 @@ +{ + "name": "a", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "a", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "debug": "^4.3.4" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/@ampproject/remapping": { + "version": "2.2.1", + "resolved": "http://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/core/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "packages/lockfile-lint/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "packages/lockfile-lint/node_modules/cliui": { + "version": "7.0.4", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + } + } + } + \ No newline at end of file diff --git a/packages/lockfile-lint-api/__tests__/parseNpmLockfile3.test.js b/packages/lockfile-lint-api/__tests__/parseNpmLockfile3.test.js new file mode 100644 index 0000000..28f6f48 --- /dev/null +++ b/packages/lockfile-lint-api/__tests__/parseNpmLockfile3.test.js @@ -0,0 +1,53 @@ +/* eslint-disable no-new */ +'use strict' + +const ParseLockfile = require('../src/ParseLockfile') +const path = require('path') + +describe('ParseLockfile v3 Npm', () => { + it('parsing an npm lockfile returns an object with packages', () => { + const mockNpmLockfilePath = path.join(__dirname, './__fixtures__/package-lock-v3.json') + const options = { + lockfilePath: mockNpmLockfilePath, + lockfileType: 'npm' + } + const parser = new ParseLockfile(options) + const lockfile = parser.parseSync() + + expect(lockfile.type).toEqual('success') + expect(lockfile.object).toEqual( + expect.objectContaining({ + '@ampproject/remapping@2.2.1-6896846cb1525fe356a0a09c28e387a034d364ab': expect.any(Object), + 'argparse@2.0.1-d71b6b43bc0d034f7b86d6d1149d6a43be13f2ed': expect.any(Object), + 'cliui@7.0.4-a26e875b912c887d1429fe2e16ed66bcbe362d61': expect.any(Object), + 'debug@4.3.4-c15d73814bfc58727435c213e23203e48f036cd9': expect.any(Object), + 'ms@2.1.2-d6934ce87f6e568c4f5d6d9f6e5c5697992e7b91': expect.any(Object) + }) + ) + }) + + it('parsing an npm lockfile with invalid content throws an error', () => { + const mockNpmLockfilePath = path.join(__dirname, './__fixtures__/bad-package-lock-v3.json') + const options = { + lockfilePath: mockNpmLockfilePath, + lockfileType: 'npm' + } + const parser = new ParseLockfile(options) + expect(() => parser.parseSync()).toThrowError( + `Unable to parse npm lockfile "${mockNpmLockfilePath}"` + ) + }) + + it('parsing an npm lockfile with no packages doesnt trigger an error', () => { + const mockNpmLockfilePath = path.join(__dirname, './__fixtures__/package-lock-v3-empty.json') + const options = { + lockfilePath: mockNpmLockfilePath, + lockfileType: 'npm' + } + const parser = new ParseLockfile(options) + const lockfile = parser.parseSync() + + expect(lockfile.type).toEqual('success') + expect(lockfile.object).toEqual({}) + }) +}) diff --git a/packages/lockfile-lint-api/src/ParseLockfile.js b/packages/lockfile-lint-api/src/ParseLockfile.js index 7b82d7f..9c9d8fe 100644 --- a/packages/lockfile-lint-api/src/ParseLockfile.js +++ b/packages/lockfile-lint-api/src/ParseLockfile.js @@ -27,10 +27,10 @@ function checkSampleContent (lockfile, isYarnBerry) { const [sampleKey, sampleValue] = Object.entries(lockfile)[isYarnBerry ? 1 : 0] return ( sampleKey.match(/.*@.*/) && - (sampleValue && - typeof sampleValue === 'object' && - sampleValue.hasOwnProperty('version') && - (sampleValue.hasOwnProperty('resolved') || sampleValue.hasOwnProperty('resolution'))) + sampleValue && + typeof sampleValue === 'object' && + sampleValue.hasOwnProperty('version') && + (sampleValue.hasOwnProperty('resolved') || sampleValue.hasOwnProperty('resolution')) ) } /** @@ -174,7 +174,20 @@ class ParseLockfile { // transform original format of npm's package-json to match yarns // so we have a unified format to validate against - const npmDepsTree = packageJsonParsed.dependencies + // const npmDepsTree = packageJsonParsed.dependencies + let npmDepsTree = null + + if ( + packageJsonParsed.dependencies && + Object.keys(packageJsonParsed.dependencies).length > 0 + ) { + npmDepsTree = packageJsonParsed.dependencies + } + + if (packageJsonParsed.packages && Object.keys(packageJsonParsed.packages).length > 0) { + npmDepsTree = packageJsonParsed.packages + } + flattenedDepTree = npmDepsTree ? this._flattenNpmDepsTree(npmDepsTree) : {} } catch (error) { throw new ParsingError(PARSE_NPMLOCKFILE_FAILED, this.options.lockfilePath, error) @@ -188,25 +201,61 @@ class ParseLockfile { _flattenNpmDepsTree (npmDepsTree, npmDepMap = {}) { for (const [depName, depMetadata] of Object.entries(npmDepsTree)) { - const depMetadataShortend = { - version: depMetadata.version, - resolved: depMetadata.resolved ? depMetadata.resolved : depMetadata.version, - integrity: depMetadata.integrity, - requires: depMetadata.requires - } - const hashedDepValues = hash(depMetadataShortend) + // only evaluate dependency metadata if it's an object with actual metadata + // @TODO potentially, this entry can be just a dependency name and version + // which would inject a new dependency on npm install - warn based on diff? + if (typeof depMetadata === 'object' && depName.length > 0) { + const depMetadataShortend = { + version: depMetadata.version, + resolved: depMetadata.resolved ? depMetadata.resolved : depMetadata.version, + integrity: depMetadata.integrity, + requires: depMetadata.requires + } + const hashedDepValues = hash(depMetadataShortend) - npmDepMap[`${depName}@${depMetadata.version}-${hashedDepValues}`] = depMetadataShortend + // @TODO should we implement a clean package name + // or stay aligned with npm's lockfile reporting of full package path on disk? + // it has advantages in monorepos, such as reporting something like: + // packages/lockfile-lint/node_modules/yargs-parser + // instead of just + // yargs-parser + // + // npm package-lock.json v3 has depName set to path on disk, i.e: + // "node_modules/@babel/compat-data": { + // "version": "7.22.5", + // ..} + // we strip off the 'node_modules/' suffix to print pretty package name + // let depNameClean = depName + // if (depName.indexOf('node_modules/') === 0) { + // depNameClean = depName.substring('node_modules/'.length) + // } + const depNameClean = this.extractedPackageName(depName) - const nestedDepsTree = depMetadata.dependencies + npmDepMap[`${depNameClean}@${depMetadata.version}-${hashedDepValues}`] = depMetadataShortend - if (nestedDepsTree && Object.keys(nestedDepsTree).length !== 0) { - this._flattenNpmDepsTree(nestedDepsTree, npmDepMap) + const nestedDepsTree = depMetadata.dependencies + + if (nestedDepsTree && Object.keys(nestedDepsTree).length !== 0) { + this._flattenNpmDepsTree(nestedDepsTree, npmDepMap) + } } } return npmDepMap } + + extractedPackageName (packageName) { + const parts = packageName.split('/') + const lastIndex = parts.lastIndexOf('node_modules') + + if (lastIndex === -1) { + // If "node_modules" is not found, return the last part of the input + return parts[parts.length - 1] + } else { + // If "node_modules" is found, return the part after it + return parts.slice(lastIndex + 1).join('/') + } + } } module.exports = ParseLockfile